diff --git a/.dockerignore b/.dockerignore index 7f9e68cd..248ee7db 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,9 +14,6 @@ target/ /data/ data/ -# Backtest tooling output: large JSON fixtures + Python venv state. -# Re-collected on demand via `tools/backtest-collect/backtest_collect.py`. -tools/backtest-collect/fixtures-*.json tools/baseline-latency/data/ tools/**/__pycache__/ tools/**/*.pyc diff --git a/.github/actions/rust-setup/action.yml b/.github/actions/rust-setup/action.yml index b5a5c12b..83437070 100644 --- a/.github/actions/rust-setup/action.yml +++ b/.github/actions/rust-setup/action.yml @@ -1,5 +1,5 @@ name: rust-setup -description: Pinned Rust 1.94 + sccache (R2 backend, configured at the workflow env level) + registry-only Swatinem cache shared across jobs. +description: Pinned Rust 1.94 + sccache (GitHub Actions cache backend, SCCACHE_GHA_ENABLED) + registry-only Swatinem cache shared across jobs. inputs: targets: @@ -11,7 +11,7 @@ inputs: save-if: description: > May this run WRITE the registry cache? Defaults to always: Swatinem now caches - only the small ~/.cargo registry (target/ moved to sccache/R2), and the GHA + only the small ~/.cargo registry (target/ moved to sccache over the GitHub Actions cache), and the GHA cache is branch-scoped, so a PR saving its own registry warms its re-runs without touching the develop baseline. Set false to make a job restore-only. default: "true" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0f2788e..41c17199 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,24 +19,14 @@ env: # jobs: any per-job RUSTFLAGS delta re-fingerprints the whole graph and every # sccache object key, defeating reuse. RUSTFLAGS: "-D warnings" - # sccache caches individual rustc invocations across jobs AND runs, including the - # workspace crates (which Swatinem drops). Requires incremental off. Backed by a - # Cloudflare R2 bucket (S3 API), so the cache is shared across jobs, runs, the - # Dockerfile build, and the local host sccache — one silo, zero egress cost. - # Set at the workflow env level (not the composite) because composite actions - # cannot read the `secrets` context. Keys are content-addressed on the full - # compiler input, and the sole build.rs (cow-venue) + nexum-macros are - # deterministic, so PR builds writing to the shared bucket write correct objects - # under correct keys (no poisoning); bound storage with an R2 lifecycle-expiry - # rule on the bucket (sccache does not evict cloud backends itself). + # sccache caches individual rustc invocations across jobs and runs, including the + # workspace crates (which Swatinem drops). Requires incremental off. Backed by the + # GitHub Actions cache (SCCACHE_GHA_ENABLED): free, no secrets, and available on + # fork pull_request runs, so forks build cached too. The per-repo cache evicts LRU + # on its own; a miss degrades to a cold compile, never a build failure. RUSTC_WRAPPER: sccache CARGO_INCREMENTAL: "0" - SCCACHE_BUCKET: shepherd-sccache - SCCACHE_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com - SCCACHE_REGION: auto - SCCACHE_S3_KEY_PREFIX: ci/rust-1.94 - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + SCCACHE_GHA_ENABLED: "true" jobs: fmt: @@ -78,7 +68,8 @@ jobs: - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 with: tool: nextest - # Build all 15 guest module wasms ONCE (release/wasm32-wasip2): the single + # Build all 17 guest wasms ONCE (16 modules + the cow adapter, + # release/wasm32-wasip2): the single # source of truth for guest buildability and the artifacts the integration # tests load. Replaces the deleted 9-way build-module matrix, which recompiled # the shared wasm dependency graph ~9x cold. Per-module size report folded in; @@ -87,9 +78,14 @@ jobs: run: | cargo build --release --target wasm32-wasip2 --locked \ -p example -p twap-monitor -p ethflow-watcher -p price-alert \ - -p balance-tracker -p stop-loss -p http-probe \ - -p clock-reader -p flaky-bomb -p fuel-bomb \ - -p memory-bomb -p panic-bomb -p slow-host + -p balance-tracker -p http-probe -p echo-venue \ + -p echo-client -p echo-keeper -p clock-reader -p flaky-bomb -p flaky-venue \ + -p fuel-bomb -p memory-bomb -p panic-bomb -p slow-host + # Separate invocation on purpose: unifying `cow-venue/adapter` + # into the module build would link the adapter's component + # export glue into every keeper module wasm. + cargo build --release --target wasm32-wasip2 --locked \ + -p cow-venue --features cow-venue/adapter { echo "### module .wasm sizes" echo "| module | bytes |" @@ -143,3 +139,42 @@ jobs: CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++ AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar run: cargo check --workspace --all-features --locked --target aarch64-unknown-linux-gnu + + # Blocking zero-leak gate: host-layer crate graphs stay venue-free, the + # runtime Rust sources carry no charter symbol and no privileged router + # field, and nexum:host names no foreign WIT package and resolves as a + # leaf (scripts/check-venue-agnostic.sh). + venue-agnostic: + name: venue-agnostic + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: ./.github/actions/rust-setup + - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: wasm-tools,ripgrep + - run: ./scripts/check-venue-agnostic.sh + + # Blocking orderbook-only gate: the CoW venue crate carries no + # composable symbol (scripts/check-cow-orderbook-only.sh). + cow-orderbook-only: + name: cow-orderbook-only + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: ripgrep + - run: ./scripts/check-cow-orderbook-only.sh + + # Blocking dep-sync gate for the transitional three-grouping workspace: every + # crate is grouped under nexum/videre/shepherd and depends only within or below + # its tier, so no upward edge becomes a circular repo dependency at the carve + # (scripts/check-carve-groups.sh, M5 #403). + carve-groups: + name: carve-groups + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: ./.github/actions/rust-setup + - run: ./scripts/check-carve-groups.sh diff --git a/.gitignore b/.gitignore index d34b1806..35532e08 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ skills-lock.json # Engine runtime state (default state_dir from engine.toml). data/ +# Shipped crate data slices are source, not runtime state: keep them. +!crates/*/data/ +!crates/*/data/** # E2E automation: rendered configs with embedded RPC keys + script state # never get committed. diff --git a/Cargo.lock b/Cargo.lock index 93ac4ee1..f686c938 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -227,6 +227,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" dependencies = [ "alloy-rlp", + "borsh", "bytes", "cfg-if", "const-hex", @@ -280,7 +281,7 @@ dependencies = [ "lru", "parking_lot", "pin-project", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "thiserror 2.0.18", @@ -349,7 +350,7 @@ dependencies = [ "alloy-transport-ws", "futures", "pin-project", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "tokio", @@ -539,7 +540,7 @@ dependencies = [ "alloy-json-rpc", "alloy-transport", "itertools 0.14.0", - "reqwest 0.13.4", + "reqwest", "serde_json", "tower", "tracing", @@ -1483,6 +1484,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "composable-cow" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "borsh", + "cow-venue", + "cowprotocol", + "nexum-sdk", + "nexum-sdk-test", + "proptest", + "tracing", + "videre-sdk", +] + [[package]] name = "const-hex" version = "1.19.1" @@ -1553,6 +1570,26 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cow-venue" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "borsh", + "cowprotocol", + "http", + "nexum-sdk", + "serde", + "serde_json", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "videre-sdk", + "videre-test", + "wit-bindgen 0.59.0", +] + [[package]] name = "cowprotocol" version = "0.2.0" @@ -1588,14 +1625,11 @@ dependencies = [ "cowprotocol-primitives", "cowprotocol-signing", "js-sys", - "reqwest 0.12.28", "serde", "serde_json", "serde_with", "thiserror 2.0.18", "url", - "wasm-bindgen", - "wasm-bindgen-futures", ] [[package]] @@ -2138,6 +2172,33 @@ dependencies = [ "spki", ] +[[package]] +name = "echo-client" +version = "0.1.0" +dependencies = [ + "nexum-sdk", + "videre-sdk", + "wit-bindgen 0.58.0", +] + +[[package]] +name = "echo-keeper" +version = "0.1.0" +dependencies = [ + "nexum-sdk", + "videre-sdk", + "wit-bindgen 0.59.0", +] + +[[package]] +name = "echo-venue" +version = "0.1.0" +dependencies = [ + "videre-sdk", + "videre-test", + "wit-bindgen 0.59.0", +] + [[package]] name = "educe" version = "0.6.0" @@ -2242,12 +2303,12 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", + "cow-venue", "cowprotocol", "nexum-sdk", "nexum-sdk-test", - "shepherd-sdk", - "shepherd-sdk-test", "tracing", + "videre-sdk", "wit-bindgen 0.59.0", ] @@ -2266,6 +2327,7 @@ dependencies = [ name = "example" version = "0.1.0" dependencies = [ + "nexum-sdk", "wit-bindgen 0.59.0", ] @@ -2347,6 +2409,14 @@ dependencies = [ "wit-bindgen 0.59.0", ] +[[package]] +name = "flaky-venue" +version = "0.1.0" +dependencies = [ + "videre-sdk", + "wit-bindgen 0.59.0", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2819,7 +2889,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.8", ] [[package]] @@ -3537,14 +3606,32 @@ name = "nexum-cli" version = "0.2.0" dependencies = [ "anyhow", - "clap", + "nexum-launch", "nexum-runtime", - "shepherd-cow-host", "tokio", +] + +[[package]] +name = "nexum-launch" +version = "0.2.0" +dependencies = [ + "anyhow", + "clap", + "nexum-runtime", "tracing", "tracing-subscriber", ] +[[package]] +name = "nexum-module-macros" +version = "0.1.0" +dependencies = [ + "nexum-world", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "nexum-runtime" version = "0.2.0" @@ -3557,6 +3644,7 @@ dependencies = [ "alloy-transport", "alloy-transport-ws", "anyhow", + "async-trait", "bytes", "futures", "http", @@ -3566,6 +3654,7 @@ dependencies = [ "metrics-exporter-prometheus", "nexum-runtime", "nexum-tasks", + "nexum-world", "redb", "serde", "serde_json", @@ -3588,15 +3677,24 @@ dependencies = [ name = "nexum-sdk" version = "0.1.0" dependencies = [ + "alloy-chains", + "alloy-json-rpc", "alloy-primitives", + "alloy-provider", + "alloy-rpc-client", "alloy-rpc-types-eth", "alloy-sol-types", + "alloy-transport", + "borsh", "http", + "nexum-module-macros", + "nexum-sdk-test", + "nexum-world", "proptest", "serde_json", - "shepherd-sdk-test", "strum", "thiserror 2.0.18", + "tower", "tracing", "tracing-core", "wstd", @@ -3619,6 +3717,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "nexum-world" +version = "0.1.0" +dependencies = [ + "strum", + "syn 2.0.118", + "tempfile", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "no-std-probe" +version = "0.1.0" +dependencies = [ + "videre-sdk", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3746,7 +3861,7 @@ dependencies = [ "axum", "clap", "rand 0.10.2", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "tokio", @@ -4424,44 +4539,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots 1.0.8", -] - [[package]] name = "reqwest" version = "0.13.4" @@ -4543,6 +4620,7 @@ dependencies = [ "ark-ff 0.3.0", "ark-ff 0.4.2", "ark-ff 0.5.0", + "borsh", "bytes", "fastrlp 0.3.1", "fastrlp 0.4.0", @@ -5063,66 +5141,14 @@ dependencies = [ ] [[package]] -name = "shepherd-backtest" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "ethflow-watcher", - "hex", - "nexum-sdk", - "serde", - "serde_json", - "shepherd-sdk-test", -] - -[[package]] -name = "shepherd-cow-host" +name = "shepherd" version = "0.2.0" dependencies = [ - "alloy-chains", - "alloy-primitives", - "alloy-rpc-types-eth", "anyhow", - "cowprotocol", - "http", - "metrics", + "nexum-launch", "nexum-runtime", - "reqwest 0.13.4", - "serde", - "serde_json", - "strum", - "tempfile", - "thiserror 2.0.18", "tokio", - "toml 1.1.2+spec-1.1.0", - "tracing", - "url", - "wasmtime", - "wiremock", -] - -[[package]] -name = "shepherd-sdk" -version = "0.1.0" -dependencies = [ - "alloy-primitives", - "alloy-sol-types", - "cowprotocol", - "nexum-sdk", - "proptest", - "strum", - "thiserror 2.0.18", -] - -[[package]] -name = "shepherd-sdk-test" -version = "0.1.0" -dependencies = [ - "nexum-sdk", - "nexum-sdk-test", - "serde_json", - "shepherd-sdk", + "videre-host", ] [[package]] @@ -5233,22 +5259,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "stop-loss" -version = "0.1.0" -dependencies = [ - "alloy-primitives", - "alloy-sol-types", - "cowprotocol", - "nexum-sdk", - "nexum-sdk-test", - "serde_json", - "shepherd-sdk", - "shepherd-sdk-test", - "tracing", - "wit-bindgen 0.59.0", -] - [[package]] name = "strsim" version = "0.11.1" @@ -5789,15 +5799,15 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", + "composable-cow", + "cow-venue", "cowprotocol", "nexum-sdk", "nexum-sdk-test", "serde_json", - "shepherd-sdk", - "shepherd-sdk-test", - "strum", - "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", "tracing", + "videre-sdk", "wit-bindgen 0.59.0", ] @@ -5920,6 +5930,76 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "videre-host" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "nexum-runtime", + "nexum-tasks", + "serde", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml 1.1.2+spec-1.1.0", + "tracing", + "videre-host", + "videre-status-body", + "wasmtime", +] + +[[package]] +name = "videre-macros" +version = "0.1.0" +dependencies = [ + "nexum-world", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "videre-sdk" +version = "0.1.0" +dependencies = [ + "borsh", + "http", + "nexum-sdk", + "nexum-sdk-test", + "strum", + "thiserror 2.0.18", + "tracing", + "videre-macros", + "videre-status-body", + "wit-bindgen 0.59.0", +] + +[[package]] +name = "videre-status-body" +version = "0.1.0" +dependencies = [ + "borsh", + "thiserror 2.0.18", +] + +[[package]] +name = "videre-test" +version = "0.1.0" +dependencies = [ + "borsh", + "hex", + "http", + "nexum-sdk", + "nexum-sdk-test", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "videre-sdk", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -6071,6 +6151,18 @@ dependencies = [ "wasmparser 0.253.0", ] +[[package]] +name = "wasm-metadata" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f998ccc6e012f7b86865eb2a106c8a0422017a1a88977ce01a69f2244be2e57" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder 0.251.0", + "wasmparser 0.251.0", +] + [[package]] name = "wasm-metadata" version = "0.253.0" @@ -6811,13 +6903,33 @@ dependencies = [ "bitflags", ] +[[package]] +name = "wit-bindgen" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a43552cfa071f246cfd99e5dbb23710dfe7336b3259e09339818483359470749" +dependencies = [ + "wit-bindgen-rust-macro 0.58.0", +] + [[package]] name = "wit-bindgen" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94c5e45f6d4cfaca727c1c48989ab3e05bb289bf84fbad226e1cfbbef2c04b7f" dependencies = [ - "wit-bindgen-rust-macro", + "wit-bindgen-rust-macro 0.59.0", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4738d1c9a78e97bc7f664bfafd5d8e67d7bb26faa5c41e6d628e8bbdad3ec351" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.251.0", ] [[package]] @@ -6831,6 +6943,22 @@ dependencies = [ "wit-parser 0.253.0", ] +[[package]] +name = "wit-bindgen-rust" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130ce1f531bc9f9a75922244aa773bf5e2117fda1ef4a86b9f98d6b8135eb46" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.118", + "wasm-metadata 0.251.0", + "wit-bindgen-core 0.58.0", + "wit-component 0.251.0", +] + [[package]] name = "wit-bindgen-rust" version = "0.59.0" @@ -6842,9 +6970,24 @@ dependencies = [ "indexmap 2.14.0", "prettyplease", "syn 2.0.118", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", + "wasm-metadata 0.253.0", + "wit-bindgen-core 0.59.0", + "wit-component 0.253.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07296369e4d598e7e79b64eef66f724d83324ea671bcf677d78fc5cf92604ae5" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.118", + "wit-bindgen-core 0.58.0", + "wit-bindgen-rust 0.58.0", ] [[package]] @@ -6858,8 +7001,27 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.118", - "wit-bindgen-core", - "wit-bindgen-rust", + "wit-bindgen-core 0.59.0", + "wit-bindgen-rust 0.59.0", +] + +[[package]] +name = "wit-component" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a5e60173c413659c689f0581b0cf5d1a2404077568f9ffdce748a9eb2fc913" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.251.0", + "wasm-metadata 0.251.0", + "wasmparser 0.251.0", + "wit-parser 0.251.0", ] [[package]] @@ -6876,7 +7038,7 @@ dependencies = [ "serde_derive", "serde_json", "wasm-encoder 0.253.0", - "wasm-metadata", + "wasm-metadata 0.253.0", "wasmparser 0.253.0", "wit-parser 0.253.0", ] diff --git a/Cargo.toml b/Cargo.toml index 516a45ac..6f5a27d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,29 +1,40 @@ [workspace] members = [ - "crates/nexum-cli", - "crates/nexum-runtime", - "crates/nexum-sdk", - "crates/nexum-sdk-test", - "crates/nexum-tasks", - "crates/shepherd-backtest", - "crates/shepherd-cow-host", - "crates/shepherd-sdk", - "crates/shepherd-sdk-test", - "modules/ethflow-watcher", - "modules/example", - "modules/examples/balance-tracker", - "modules/examples/http-probe", - "modules/examples/price-alert", - "modules/examples/stop-loss", - "modules/fixtures/clock-reader", - "modules/fixtures/flaky-bomb", - "modules/fixtures/fuel-bomb", - "modules/fixtures/memory-bomb", - "modules/fixtures/panic-bomb", - "modules/fixtures/slow-host", - "modules/twap-monitor", - "tools/load-gen", - "tools/orderbook-mock", + "shepherd/crates/composable-cow", + "shepherd/crates/cow-venue", + "nexum/crates/nexum-cli", + "nexum/crates/nexum-launch", + "nexum/crates/nexum-module-macros", + "nexum/crates/nexum-runtime", + "nexum/crates/nexum-sdk", + "nexum/crates/nexum-sdk-test", + "nexum/crates/nexum-tasks", + "nexum/crates/nexum-world", + "videre/crates/no-std-probe", + "shepherd/crates/shepherd", + "videre/crates/videre-host", + "videre/crates/videre-macros", + "videre/crates/videre-sdk", + "videre/crates/videre-status-body", + "videre/crates/videre-test", + "shepherd/modules/ethflow-watcher", + "nexum/modules/example", + "nexum/modules/examples/balance-tracker", + "videre/modules/examples/echo-client", + "videre/modules/examples/echo-keeper", + "videre/modules/examples/echo-venue", + "nexum/modules/examples/http-probe", + "nexum/modules/examples/price-alert", + "nexum/modules/fixtures/clock-reader", + "nexum/modules/fixtures/flaky-bomb", + "videre/modules/fixtures/flaky-venue", + "nexum/modules/fixtures/fuel-bomb", + "nexum/modules/fixtures/memory-bomb", + "nexum/modules/fixtures/panic-bomb", + "nexum/modules/fixtures/slow-host", + "shepherd/modules/twap-monitor", + "nexum/tools/load-gen", + "shepherd/tools/orderbook-mock", ] resolver = "2" @@ -48,11 +59,20 @@ anyhow = "1" thiserror = "2" tokio = { version = "1", features = ["full"] } futures = "0.3" +# Cold `dyn` boot paths only (`ProviderKind::install`); hot guest traits +# use native async-fn-in-trait. +async-trait = "0.1" # Serde + config. serde = { version = "1", features = ["derive"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } +# Borsh wire codec behind the venue SDK's versioned `IntentBody` bodies. +# The venue SDK re-exports the runtime crate for its derive's generated +# code; `derive` is on so venue payload types can `#[derive(BorshSerialize, +# BorshDeserialize)]` through the same dependency. +borsh = { version = "1", features = ["derive"] } + # Observability. tracing = "0.1" # `tracing-core` alone (no subscriber registry) backs the guest-side @@ -80,7 +100,7 @@ auto_impl = "1" derive_more = { version = "2", default-features = false, features = ["full"] } # CLI parser. Used by every binary crate (engine, load-gen, -# orderbook-mock, shepherd-backtest) via the derive macro. +# orderbook-mock) via the derive macro. clap = { version = "4", features = ["derive"] } # alloy stack. Engine uses the full provider/transport surface; @@ -89,7 +109,9 @@ clap = { version = "4", features = ["derive"] } # moves every consumer at once. alloy-primitives = { version = "1.6", default-features = false, features = ["std", "serde"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } -alloy-provider = { version = "2.1", default-features = false, features = ["ws", "ipc", "pubsub", "reqwest"] } +# Featureless here so the guest SDK's wasm build stays transport-free; +# the engine and tooling add ws/ipc/pubsub/reqwest at their call sites. +alloy-provider = { version = "2.1", default-features = false } alloy-rpc-types-eth = { version = "2.1", default-features = false, features = ["std"] } alloy-transport-ws = { version = "2.1", default-features = false } # Typed EIP-155 chain ids for config keys, provider/orderbook pools, and @@ -97,19 +119,12 @@ alloy-transport-ws = { version = "2.1", default-features = false } # the engine can key maps and signatures on `Chain` instead of a bare u64. alloy-chains = { version = "0.2", default-features = false, features = ["std", "serde"] } -# CoW Protocol bindings. Pinned to one version across the workspace -# (was `1.0.0-alpha` in engine vs `1.0.0-alpha.3` in SDK before -# hoisting). The engine takes `http-client` for `OrderBookApi`; -# guest-side consumers (SDK, strategies) express their own -# `default-features = false` builds for the `cdylib` wasm target. -cowprotocol = { version = "0.2.0", default-features = false, features = ["http-client"] } - -# HTTP transport for `cow_api::request` REST passthrough and the -# orderbook-mock test surface. +# HTTP transport for the SDK helpers and the orderbook-mock test +# surface. reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } -# Typed HTTP method/request/response for the CoW passthrough and the -# engine's wasi:http gate. Single `http` version in the graph via reqwest, -# so `reqwest::Method` is `http::Method`. +# Typed HTTP method/request/response for the engine's wasi:http gate. +# Single `http` version in the graph via reqwest, so `reqwest::Method` +# is `http::Method`. http = "1" # Body trait + combinators (already in the graph via wasmtime-wasi-http) # for the engine's response-body cap on the wasi:http gate. @@ -117,6 +132,13 @@ http-body = "1" http-body-util = "0.1" bytes = "1" +# Proc-macro toolkit backing `nexum-module-macros` and `videre-macros`. +# Host-side only: a proc-macro crate always builds for the host, even +# when the module consuming it targets wasm. +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } + # `wit-bindgen` is consumed by every guest module crate (example + # every strategy + every fixture). Hoisted so a single bump moves # them in lock-step. @@ -144,7 +166,9 @@ toml = "1" metrics = "0.24" metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] } -# alloy JSON-RPC client + transport (engine chain backend). +# alloy JSON-RPC client + transport (engine chain backend and the +# guest SDK's host-backed transport). +alloy-json-rpc = { version = "2.1", default-features = false } alloy-rpc-client = { version = "2.1", default-features = false } alloy-transport = { version = "2.1", default-features = false } @@ -160,7 +184,7 @@ axum = "0.8" # Randomness for tooling. rand = "0.10" -# Hex codec for the backtest harness. +# Hex codec for test fixtures and receipt rendering. hex = "0.4" # Dev/test helpers. diff --git a/Dockerfile b/Dockerfile index eb7fa3f0..69a38621 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,8 @@ # syntax=docker/dockerfile:1.6 # -# Multi-stage build for `nexum` (Shepherd) - the engine binary -# plus the five production WASM modules baked into a single image. +# Multi-stage build for `shepherd` - the cow composition-root engine +# binary plus the five production WASM modules and the bundled cow +# venue adapter baked into a single image. # # Stage 1 (`build`): full Rust toolchain + wasm32-wasip2 target, builds # the engine in release mode + each module to a Component Model wasm @@ -65,27 +66,30 @@ FROM chef AS build # changed workspace crate. `--locked` stays on the real builds below, which # validate the committed Cargo.lock verbatim. COPY --from=planner /src/recipe.json recipe.json -RUN cargo chef cook --release -p nexum-cli --recipe-path recipe.json \ +RUN cargo chef cook --release -p shepherd --recipe-path recipe.json \ && cargo chef cook --release --target wasm32-wasip2 \ -p twap-monitor -p ethflow-watcher -p price-alert \ - -p balance-tracker -p stop-loss --recipe-path recipe.json + -p balance-tracker --recipe-path recipe.json \ + && cargo chef cook --release --target wasm32-wasip2 \ + -p cow-venue --features cow-venue/adapter --recipe-path recipe.json # Now the workspace sources. `.dockerignore` keeps the context lean -# (no `target/`, no `data/`, no large baseline / backtest fixtures). +# (no `target/`, no `data/`, no large baseline fixtures). # Only the workspace crates recompile here; deps come from the cooked layer. COPY . . # Engine binary in release. --locked ensures the committed Cargo.lock # is used verbatim so builds are reproducible. -RUN cargo build -p nexum-cli --release --locked +RUN cargo build -p shepherd --release --locked -# Five production modules. The wasm artefacts land under +# Four production modules plus the bundled cow venue adapter. The wasm +# artefacts land under # `target/wasm32-wasip2/release/.wasm`. RUN cargo build -p twap-monitor --target wasm32-wasip2 --release --locked \ && cargo build -p ethflow-watcher --target wasm32-wasip2 --release --locked \ && cargo build -p price-alert --target wasm32-wasip2 --release --locked \ && cargo build -p balance-tracker --target wasm32-wasip2 --release --locked \ - && cargo build -p stop-loss --target wasm32-wasip2 --release --locked + && cargo build -p cow-venue --target wasm32-wasip2 --release --locked --features adapter # ----------------------------------------------------------------- runtime @@ -108,7 +112,7 @@ RUN apt-get update \ && install -d -o root -g root -m 0755 /etc/shepherd # Engine binary. -COPY --from=build /src/target/release/nexum /usr/local/bin/nexum +COPY --from=build /src/target/release/shepherd /usr/local/bin/shepherd # Module .wasm artefacts. The Component Model wasm files are loaded # by the engine at boot via the `[[modules]]` entries in engine.toml. @@ -117,11 +121,17 @@ COPY --from=build /src/target/wasm32-wasip2/release/*.wasm /opt/shepherd/modules # Module manifests (the `module.toml` next to each cdylib crate). The # engine resolves capability declarations + chain subscriptions from # these at supervisor boot. -COPY --from=build /src/modules/twap-monitor/module.toml /opt/shepherd/manifests/twap-monitor.toml -COPY --from=build /src/modules/ethflow-watcher/module.toml /opt/shepherd/manifests/ethflow-watcher.toml -COPY --from=build /src/modules/examples/price-alert/module.toml /opt/shepherd/manifests/price-alert.toml -COPY --from=build /src/modules/examples/balance-tracker/module.toml /opt/shepherd/manifests/balance-tracker.toml -COPY --from=build /src/modules/examples/stop-loss/module.toml /opt/shepherd/manifests/stop-loss.toml +COPY --from=build /src/shepherd/modules/twap-monitor/module.toml /opt/shepherd/manifests/twap-monitor.toml +COPY --from=build /src/shepherd/modules/ethflow-watcher/module.toml /opt/shepherd/manifests/ethflow-watcher.toml +COPY --from=build /src/nexum/modules/examples/price-alert/module.toml /opt/shepherd/manifests/price-alert.toml +COPY --from=build /src/nexum/modules/examples/balance-tracker/module.toml /opt/shepherd/manifests/balance-tracker.toml + +# The bundled cow venue adapter's manifests; installed via the +# engine.toml [[adapters]] stanza, never compiled into the engine. +# One manifest per chain: mainnet (cow-venue.toml) and Sepolia +# (cow-venue.sepolia.toml); pick the one matching the run's chain. +COPY --from=build /src/shepherd/crates/cow-venue/module.toml /opt/shepherd/manifests/cow-venue.toml +COPY --from=build /src/shepherd/crates/cow-venue/module.sepolia.toml /opt/shepherd/manifests/cow-venue.sepolia.toml # Drop privileges. The engine never needs root at runtime: it only # reads /etc/shepherd/engine.toml, writes to /var/lib/shepherd, and @@ -138,5 +148,5 @@ EXPOSE 9100 # `--engine-config /etc/shepherd/engine.toml` matches the production # guide's expected mount point. Operators override via # `docker run ... -v /path/to/engine.toml:/etc/shepherd/engine.toml:ro`. -ENTRYPOINT ["/usr/bin/tini", "--", "nexum"] +ENTRYPOINT ["/usr/bin/tini", "--", "shepherd"] CMD ["--engine-config", "/etc/shepherd/engine.toml"] diff --git a/GRANT_APPLICATION.md b/GRANT_APPLICATION.md index 21d5262a..ef1c4568 100644 --- a/GRANT_APPLICATION.md +++ b/GRANT_APPLICATION.md @@ -99,12 +99,9 @@ Many DeFi automation use cases require monitoring + execution patterns: ### The Missing Piece -We have: -✅ On-chain conditional orders (ComposableCoW) -⚠️ Fixed-case off-chain execution layer (watch-tower and EthFlow) +We have: ✅ On-chain conditional orders (ComposableCoW) ⚠️ Fixed-case off-chain execution layer (watch-tower and EthFlow) -We’re missing: -❌ **Programmable off-chain execution layer** +We’re missing: ❌ **Programmable off-chain execution layer** Shepherd fills this gap and becomes the foundation for future innovations like gas abstraction (Methane). @@ -172,11 +169,9 @@ fn state_set(key, value) -> Result<()> ### Core Protocol Use Cases (Enabled by Shepherd) -**1. TWAP Order Monitoring (Initial Implementation)** -Replace current watch-tower with WASM module. Easy to update, community can customise. +**1. TWAP Order Monitoring (Initial Implementation)** Replace current watch-tower with WASM module. Easy to update, community can customise. -**2. Ethflow Order Monitoring (Initial Implementation)** -Replace existing Ethflow monitoring with WASM module, removing Ethflow-specific logic from the backend and reducing cross-domain concerns. +**2. Ethflow Order Monitoring (Initial Implementation)** Replace existing Ethflow monitoring with WASM module, removing Ethflow-specific logic from the backend and reducing cross-domain concerns. **3. Methane Gas Abstraction (Future - Built on Shepherd)** @@ -196,17 +191,13 @@ Replace existing Ethflow monitoring with WASM module, removing Ethflow-specific ### Community Use Cases -**5. Stop-Loss / Take-Profit Orders** -Monitor price oracles, submit CoW order when conditions met. +**5. Stop-Loss / Take-Profit Orders** Monitor price oracles, submit CoW order when conditions met. -**6. Automated Portfolio Rebalancing** -Track wallet balances, trigger rebalance when allocation drifts. +**6. Automated Portfolio Rebalancing** Track wallet balances, trigger rebalance when allocation drifts. -**7. Yield Farming Automation** -Monitor lending positions, automatically compound rewards when profitable. +**7. Yield Farming Automation** Monitor lending positions, automatically compound rewards when profitable. -**8. DAO Governance Automation** -Automatically vote on proposals based on predefined rules. +**8. DAO Governance Automation** Automatically vote on proposals based on predefined rules. **Key Point:** Shepherd is the **foundation**. Once built, Methane and Fee Automation become modules rather than separate infrastructure projects—dramatically reducing complexity and development time. @@ -263,8 +254,7 @@ Automatically vote on proposals based on predefined rules. ### Milestone 1: Core Runtime & Event System -**Duration:** 3 weeks -**Effort Estimate:** 120 hours (3 weeks FTE) +**Duration:** 3 weeks **Effort Estimate:** 120 hours (3 weeks FTE) **Deliverables:** @@ -286,8 +276,7 @@ Automatically vote on proposals based on predefined rules. ### Milestone 2: TWAP & Ethflow Module Implementation -**Duration:** 2.5 weeks -**Effort Estimate:** 100 hours (2.5 weeks FTE) +**Duration:** 2.5 weeks **Effort Estimate:** 100 hours (2.5 weeks FTE) **Deliverables:** @@ -316,8 +305,7 @@ Automatically vote on proposals based on predefined rules. ### Milestone 3: SDK & Developer Experience -**Duration:** 1.5 weeks -**Effort Estimate:** 60 hours (1.5 weeks FTE) +**Duration:** 1.5 weeks **Effort Estimate:** 60 hours (1.5 weeks FTE) **Deliverables:** @@ -343,8 +331,7 @@ Automatically vote on proposals based on predefined rules. ### Milestone 4: Production Hardening -**Duration:** 1.5 weeks -**Effort Estimate:** 60 hours (1.5 weeks FTE) +**Duration:** 1.5 weeks **Effort Estimate:** 60 hours (1.5 weeks FTE) **Deliverables:** @@ -366,8 +353,7 @@ Automatically vote on proposals based on predefined rules. ### Milestone 5: Multi-Chain Considerations & Final Testing -**Duration:** 1 week -**Effort Estimate:** 40 hours (1 week FTE) +**Duration:** 1 week **Effort Estimate:** 40 hours (1 week FTE) **Deliverables:** @@ -392,8 +378,7 @@ Automatically vote on proposals based on predefined rules. ## Total Effort Estimate -**Total Duration:** 9.5 weeks -**Total Effort:** 380 hours (9.5 weeks FTE) +**Total Duration:** 9.5 weeks **Total Effort:** 380 hours (9.5 weeks FTE) **Breakdown:** @@ -410,8 +395,7 @@ Automatically vote on proposals based on predefined rules. ## Grant Funding Request -**Rate:** €100/hour -**Total Grant Amount:** €38,000 +**Rate:** €100/hour **Total Grant Amount:** €38,000 **Payment Terms:** @@ -657,8 +641,7 @@ This creates the most powerful automation infrastructure in DeFi: fully programm * ✅ ComposableCoW SDK: Widely adopted by developers * ✅ Core Contributor: 14+ months of consistent contributions -**Why This Project:** -Shepherd represents the culmination of 18+ months working on CoW Protocol automation: +**Why This Project:** Shepherd represents the culmination of 18+ months working on CoW Protocol automation: 1. Built the conditional order framework (ComposableCoW) 2. Implemented specific order types (TWAP) @@ -709,8 +692,7 @@ Shepherd completes the automation story for CoW Protocol: 3. **Fee Automation/Methane** → Advanced automation patterns ✅ 4. **Shepherd** → Programmable execution layer ✅ -**The Vision:** -Make CoW Protocol the platform for programmable DeFi automation—where any developer can build sophisticated trading strategies, yield optimisation, and automation without building infrastructure from scratch. +**The Vision:** Make CoW Protocol the platform for programmable DeFi automation—where any developer can build sophisticated trading strategies, yield optimisation, and automation without building infrastructure from scratch. **Why Now:** diff --git a/README.md b/README.md index 91026dd0..f0b248cb 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,42 @@ # Shepherd -[![CI](https://github.com/nullislabs/shepherd/actions/workflows/ci.yml/badge.svg)](https://github.com/nullislabs/shepherd/actions/workflows/ci.yml) -[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE) +[![CI](https://github.com/nullislabs/shepherd/actions/workflows/ci.yml/badge.svg)](https://github.com/nullislabs/shepherd/actions/workflows/ci.yml) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE) -**Shepherd is a CoW Protocol-extended [Nexum Runtime](https://github.com/nullislabs): on-chain automation that runs as sandboxed WebAssembly, not scripts.** +Shepherd is a CoW Protocol extension of the [Nexum Runtime](https://github.com/nullislabs): on-chain automation that runs as sandboxed WebAssembly. -The Nexum Runtime executes untrusted automation as WASM components against the `nexum:host` WIT contract. Every module receives exactly the host capabilities it declares in its manifest and nothing more - no ambient filesystem or network. Execution is metered by fuel and epoch, memory-capped, and transactional per event: state commits on success and rolls back on trap. Modules are distributed content-addressed and verified by hash. There is no central service to depend on; you run the node. +The Nexum Runtime executes untrusted automation as WASM components against the `nexum:host` WIT contract. A module receives only the host capabilities it declares in its manifest: no ambient filesystem or network. Execution is metered by fuel and epoch, memory-capped, and transactional per event: state commits on success and rolls back on trap. Modules are content-addressed and verified by hash. There is no central service; you run the node. -Shepherd extends that runtime with `shepherd:cow` - CoW Protocol order APIs and submission - so a TWAP, EthFlow, or ComposableCoW watch-tower is an ordinary module, not a special case baked into the engine. Write the strategy once as a component; the runtime supervises, restarts, meters, and sandboxes it. +Shepherd registers the `videre:venue` platform and bundles the `cow-venue` adapter, so a keeper module submits CoW Protocol orders through the venue registry rather than through engine-baked logic. A keeper watching `ComposableCoW` or `EthFlow` is an ordinary module. -A module built against the universal `nexum:host` world runs on any Nexum-compatible host. A module built against `shepherd:cow` additionally gains CoW Protocol access and requires a Shepherd host. +A module built against `nexum:host` runs on any Nexum-compatible host. CoW order submission additionally needs a host that registers the venue platform, which the `shepherd` binary does. -> **Pre-release** and under active development. Testnets and lab environments only. - -Looking for the org? See **[github.com/nullislabs](https://github.com/nullislabs)**. - ---- - -## Why - -- **WASM Component Model, not a plugin API** - a WIT-typed host/guest contract with structural isolation and multi-language guests (Rust today; anything that compiles to a component next). -- **Capability-scoped by construction** - a module sees only the host primitives it declares. No filesystem; outbound HTTP only against a per-module allowlist (`wasi:http`); WASI clocks and randomness are linked in ambiently. -- **Metered and transactional** - per-event fuel and epoch limits, a memory cap, and all-or-nothing state. A runaway module cannot starve its neighbours or corrupt its store. -- **Declarative subscriptions** - modules declare their block, log, and cron events in a manifest; the runtime wires and multiplexes the sources. -- **Content-addressed distribution** - modules are fetched by hash (Swarm, IPFS, OCI, HTTPS) and integrity-checked before they load. -- **Self-hosted** - one binary, your keys, your RPC. No centralised dependency. - ---- +> Pre-release, under active development. Testnets and lab environments only. ## Layout | Path | Purpose | | --- | --- | -| `crates/nexum-runtime/` | The **engine** - the Nexum Runtime's reference host: a wasmtime implementation of the `nexum:host` contract. | -| `crates/nexum-cli/` | The `nexum` binary - a thin CLI over the runtime library. | -| `crates/nexum-sdk/` | Generic guest SDK - the host trait seam, bind macro, chain/config/address helpers, wasi:http `fetch`, and tracing facade for any module. | -| `crates/shepherd-sdk/` | CoW-domain guest SDK - the cow-api trait and CoW Protocol helpers on top of `nexum-sdk`. | -| `wit/nexum-host/` | The **`nexum:host`** WIT package - the host/guest contract every engine implements and every module imports. | -| `wit/shepherd-cow/` | The `shepherd:cow` WIT package - CoW Protocol extensions on top of `nexum:host`. | -| `modules/` | Guest modules - TWAP and EthFlow watch-towers, examples, and test fixtures. | +| `nexum/crates/nexum-runtime/` | The engine: a wasmtime host implementing the `nexum:host` contract. | +| `nexum/crates/nexum-launch/` | Launcher library: shared CLI, config load, tracing, preset launch. | +| `nexum/crates/nexum-cli/` | The bare `nexum` binary: the core lattice, no extension payload. | +| `shepherd/crates/shepherd/` | The `shepherd` binary: the cow composition root registering the videre venue platform and the Prometheus add-on. | +| `nexum/crates/nexum-sdk/` | Guest SDK: host trait seam, bind macro, chain/config/address helpers, `wasi:http` fetch, tracing facade. | +| `videre/crates/videre-sdk/` | Venue-platform SDK: the `videre:venue` client and adapter contracts. | +| `shepherd/crates/cow-venue/` | The bundled CoW venue adapter component. | +| `wit/nexum-host/` | The `nexum:host` WIT package: the host/guest contract. | +| `wit/videre-venue/` | The `videre:venue` WIT package: the venue-adapter contract. | +| `wit/shepherd-cow/` | `cow-events.wit`: the CoW event ABIs of record. | +| `modules/` | Guest modules: TWAP and EthFlow keepers, examples, and test fixtures. | | `docs/` | Architecture and design notes. Start with [`docs/00-overview.md`](docs/00-overview.md). | -> **Engine vs. host.** An *engine* is a concrete implementation that runs WASM components (today `nexum`, a wasmtime daemon). The `nexum:host` WIT package is the *contract* - the host imports a guest sees. Other engines (mobile, browser) can implement the same contract, and modules built against it run on any compliant engine. - ---- +An engine is a concrete implementation that runs WASM components (`nexum`, a wasmtime daemon). The `nexum:host` WIT package is the contract. Modules built against it run on any compliant engine. ## Build from source Shepherd uses [Nix](https://nixos.org/) flakes to pin the toolchain and [just](https://github.com/casey/just) as the task runner. ```sh -nix develop # enter the dev shell (Rust, wasm-tools, just, ...) +nix develop # dev shell (Rust, wasm-tools, just) just build # build the engine and the example module just run # run the engine against the example module just test # unit tests @@ -58,55 +44,30 @@ just test # unit tests Without Nix you need Rust (edition 2024), the `wasm32-wasip2` target, and `wasm-tools`. ---- - ## Running -Single module (development): +Development, a single module against a synthetic event: ```sh nexum [] ``` -Multi-module (production) - `engine.toml` declares RPC endpoints, the state directory, and a `[[modules]]` list: +Production, an `engine.toml` declaring chains, state directory, modules, and adapters: ```sh -nexum --engine-config engine.toml -``` - -A module's own `module.toml` declares its capabilities and event subscriptions: - -```toml -[module] -name = "twap-monitor" -version = "0.1.0" - -[capabilities] -required = ["chain", "local-store", "cow-api"] -optional = ["http"] - -[[subscription]] -kind = "chain-log" -chain_id = 1 -address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" # ComposableCoW +shepherd --engine-config engine.toml ``` -See [`docs/`](docs) for the full schema and the design corpus - start with [`docs/00-overview.md`](docs/00-overview.md). - ---- +See [`docs/deployment.md`](docs/deployment.md) for the `engine.toml` reference and [`docs/production.md`](docs/production.md) for the production deploy. ## Contributing -Open an issue before non-trivial PRs - this is a pre-release codebase under active churn. Conventional Commits. CI runs `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, and per-module `wasm32-wasip2` builds. +Open an issue before non-trivial PRs. Conventional Commits. CI runs `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, and per-module `wasm32-wasip2` builds. ## Security -Capability sandboxing, key handling, and order signing are security-critical. Please report vulnerabilities privately rather than in public issues. +Capability sandboxing, key handling, and order signing are security-critical. Report vulnerabilities privately rather than in public issues. ## License AGPL-3.0-or-later © Nullis Labs LLC and contributors. See [LICENSE](LICENSE). - -``` -● AGPL-3.0 · pre-release · Nexum Runtime -``` diff --git a/crates/nexum-cli/src/cli.rs b/crates/nexum-cli/src/cli.rs deleted file mode 100644 index 82b7739f..00000000 --- a/crates/nexum-cli/src/cli.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! CLI surface for the `nexum` binary, derived via clap. -//! -//! The 0.2 binary accepts either a positional ` []` -//! shortcut that synthesises a one-module engine config, or a -//! `--engine-config ` flag that points at a TOML declaring -//! multiple modules. Production deployments use the second form; the -//! positional shortcut stays for parity with the M1 reference CLI and -//! for smoke tests. - -use std::path::PathBuf; - -use clap::Parser; - -/// Parsed CLI surface. -/// -/// `nexum [ []] [--engine-config ] [--pretty-logs]` -/// -/// Positional `` is a backwards-compat shortcut that -/// synthesises a one-module engine config. Production deployments pass -/// `--engine-config` and declare modules in TOML. -/// -/// `--pretty-logs` selects the human-readable tracing formatter (the -/// historical 0.1 default). Without the flag the engine emits JSON -/// log lines per the structured-logging contract: a single -/// `jq` / Loki / Grafana stream reconstructs the full timeline of -/// any dispatch, host call, or order submission. -#[derive(Parser, Debug, Default)] -#[command( - name = "nexum", - about = "Run one or more Wasm Component modules under the Shepherd supervisor", - long_about = None, - version, -)] -pub struct Cli { - /// Optional positional path to a Wasm Component file. Synthesises - /// a one-module engine config when no `--engine-config` is given. - pub wasm: Option, - - /// Optional positional path to the module's `module.toml` manifest. - /// Only consulted alongside the positional `wasm` shortcut. - pub manifest: Option, - - /// Optional explicit path to the engine-wide `engine.toml` config. - /// When omitted, the engine resolves the default search path - /// documented in `engine_config::load_or_default`. - #[arg(long = "engine-config")] - pub engine_config: Option, - - /// Use the human-readable tracing formatter instead of the - /// default JSON formatter (structured-logging contract). - #[arg(long = "pretty-logs")] - pub pretty_logs: bool, - - /// Override the chain-log poller's per-block `eth_getLogs` - /// concurrency during backfill. Higher catches up faster at more - /// node load. Overrides `[engine] log_backfill_concurrency` when - /// set. - #[arg(long = "log-backfill-concurrency")] - pub log_backfill_concurrency: Option, -} diff --git a/crates/nexum-cli/src/launch.rs b/crates/nexum-cli/src/launch.rs deleted file mode 100644 index dd67890c..00000000 --- a/crates/nexum-cli/src/launch.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Composition root: bind the reference lattice (core backends plus the -//! cow-api extension in the `Ext` slot), build the shared backends and the -//! extension list, then hand off to the generic runtime launch. - -use std::path::Path; - -use nexum_runtime::addons::{PrometheusAddOn, RuntimeAddOn}; -use nexum_runtime::builder::RuntimeBuilder; -use nexum_runtime::engine_config::EngineConfig; -use nexum_runtime::host::component::{ - ComponentsBuilder, LocalStoreBuilder, ProviderPoolBuilder, RuntimeTypes, -}; -use nexum_runtime::host::local_store_redb::LocalStore; -use nexum_runtime::host::provider_pool::ProviderPool; -use shepherd_cow_host::{ReferenceExt, ReferenceExtBuilder, extension}; - -/// The backends the reference engine ships: the core seams plus the -/// cow-api extension payload in the [`Ext`](RuntimeTypes::Ext) slot. -#[derive(Debug, Clone, Copy, Default)] -struct ReferenceTypes; - -impl RuntimeTypes for ReferenceTypes { - type Chain = ProviderPool; - type Store = LocalStore; - type Ext = ReferenceExt; -} - -/// Build the reference backends and extension list, then run until shutdown. -pub async fn run_from_config( - engine_cfg: &EngineConfig, - wasm: Option<&Path>, - manifest: Option<&Path>, -) -> anyhow::Result<()> { - // Attach the reference add-on set. The binary ships the Prometheus - // exporter; an embedder omits or replaces it by choosing a different - // list here. - let add_ons: [&dyn RuntimeAddOn; 1] = [&PrometheusAddOn]; - - // Assemble and launch over the type-state builder: bind the reference - // lattice, wire cow-api as an extension (linker hook plus capability - // namespace; the core runtime knows nothing of cow, it plugs in here), - // name the component builders, then drive to a running handle and block - // until the event loop returns on shutdown. - RuntimeBuilder::new(engine_cfg) - .with_types::() - .with_extensions([extension::()]) - .with_module_source(wasm.map(Path::to_path_buf), manifest.map(Path::to_path_buf)) - .with_components(ComponentsBuilder::new( - ProviderPoolBuilder, - LocalStoreBuilder, - ReferenceExtBuilder, - )) - .with_add_ons(&add_ons) - .launch() - .await? - .wait() - .await -} diff --git a/crates/nexum-cli/src/main.rs b/crates/nexum-cli/src/main.rs deleted file mode 100644 index f2ce5f46..00000000 --- a/crates/nexum-cli/src/main.rs +++ /dev/null @@ -1,48 +0,0 @@ -#![cfg_attr(not(test), warn(unused_crate_dependencies))] - -mod cli; -mod launch; - -use clap::Parser; -use tracing::info; -use tracing_subscriber::EnvFilter; - -use crate::cli::Cli; -use nexum_runtime::engine_config; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); - - let mut engine_cfg = engine_config::load_or_default(cli.engine_config.as_deref())?; - if let Some(n) = cli.log_backfill_concurrency { - engine_cfg.engine.log_backfill_concurrency = n; - } - - let env_filter = EnvFilter::try_from_default_env() - .or_else(|_| EnvFilter::try_new(&engine_cfg.engine.log_level)) - .unwrap_or_else(|_| EnvFilter::new("info")); - // Structured logging: JSON by default (machine-readable - // for production; one `jq` query reconstructs any dispatch - // timeline); `--pretty-logs` opts back into the 0.1 human-readable - // formatter for local dev. The same `EnvFilter` applies to both - // so `RUST_LOG=debug` works identically. - if cli.pretty_logs { - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .with_target(true) - .init(); - } else { - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .with_target(true) - .json() - .flatten_event(true) - .with_current_span(false) - .init(); - } - - info!("nexum starting"); - - launch::run_from_config(&engine_cfg, cli.wasm.as_deref(), cli.manifest.as_deref()).await -} diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs deleted file mode 100644 index a48bebca..00000000 --- a/crates/nexum-runtime/src/bindings.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! WIT bindings generated by `wasmtime::component::bindgen!`. -//! -//! The core host binds the `nexum:host/event-module` world: the six core -//! primitives. Outbound HTTP is not a `nexum:host` interface: it is -//! wasi:http, linked separately; clocks are ambient wasi:clocks. Domain -//! extensions such as cow-api bind their own world and wire themselves in -//! at the composition root; they are not part of this core surface. -//! -//! Every `Host` trait impl in `crate::host::impls` consumes types -//! generated here. - -wasmtime::component::bindgen!({ - path: ["../../wit/nexum-host"], - world: "nexum:host/event-module", - imports: { default: async }, - exports: { default: async }, -}); diff --git a/crates/nexum-runtime/src/host/component/chain.rs b/crates/nexum-runtime/src/host/component/chain.rs deleted file mode 100644 index 072ebfe8..00000000 --- a/crates/nexum-runtime/src/host/component/chain.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! Chain backend seam: raw JSON-RPC dispatch plus block/chain-log -//! subscriptions, mirroring the inherent `ProviderPool` API. - -use std::future::Future; - -use alloy_chains::Chain; -use alloy_rpc_types_eth::Filter; -use strum::{EnumString, IntoStaticStr}; - -use crate::host::provider_pool::{BlockStream, CanonicalLogStream, ProviderError, ProviderPool}; - -/// The permitted JSON-RPC read surface as a closed type. Methods that -/// sign or mutate node state have no variant, so a guest-supplied -/// signing method (for example `eth_sign` or `eth_sendTransaction`) -/// cannot be represented and never reaches the provider. This is the -/// structural ceiling; an operator allowlist narrows within it and -/// never widens it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr)] -pub enum ChainMethod { - #[strum(serialize = "eth_blockNumber")] - EthBlockNumber, - #[strum(serialize = "eth_call")] - EthCall, - #[strum(serialize = "eth_chainId")] - EthChainId, - #[strum(serialize = "eth_estimateGas")] - EthEstimateGas, - #[strum(serialize = "eth_feeHistory")] - EthFeeHistory, - #[strum(serialize = "eth_gasPrice")] - EthGasPrice, - #[strum(serialize = "eth_maxPriorityFeePerGas")] - EthMaxPriorityFeePerGas, - #[strum(serialize = "eth_getBalance")] - EthGetBalance, - #[strum(serialize = "eth_getBlockByHash")] - EthGetBlockByHash, - #[strum(serialize = "eth_getBlockByNumber")] - EthGetBlockByNumber, - #[strum(serialize = "eth_getBlockReceipts")] - EthGetBlockReceipts, - #[strum(serialize = "eth_getCode")] - EthGetCode, - #[strum(serialize = "eth_getLogs")] - EthGetLogs, - #[strum(serialize = "eth_getProof")] - EthGetProof, - #[strum(serialize = "eth_getStorageAt")] - EthGetStorageAt, - #[strum(serialize = "eth_getTransactionByHash")] - EthGetTransactionByHash, - #[strum(serialize = "eth_getTransactionCount")] - EthGetTransactionCount, - #[strum(serialize = "eth_getTransactionReceipt")] - EthGetTransactionReceipt, - #[strum(serialize = "net_version")] - NetVersion, -} - -impl ChainMethod { - /// The wire method name forwarded to the provider. `&'static` - /// because the permitted set is closed, so the name drops straight - /// into alloy's `Cow<'static, str>` method slot without allocating. - pub fn as_str(self) -> &'static str { - self.into() - } -} - -/// Async chain backend. Methods mirror [`ProviderPool`] one-to-one; -/// the `impl Future + Send` form bakes in the Send bound generic -/// consumers need across `.await` in tokio tasks (not dyn-compatible). -pub trait ChainProvider { - /// Open a `newHeads` block subscription on `chain`. - fn subscribe_blocks( - &self, - chain: Chain, - ) -> impl Future> + Send; - - /// Current head block number (`eth_blockNumber`), used as the - /// canonical log poller's start block. - fn block_number(&self, chain: Chain) - -> impl Future> + Send; - - /// Open a canonical (reorg-aware) `eth_getLogs` log poller on - /// `chain` from `start_block`. - fn watch_chain_logs( - &self, - chain: Chain, - filter: Filter, - start_block: u64, - ) -> Result; - - /// Raw JSON-RPC dispatch. `method` is a permitted read-surface - /// method; `params_json` is the JSON params array. - fn request( - &self, - chain: Chain, - method: ChainMethod, - params_json: String, - ) -> impl Future> + Send; -} - -impl ChainProvider for ProviderPool { - fn subscribe_blocks( - &self, - chain: Chain, - ) -> impl Future> + Send { - ProviderPool::subscribe_blocks(self, chain) - } - - fn block_number( - &self, - chain: Chain, - ) -> impl Future> + Send { - ProviderPool::block_number(self, chain) - } - - fn watch_chain_logs( - &self, - chain: Chain, - filter: Filter, - start_block: u64, - ) -> Result { - ProviderPool::watch_chain_logs(self, chain, filter, start_block) - } - - fn request( - &self, - chain: Chain, - method: ChainMethod, - params_json: String, - ) -> impl Future> + Send { - ProviderPool::request(self, chain, method, params_json) - } -} - -#[cfg(test)] -mod tests { - use super::ChainMethod; - - #[test] - fn read_surface_methods_parse() { - for m in [ - "eth_call", - "eth_blockNumber", - "eth_getBalance", - "eth_getLogs", - "eth_getTransactionReceipt", - "net_version", - ] { - assert!(ChainMethod::try_from(m).is_ok(), "{m} should parse"); - } - } - - #[test] - fn signing_and_mutating_methods_have_no_variant() { - for m in [ - "eth_sign", - "eth_signTransaction", - "eth_sendTransaction", - "eth_sendRawTransaction", - "eth_accounts", - "personal_sign", - "personal_unlockAccount", - "admin_peers", - "debug_traceCall", - "miner_start", - "eth_notAMethod", - "", - ] { - assert!(ChainMethod::try_from(m).is_err(), "{m} must be rejected"); - } - } - - #[test] - fn as_str_round_trips_the_wire_name() { - assert_eq!(ChainMethod::EthCall.as_str(), "eth_call"); - assert_eq!( - ChainMethod::try_from(ChainMethod::EthGetBalance.as_str()).unwrap(), - ChainMethod::EthGetBalance, - ); - } -} diff --git a/crates/nexum-runtime/src/host/component/runtime_types.rs b/crates/nexum-runtime/src/host/component/runtime_types.rs deleted file mode 100644 index a96cbeb1..00000000 --- a/crates/nexum-runtime/src/host/component/runtime_types.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! The RuntimeTypes lattice: one trait naming the core backend seams plus -//! the pluggable extension slot, so every generic signature takes a single -//! parameter. -//! -//! Time, randomness, and outbound HTTP are deliberately not members: all -//! are WASI concerns serviced per store (WasiCtxBuilder for clocks and -//! randomness, wasi:http behind the allowlist gate), not host backends. -//! Domain backends such as cow-api are not core seams: they live behind -//! the [`RuntimeTypes::Ext`] slot and are wired in as extensions. - -use crate::host::component::{ChainProvider, StateStore}; - -/// Names the core backend seams a runtime assembly provides, plus the -/// extension slot ([`Ext`](RuntimeTypes::Ext)) that carries any non-core -/// backend an extension needs. -pub trait RuntimeTypes: 'static { - /// JSON-RPC dispatch and subscriptions. - type Chain: ChainProvider + Clone + Send + Sync + 'static; - /// Process-wide store vending per-module handles. - type Store: StateStore + Clone + Send + Sync + 'static; - /// Extension state slot. Backends that are not core capabilities live - /// here; an extension reaches its payload through the `ExtState` - /// accessor without naming the concrete lattice. `()` for an assembly - /// with no extensions. - type Ext: Clone + Send + Sync + 'static; -} - -/// Per-module store handle of a lattice's Store member. -pub type Handle = <::Store as StateStore>::Handle; diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs deleted file mode 100644 index f5b1b806..00000000 --- a/crates/nexum-runtime/src/host/extension.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! The extension seam: a linker hook plus the capability namespace an -//! extension contributes, assembled at the composition root and threaded -//! into every module linker. - -use std::sync::Arc; - -use wasmtime::component::Linker; - -use crate::host::component::RuntimeTypes; -use crate::host::state::HostState; -use crate::manifest::NamespaceCaps; - -/// Adds an extension's WIT interfaces to a module linker. Runs after the -/// core interfaces and before instantiation. Takes only `&mut Linker`, so -/// the seam stays compatible with a future per-extension router that -/// serialises access to the non-`Sync` wasmtime `Store`. -pub type LinkerHook = Arc>) -> anyhow::Result<()> + Send + Sync>; - -/// One runtime extension: how to wire its interfaces into a module linker, -/// and the capability namespace enforcement must recognise for it. The two -/// travel together: a module that imports an extension interface boots only -/// if the linker entry AND the capability namespace are both registered -/// before instantiation. -pub struct Extension { - /// Linker contribution: adds the extension's imports to a module linker. - pub link: LinkerHook, - /// Capability namespace this extension owns, merged into enforcement so - /// a module importing the extension's interfaces still validates. - pub capabilities: NamespaceCaps, -} - -impl Clone for Extension { - fn clone(&self) -> Self { - Self { - link: Arc::clone(&self.link), - capabilities: self.capabilities, - } - } -} diff --git a/crates/nexum-runtime/src/host/impls/local_store.rs b/crates/nexum-runtime/src/host/impls/local_store.rs deleted file mode 100644 index 32a17f09..00000000 --- a/crates/nexum-runtime/src/host/impls/local_store.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! `nexum:host/local-store`: redb backend with host-side namespacing. - -use crate::bindings::nexum; -use crate::bindings::nexum::host::types::Fault; -use crate::host::component::{RuntimeTypes, StateHandle}; -use crate::host::state::HostState; - -impl nexum::host::local_store::Host for HostState { - async fn get(&mut self, key: String) -> Result>, Fault> { - self.store.get(&key).map_err(Fault::from) - } - - async fn set(&mut self, key: String, value: Vec) -> Result<(), Fault> { - self.store.set(&key, &value).map_err(Fault::from) - } - - async fn delete(&mut self, key: String) -> Result<(), Fault> { - self.store.delete(&key).map_err(Fault::from) - } - - async fn list_keys(&mut self, prefix: String) -> Result, Fault> { - self.store.list_keys(&prefix).map_err(Fault::from) - } -} diff --git a/crates/nexum-runtime/src/host/impls/messaging.rs b/crates/nexum-runtime/src/host/impls/messaging.rs deleted file mode 100644 index f2ff87c2..00000000 --- a/crates/nexum-runtime/src/host/impls/messaging.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! `nexum:host/messaging`: deferred to 0.3 (Waku backend). `query` -//! returns an empty result, same posture as `identity::accounts`. - -use crate::bindings::nexum; -use crate::bindings::nexum::host::types::Fault; -use crate::host::component::RuntimeTypes; -use crate::host::state::HostState; - -impl nexum::host::messaging::Host for HostState { - async fn publish(&mut self, _content_topic: String, _payload: Vec) -> Result<(), Fault> { - Err(Fault::Unsupported("Waku backend deferred to 0.3".into())) - } - - async fn query( - &mut self, - _content_topic: String, - _start_time: Option, - _end_time: Option, - _limit: Option, - ) -> Result, Fault> { - Ok(vec![]) - } -} diff --git a/crates/nexum-runtime/src/host/impls/mod.rs b/crates/nexum-runtime/src/host/impls/mod.rs deleted file mode 100644 index 2247ed60..00000000 --- a/crates/nexum-runtime/src/host/impls/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! `Host` trait impls for [`crate::host::state::HostState`], one -//! file per WIT interface. -//! -//! The interfaces themselves (and their generated trait shapes) live -//! in [`crate::bindings`]; this module only contains the dispatch -//! glue between the WIT signature and the corresponding backend in -//! [`crate::host`]. - -mod chain; -mod identity; -mod local_store; -mod logging; -mod messaging; -mod remote_store; -mod types; diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs deleted file mode 100644 index 66e4121e..00000000 --- a/crates/nexum-runtime/src/host/mod.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Host-side backends for the `nexum:host` interfaces, plus the -//! per-module `HostState` and the WIT `Host` trait impls. -//! -//! Layout: -//! - [`state`]: the `HostState` struct + `WasiView` impl, the receiver -//! every WIT `Host` trait is implemented for. `HostState` is generic -//! over the `RuntimeTypes` lattice; the composition root supplies the -//! concrete assembly. -//! - [`error`]: From conversions that project backend errors into the -//! WIT `chain-error` / `Fault` shapes, plus the `Fault` label and -//! message projections the supervisor records. -//! - [`provider_pool`], [`local_store_redb`]: capability backends. Pure -//! code with no bindgen types, so each can be unit-tested without -//! spinning up a wasmtime store. -//! - `impls` (private): the bindgen-side trait impls, one file per core -//! WIT interface, that dispatch to the backends above. -//! - [`component`]: backend traits over the capability backends, the seam a generic runtime consumes. -//! - [`extension`]: the extension seam (linker hook + capability -//! namespace) an extension is wired in through at the composition root. -//! Domain extensions such as cow-api live in their own crates and plug -//! in through this seam rather than being hard-linked into the core host. -//! - [`http`]: the wasi:http outgoing gate enforcing the per-module -//! `[capabilities.http].allow` list. -//! - [`logs`]: the typed module-log pipeline (capture points -> router -> -//! tracing event + retention store) and its embedder read surface. - -pub mod component; -pub mod error; -pub mod extension; -pub mod http; -mod impls; -pub mod local_store_redb; -pub mod logs; -pub mod provider_pool; -pub mod state; diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs deleted file mode 100644 index fd484935..00000000 --- a/crates/nexum-runtime/src/manifest/mod.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! `module.toml` parser and capability-enforcement helpers (0.2 scope). -//! -//! 0.2 intentionally ships a slim subset of the manifest spec: -//! -//! - `[capabilities].required` is parsed and validated (names must be in -//! the known capability set; the 0.2 reference engine always provides -//! all of them, so this is a sanity check + future-proofing). -//! - `[capabilities].optional` is parsed and logged; trap-stub fallback -//! for absent optionals is deferred to 0.3. -//! - `[capabilities.http].allow` is parsed and consulted by the -//! wasi:http gate before any outbound call. -//! - `[config]` is flattened to `Vec<(String, String)>` and passed to the -//! module's `init`. Typed `config-value` variant is deferred to 0.3. -//! -//! When the manifest file is missing or has no `[capabilities]` section, -//! a deprecation warning is emitted and the engine falls back to 0.1 -//! behaviour (treat every linked capability as required). This fallback -//! will be removed in 0.3. -//! -//! ## Layout -//! -//! - `types`: the serde `Manifest` shape + `LoadedManifest` the engine -//! actually consumes, plus the core-capability list. -//! - `load`: `module.toml` -> `LoadedManifest`, plus the host-matching -//! helper the wasi:http gate uses at request time. -//! - `capabilities`: WIT-import vs declared-capabilities cross-check, plus -//! the extension-extensible `CapabilityRegistry`. -//! - `error`: `ParseError`, `CapabilityViolation`, `CapabilityError`. - -mod capabilities; -mod error; -mod load; -mod types; - -pub(crate) use capabilities::enforce_capabilities; -pub use capabilities::{CapabilityRegistry, NamespaceCaps}; -pub(crate) use load::{fallback_manifest, host_allowed, load}; -pub(crate) use types::{LoadedManifest, ResourceSection, Subscription}; -// CapabilityViolation, ParseError, and the *Section structs are -// reachable through these functions' return / argument types; -// consumers that need to name them directly do so via -// `crate::manifest::error::*` or `::types::*`. diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs deleted file mode 100644 index 88feea78..00000000 --- a/crates/nexum-runtime/src/manifest/types.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Data structures: `Manifest`, sections, and `LoadedManifest`. -//! -//! Plain serde shapes plus the core-capability list. The parsing -//! and validation logic lives in [`mod@super::load`]; capability enforcement -//! in [`super::capabilities`]. - -use serde::Deserialize; - -/// Core capability names: the `nexum:host` interfaces the `event-module` -/// world links into every module linker. The `http` capability is not a -/// `nexum:host` interface (it gates `wasi:http/*` imports) and is handled -/// separately by the registry. Domain-extension capabilities (e.g. -/// cow-api) are not listed here; each extension contributes its own -/// namespace to the [`super::capabilities::CapabilityRegistry`] at the -/// composition root. -pub const CORE_CAPABILITIES: &[&str] = &[ - "chain", - "identity", - "local-store", - "remote-store", - "messaging", - "logging", -]; - -#[derive(Debug, Deserialize, Default)] -pub struct Manifest { - #[serde(default)] - pub module: ModuleSection, - #[serde(default)] - pub capabilities: Option, - #[serde(default)] - pub config: toml::Table, - /// Event subscriptions the runtime wires before calling - /// `_init`. See `docs/02-modules-events-packaging.md` for the - /// schema; 0.2 implements `block` and `chain-log` kinds, `cron` is - /// parsed and ignored (deferred to 0.3). - #[serde(default, rename = "subscription")] - pub subscriptions: Vec, -} - -/// One `[[subscription]]` table in `module.toml`. -/// -/// The discriminator is the `kind` field; remaining fields are -/// validated per-kind by the supervisor. Unknown kinds are surfaced -/// at load time so a typo does not silently disable an event source. -#[derive(Debug, Deserialize, Clone)] -#[serde(tag = "kind", rename_all = "lowercase")] -pub enum Subscription { - /// New-block events. Fan-out is shared per chain - the - /// supervisor opens one subscription per chain id and routes to - /// every module that asked for blocks on that chain. - Block { - /// EVM chain id. - chain_id: u64, - }, - /// Chain-log events matching `address` + topic-0. Fan-out is - /// per-module - the supervisor opens one subscription per - /// `[[subscription]]` entry and tags emitted events with the - /// owning module. - #[serde(rename = "chain-log")] - ChainLog { - /// EVM chain id. - chain_id: u64, - /// Contract address as `0x`-prefixed 20-byte hex. Optional. - #[serde(default)] - address: Option, - /// Topic-0 of the event the module wants to consume. `0x`- - /// prefixed 32-byte hex. Optional - when absent the - /// subscription matches every event from the address(es). - #[serde(default)] - event_signature: Option, - /// Resume across engine restarts. When `true` the host persists a - /// durable per-subscription cursor and re-opens the log poller - /// from just after the last dispatched block, instead of at the - /// current head. Delivery is then at-least-once, so the module must - /// tolerate redelivery (the chassis idempotency journal already - /// dedups it). - #[serde(default)] - resume: bool, - /// Optional cap on how far back a `resume` subscription will - /// backfill, in blocks. `None` (the default) backfills the entire - /// gap with no loss; set it only for a consumer that explicitly - /// tolerates dropping the oldest missed blocks. - #[serde(default)] - max_lookback: Option, - }, - /// Cron-scheduled tick. 0.2 parses but does not dispatch; the - /// supervisor emits a warning so the operator knows the - /// declaration is currently inert. `schedule` is preserved so a - /// 0.3 dispatcher can pick it up without re-parsing the manifest. - Cron { - /// Standard 5-field cron expression. - #[allow(dead_code)] - schedule: String, - }, -} - -#[derive(Debug, Deserialize, Default)] -#[allow(dead_code)] // version + component parsed for future 0.3 hash-verification. -pub struct ModuleSection { - #[serde(default)] - pub name: String, - #[serde(default)] - pub version: String, - #[serde(default)] - pub component: String, - /// Per-module resource overrides; each unset field inherits the engine - /// `[limits]` default. - #[serde(default)] - pub resources: ResourceSection, -} - -/// `[module.resources]` overrides layered over the engine `[limits]` -/// defaults. Every field is optional; an unset field keeps the default. -#[derive(Debug, Deserialize, Default)] -pub struct ResourceSection { - /// Linear-memory cap, in bytes. - #[serde(default)] - pub max_memory_bytes: Option, - /// Fuel granted per event dispatch. - #[serde(default)] - pub max_fuel_per_event: Option, - /// Local-store byte quota (key + value bytes). - #[serde(default)] - pub max_state_bytes: Option, -} - -#[derive(Debug, Deserialize, Default)] -pub struct CapabilitiesSection { - #[serde(default)] - pub required: Vec, - #[serde(default)] - pub optional: Vec, - #[serde(default)] - pub http: Option, -} - -#[derive(Debug, Deserialize, Default)] -pub struct HttpSection { - #[serde(default)] - pub allow: Vec, -} - -/// Loaded + validated manifest, plus the data the engine needs to -/// instantiate a module. -#[derive(Debug)] -pub struct LoadedManifest { - pub manifest: Manifest, - /// Hosts wasi:http outgoing requests may target. Each entry is - /// either an exact hostname or a `*.suffix` wildcard. - pub http_allowlist: Vec, - /// `[config]` flattened to `(key, stringified-value)` pairs ready to - /// hand to a module's `init`. TOML scalars (string, integer, float, - /// boolean) become their text form. Arrays and tables are rendered as - /// their TOML representation. - pub config: Vec<(String, String)>, -} diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs deleted file mode 100644 index 05556ee3..00000000 --- a/crates/nexum-runtime/src/preset.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Runtime presets: a preset names a lattice, its component builders, and its -//! add-on set as one bundle, so an embedder launches with -//! `RuntimeBuilder::new(cfg).runtime::().launch()` instead of naming -//! each seam. [`CoreRuntime`] is the domain-free default: the reference core -//! backends (a chain provider pool and a local redb store, no extension -//! payload) with the Prometheus add-on. A domain assembly ships its own -//! preset naming its extension builder in the `Ext` slot. - -use crate::addons::{AddOns, PrometheusAddOn}; -use crate::host::component::{ - ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, ProviderPoolBuilder, RuntimeTypes, -}; -use crate::host::local_store_redb::LocalStore; -use crate::host::provider_pool::ProviderPool; - -/// A bundled runtime assembly: the [`RuntimeTypes`] lattice plus the component -/// builders and add-ons the launcher needs, gathered behind one name. -/// Implemented by zero-sized markers; -/// [`RuntimeBuilder::runtime`](crate::builder::RuntimeBuilder::runtime) binds -/// one and launches it. -pub trait Runtime { - /// The lattice the preset assembles. - type Types: RuntimeTypes; - /// Builds the chain backend ([`RuntimeTypes::Chain`]). - type ChainBuilder: ComponentBuilder::Chain>; - /// Builds the store backend ([`RuntimeTypes::Store`]). - type StoreBuilder: ComponentBuilder::Store>; - /// Builds the extension payload ([`RuntimeTypes::Ext`]). - type ExtBuilder: ComponentBuilder::Ext>; - - /// The component builders that open the backends at launch. - fn components() -> ComponentsBuilder; - - /// The cross-cutting add-ons installed before the engine boots. - fn add_ons() -> AddOns; -} - -/// The domain-free default preset: the reference core backends (a chain -/// provider pool and a local redb store, no extension payload) with the -/// Prometheus add-on. Doubles as its own [`RuntimeTypes`] lattice. -#[derive(Debug, Clone, Copy, Default)] -pub struct CoreRuntime; - -impl RuntimeTypes for CoreRuntime { - type Chain = ProviderPool; - type Store = LocalStore; - type Ext = (); -} - -impl Runtime for CoreRuntime { - type Types = Self; - type ChainBuilder = ProviderPoolBuilder; - type StoreBuilder = LocalStoreBuilder; - type ExtBuilder = (); - - fn components() -> ComponentsBuilder { - ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) - } - - fn add_ons() -> AddOns { - vec![Box::new(PrometheusAddOn)] - } -} diff --git a/crates/nexum-runtime/src/runtime/limits.rs b/crates/nexum-runtime/src/runtime/limits.rs deleted file mode 100644 index b7778f11..00000000 --- a/crates/nexum-runtime/src/runtime/limits.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Re-exports for the configurable per-module wasmtime fuel + memory -//! limits. The canonical source is [`crate::engine_config::ModuleLimits`]. -//! -//! Fuel meters only guest instructions; host-call time is unmetered, so a -//! per-dispatch wall-clock deadline in [`crate::supervisor`] is the backstop. -//! diff --git a/crates/nexum-runtime/src/runtime/poison_policy.rs b/crates/nexum-runtime/src/runtime/poison_policy.rs deleted file mode 100644 index 70998a6e..00000000 --- a/crates/nexum-runtime/src/runtime/poison_policy.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Supervisor poison-pill policy. -//! -//! Modules that reach `max_failures` traps within a sliding -//! `window` are marked **poisoned**: the supervisor stops dispatching -//! events to them entirely (no further restart attempts), bumps a -//! `shepherd_module_poisoned{module}` gauge to 1, and logs the -//! quarantine event so an operator can investigate. Recovery -//! requires an operator-driven full engine restart (today): remove -//! the entry from `engine.toml::[[modules]]`, kill the process, fix -//! the module, restart. -//! -//! ## Difference from the restart policy -//! -//! `restart_policy::backoff_for` schedules retries for transient -//! traps; the failure counter resets on a successful dispatch. The -//! poison policy is the *sustained-failure* escalation: if a module -//! is still trapping after `max_failures` retries inside `window`, -//! it stops being a transient and becomes a permanent failure that -//! exhausts an operator's restart budget without ever recovering. -//! Stop retrying. -//! -//! The two policies share `LoadedModule.failure_count` for the -//! consecutive-failure semantic; poison adds a `failure_timestamps` -//! ring so the window check is independent of how the failures are -//! spaced (one second apart vs nine minutes apart both count toward -//! the same window). - -use std::time::Duration; - -/// Production defaults: 5 traps within 10 minutes -> quarantine. -/// Aggressive enough to catch a deterministically broken module -/// without waiting out the full exponential backoff (the 5th trap -/// happens at ~31 s into the schedule: 1+2+4+8+16 s); lenient -/// enough that a one-off RPC blip during a real cow-api submit does -/// not get a module quarantined. -pub const POISON_MAX_FAILURES: u32 = 5; -pub const POISON_WINDOW: Duration = Duration::from_secs(600); - -/// Configurable poison-pill thresholds. Resolved from `[limits.poison]` -/// on the supervisor boot paths (`ModuleLimits::poison`), falling back -/// to [`PoisonPolicy::default`] for production; operators shorten both -/// values to catch a deterministically broken module sooner. -#[derive(Debug, Clone, Copy)] -pub struct PoisonPolicy { - /// Maximum traps within `window` before the module is poisoned. - pub max_failures: u32, - /// Sliding window the failures are counted across. - pub window: Duration, -} - -impl PoisonPolicy { - pub const fn new(max_failures: u32, window: Duration) -> Self { - Self { - max_failures, - window, - } - } -} - -impl Default for PoisonPolicy { - fn default() -> Self { - Self::new(POISON_MAX_FAILURES, POISON_WINDOW) - } -} - -/// Return `true` when `failure_count` failures inside `window` -/// crosses the configured threshold. -pub fn should_poison(policy: PoisonPolicy, recent_failures: u32) -> bool { - recent_failures >= policy.max_failures -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_is_production_constants() { - let p = PoisonPolicy::default(); - assert_eq!(p.max_failures, POISON_MAX_FAILURES); - assert_eq!(p.window, POISON_WINDOW); - } - - #[test] - fn poisons_at_threshold() { - let p = PoisonPolicy::new(3, Duration::from_secs(60)); - assert!(!should_poison(p, 0)); - assert!(!should_poison(p, 2)); - assert!(should_poison(p, 3)); - assert!(should_poison(p, 100)); - } -} diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs deleted file mode 100644 index 621da7bd..00000000 --- a/crates/nexum-sdk-test/src/lib.rs +++ /dev/null @@ -1,768 +0,0 @@ -//! # nexum-sdk-test -//! -//! In-memory implementations of the [`nexum_sdk::host`] traits -//! plus assertion helpers, so a module can write integration -//! tests for its strategy logic without `wit-bindgen`, `wasmtime`, or -//! a network round-trip. -//! -//! ## Usage -//! -//! Add as a dev-dep on the module crate: -//! -//! ```toml -//! [dev-dependencies] -//! nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } -//! ``` -//! -//! Structure the module's strategy function around the host traits: -//! -//! ```rust,ignore -//! pub fn handle_block( -//! host: &H, -//! chain_id: u64, -//! block_number: u64, -//! ) -> Result<(), nexum_sdk::host::Fault> { -//! // ... -//! let res = host.request(chain_id, "eth_call", "[]")?; -//! host.set("last_block", &block_number.to_le_bytes())?; -//! host.log(nexum_sdk::Level::INFO, "saw block"); -//! Ok(()) -//! } -//! ``` -//! -//! Test against [`MockHost`]: -//! -//! ```rust -//! // Glob-import the host traits so the method shortcuts resolve. -//! use nexum_sdk::host::*; -//! use nexum_sdk_test::MockHost; -//! -//! let host = MockHost::new(); -//! host.chain.respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); -//! -//! // Call the strategy directly: -//! assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); -//! -//! // Inspect: -//! assert_eq!(host.chain.calls().len(), 1); -//! ``` -//! -//! ## Adapting from wit-bindgen -//! -//! The traits report failures as [`nexum_sdk::host::Fault`] rather than -//! the `Fault` `wit_bindgen::generate!` emits per-module. A module -//! bridges with a trivial converter on its own crate boundary - see the -//! tutorial for the exact shape. -//! -//! Domain SDK test crates compose these mocks with their own (the CoW -//! `shepherd-sdk-test` embeds them next to its `MockCowApi`). - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![warn(missing_docs)] - -use std::cell::RefCell; -use std::collections::{BTreeMap, HashMap}; -use std::fmt::{self, Write as _}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; - -use nexum_sdk::Level; -use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost, LoggingHost}; -use tracing::field::{Field, Visit}; -use tracing::level_filters::LevelFilter; -use tracing::span::{Attributes, Id, Record}; -use tracing::{Event, Metadata, Subscriber}; - -/// Composed in-memory host. Each field exposes the per-trait mock so -/// tests can program responses and assert on calls. -#[derive(Default)] -pub struct MockHost { - /// `nexum:host/chain` mock. - pub chain: MockChain, - /// `nexum:host/local-store` mock. - pub store: MockLocalStore, - /// `nexum:host/logging` mock. - pub logging: MockLogging, -} - -impl MockHost { - /// Fresh empty host. Equivalent to `Default::default`. - pub fn new() -> Self { - Self::default() - } -} - -impl ChainHost for MockHost { - fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { - self.chain.request(chain_id, method, params) - } -} - -impl LocalStoreHost for MockHost { - fn get(&self, key: &str) -> Result>, Fault> { - self.store.get(key) - } - fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { - self.store.set(key, value) - } - fn delete(&self, key: &str) -> Result<(), Fault> { - self.store.delete(key) - } - fn list_keys(&self, prefix: &str) -> Result, Fault> { - self.store.list_keys(prefix) - } -} - -impl LoggingHost for MockHost { - fn log(&self, level: Level, message: &str) { - self.logging.log(level, message); - } -} - -// ---------------------------------------------------------------- chain - -/// In-memory [`ChainHost`] backed by a `(method, params)` -> response -/// map. Records every call so tests can assert dispatch shape. -#[derive(Default)] -pub struct MockChain { - responses: RefCell>>, - calls: RefCell>, -} - -/// One recorded [`MockChain::request`] invocation. -#[derive(Clone, Debug)] -pub struct ChainCall { - /// EVM chain id the guest passed. - pub chain_id: u64, - /// JSON-RPC method name. - pub method: String, - /// JSON-encoded params array (verbatim). - pub params: String, -} - -impl MockChain { - /// Program a response for the `(method, params)` pair. Overwrites - /// any prior entry. - pub fn respond_to( - &self, - method: impl Into, - params: impl Into, - result: Result, - ) { - self.responses - .borrow_mut() - .insert((method.into(), params.into()), result); - } - - /// All calls received, in arrival order. - pub fn calls(&self) -> Vec { - self.calls.borrow().clone() - } - - /// Last call received, if any. - pub fn last_call(&self) -> Option { - self.calls.borrow().last().cloned() - } - - /// Total call count. - pub fn call_count(&self) -> usize { - self.calls.borrow().len() - } -} - -impl ChainHost for MockChain { - fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { - self.calls.borrow_mut().push(ChainCall { - chain_id, - method: method.to_string(), - params: params.to_string(), - }); - self.responses - .borrow() - .get(&(method.to_string(), params.to_string())) - .cloned() - .unwrap_or_else(|| { - Err(ChainError::Fault(Fault::Unsupported(format!( - "MockChain: no response configured for {method} {params}" - )))) - }) - } -} - -// ---------------------------------------------------------------- local-store - -/// In-memory [`LocalStoreHost`] backed by a `HashMap`. Each operation -/// runs in O(1) except `list_keys`, which scans (small N expected for -/// tests). -/// -/// Supports optional error injection via [`MockLocalStore::fail_on`] -/// and entry-count limits via [`MockLocalStore::set_max_entries`]. -#[derive(Default)] -pub struct MockLocalStore { - rows: RefCell>>, - /// When set, `set` returns `StorageFull` if the store reaches this many entries. - max_entries: RefCell>, - /// Key patterns that trigger injected faults on any operation. - error_patterns: RefCell>, -} - -impl MockLocalStore { - /// Number of rows currently held. - pub fn len(&self) -> usize { - self.rows.borrow().len() - } - - /// Whether the store is empty. - pub fn is_empty(&self) -> bool { - self.rows.borrow().is_empty() - } - - /// Direct read for assertions - bypasses the trait. - pub fn snapshot(&self) -> HashMap> { - self.rows.borrow().clone() - } - - /// Set a maximum number of entries. Once reached, `set` on a new - /// key returns a `StorageFull` error. `None` disables the limit. - pub fn set_max_entries(&self, limit: usize) { - *self.max_entries.borrow_mut() = Some(limit); - } - - /// Inject a fault for any operation where the key starts with - /// `prefix`. Multiple patterns can be registered; the first - /// matching one fires. - pub fn fail_on(&self, prefix: impl Into, fault: Fault) { - self.error_patterns - .borrow_mut() - .push((prefix.into(), fault)); - } - - fn check_injected_error(&self, key: &str) -> Result<(), Fault> { - for (pattern, fault) in self.error_patterns.borrow().iter() { - if key.starts_with(pattern) { - return Err(fault.clone()); - } - } - Ok(()) - } -} - -impl LocalStoreHost for MockLocalStore { - fn get(&self, key: &str) -> Result>, Fault> { - self.check_injected_error(key)?; - Ok(self.rows.borrow().get(key).cloned()) - } - fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { - self.check_injected_error(key)?; - if let Some(limit) = *self.max_entries.borrow() { - let rows = self.rows.borrow(); - if rows.len() >= limit && !rows.contains_key(key) { - return Err(Fault::Internal(format!( - "MockLocalStore: max entries ({limit}) reached" - ))); - } - } - self.rows - .borrow_mut() - .insert(key.to_string(), value.to_vec()); - Ok(()) - } - fn delete(&self, key: &str) -> Result<(), Fault> { - self.check_injected_error(key)?; - self.rows.borrow_mut().remove(key); - Ok(()) - } - fn list_keys(&self, prefix: &str) -> Result, Fault> { - self.check_injected_error(prefix)?; - let mut keys: Vec = self - .rows - .borrow() - .keys() - .filter(|k| k.starts_with(prefix)) - .cloned() - .collect(); - keys.sort(); - Ok(keys) - } -} - -// ---------------------------------------------------------------- logging - -/// One recorded log line. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct LogLine { - /// Severity the module passed. - pub level: Level, - /// Message body. - pub message: String, -} - -/// In-memory [`LoggingHost`] that buffers every emitted line. -#[derive(Default)] -pub struct MockLogging { - lines: RefCell>, -} - -impl MockLogging { - /// All buffered log lines, in emission order. - pub fn lines(&self) -> Vec { - self.lines.borrow().clone() - } - - /// `true` if any buffered line contains `needle` (substring match). - pub fn contains(&self, needle: &str) -> bool { - self.lines - .borrow() - .iter() - .any(|l| l.message.contains(needle)) - } - - /// Count of lines at `level`. - pub fn count_at(&self, level: Level) -> usize { - self.lines - .borrow() - .iter() - .filter(|l| l.level == level) - .count() - } -} - -impl LoggingHost for MockLogging { - fn log(&self, level: Level, message: &str) { - self.lines.borrow_mut().push(LogLine { - level, - message: message.to_string(), - }); - } -} - -// ---------------------------------------------------------------- tracing capture - -/// One tracing event captured pre-flattening. -#[derive(Clone, Debug, PartialEq)] -pub struct CapturedEvent { - /// Event severity. - pub level: Level, - /// Callsite target (module path by default). - pub target: String, - /// The `message` field; empty when the event carried none. - pub message: String, - /// Every non-message field, keyed by name. - pub fields: BTreeMap, -} - -/// A field value as tracing's `Visit` delivered it. -#[derive(Clone, Debug, PartialEq)] -pub enum FieldValue { - /// A `record_str` value. - Str(String), - /// A `record_u64` value. - U64(u64), - /// A `record_i64` value. - I64(i64), - /// A `record_bool` value. - Bool(bool), - /// A `record_debug` fallback (`?x`, `%x`, `f64`, ...), pre-rendered - /// with `{:?}`. - Debug(String), -} - -impl fmt::Display for FieldValue { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - FieldValue::Str(v) | FieldValue::Debug(v) => f.write_str(v), - FieldValue::U64(v) => write!(f, "{v}"), - FieldValue::I64(v) => write!(f, "{v}"), - FieldValue::Bool(v) => write!(f, "{v}"), - } - } -} - -impl CapturedEvent { - /// The value recorded for `name`, if the event carried it. - pub fn field(&self, name: &str) -> Option<&FieldValue> { - self.fields.get(name) - } - - /// Display-rendered field, for string comparisons. - pub fn field_str(&self, name: &str) -> Option { - self.fields.get(name).map(FieldValue::to_string) - } -} - -/// Events captured during [`capture_tracing`]. -pub struct CapturedEvents { - events: Arc>>, -} - -impl CapturedEvents { - /// Every captured event, in emission order. - pub fn events(&self) -> Vec { - self.events.lock().unwrap().clone() - } - - /// Whether no events were captured. - pub fn is_empty(&self) -> bool { - self.events.lock().unwrap().is_empty() - } - - /// Count of events at `level`. - pub fn count_at(&self, level: Level) -> usize { - self.events - .lock() - .unwrap() - .iter() - .filter(|e| e.level == level) - .count() - } - - /// Whether any captured event satisfies `pred`. - pub fn any(&self, pred: impl Fn(&CapturedEvent) -> bool) -> bool { - self.events.lock().unwrap().iter().any(pred) - } - - /// Exactly one matching event; panics with the full capture dump - /// otherwise. - pub fn expect_one(&self, pred: impl Fn(&CapturedEvent) -> bool) -> CapturedEvent { - let events = self.events.lock().unwrap(); - let matches: Vec<&CapturedEvent> = events.iter().filter(|e| pred(e)).collect(); - match matches.as_slice() { - [only] => (*only).clone(), - other => panic!( - "expected exactly one matching event, found {}; captured: {events:#?}", - other.len(), - ), - } - } -} - -type Buffer = Arc>>; - -std::thread_local! { - /// The capture buffer active on this thread, if any. `capture_tracing` - /// installs one for the duration of `f` and restores the prior slot on - /// return or unwind. - static ACTIVE_CAPTURE: RefCell> = const { RefCell::new(None) }; -} - -/// Restores the previous thread-local capture slot when a -/// `capture_tracing` call returns or unwinds. -struct CaptureGuard(Option); - -impl Drop for CaptureGuard { - fn drop(&mut self) { - ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = self.0.take()); - } -} - -/// Events-only subscriber that records each event as a typed -/// [`CapturedEvent`] into the buffer active on the emitting thread, -/// dropping events when none is set. Spans are inert. -struct CaptureSubscriber { - next_id: AtomicU64, -} - -impl Subscriber for CaptureSubscriber { - fn enabled(&self, _metadata: &Metadata<'_>) -> bool { - true - } - - fn max_level_hint(&self) -> Option { - Some(LevelFilter::TRACE) - } - - fn new_span(&self, _span: &Attributes<'_>) -> Id { - // Spans are inert, but a valid non-zero id must be returned. - let raw = self.next_id.fetch_add(1, Ordering::Relaxed).wrapping_add(1); - Id::from_u64(raw.max(1)) - } - - fn record(&self, _span: &Id, _values: &Record<'_>) {} - - fn record_follows_from(&self, _span: &Id, _follows: &Id) {} - - fn event(&self, event: &Event<'_>) { - let mut visitor = FieldVisitor::default(); - event.record(&mut visitor); - let captured = CapturedEvent { - level: *event.metadata().level(), - target: event.metadata().target().to_owned(), - message: visitor.message, - fields: visitor.fields, - }; - ACTIVE_CAPTURE.with(|slot| { - if let Some(buffer) = slot.borrow().as_ref() { - buffer.lock().unwrap().push(captured); - } - }); - } - - fn enter(&self, _span: &Id) {} - - fn exit(&self, _span: &Id) {} -} - -/// Splits an event into its `message` field and a name-keyed map of the -/// rest, mirroring the facade's dispatch so captured values match the -/// rendered line field-for-field. -#[derive(Default)] -struct FieldVisitor { - message: String, - fields: BTreeMap, -} - -impl Visit for FieldVisitor { - fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { - if field.name() == "message" { - // tracing delivers `message` as the `format_args!` result, whose - // `Debug` renders unquoted; keep the raw text, do not re-quote it. - let _ = write!(self.message, "{value:?}"); - } else { - self.fields.insert( - field.name().to_owned(), - FieldValue::Debug(format!("{value:?}")), - ); - } - } - - fn record_str(&mut self, field: &Field, value: &str) { - if field.name() == "message" { - self.message.push_str(value); - } else { - self.fields - .insert(field.name().to_owned(), FieldValue::Str(value.to_owned())); - } - } - - fn record_u64(&mut self, field: &Field, value: u64) { - self.fields - .insert(field.name().to_owned(), FieldValue::U64(value)); - } - - fn record_i64(&mut self, field: &Field, value: i64) { - self.fields - .insert(field.name().to_owned(), FieldValue::I64(value)); - } - - fn record_bool(&mut self, field: &Field, value: bool) { - self.fields - .insert(field.name().to_owned(), FieldValue::Bool(value)); - } -} - -static INSTALL_ROUTING: std::sync::Once = std::sync::Once::new(); - -/// Run `f`, returning its value and every `tracing` event it emitted. -/// -/// Capture routes through a single process-global default subscriber -/// installed on first use, keyed to the emitting thread by a thread-local -/// buffer. A process-global default is required rather than a -/// `with_default` scoped one: `tracing` caches each callsite's `Interest` -/// the first time the callsite is hit, computed against whichever -/// dispatcher is current on that thread at that instant. Under parallel -/// tests a callsite exercised outside any capture (e.g. a sibling test -/// calling the same strategy function directly) registers against the -/// no-op default and is cached `never` for the rest of the process, -/// silently starving every later scoped capture of that event. Installing -/// the capture subscriber as the global default makes the cached interest -/// stable and capture independent of test scheduling. -pub fn capture_tracing(f: impl FnOnce() -> R) -> (R, CapturedEvents) { - INSTALL_ROUTING.call_once(|| { - let _ = tracing::subscriber::set_global_default(CaptureSubscriber { - next_id: AtomicU64::new(0), - }); - }); - - let events: Buffer = Arc::new(Mutex::new(Vec::new())); - let previous = ACTIVE_CAPTURE.with(|slot| slot.borrow_mut().replace(Arc::clone(&events))); - let _guard = CaptureGuard(previous); - let result = f(); - drop(_guard); - (result, CapturedEvents { events }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn chain_records_calls_and_returns_programmed_response() { - let chain = MockChain::default(); - chain.respond_to("eth_blockNumber", "[]", Ok("\"0x1234\"".into())); - - assert_eq!( - chain.request(1, "eth_blockNumber", "[]").unwrap(), - "\"0x1234\"" - ); - assert_eq!(chain.call_count(), 1); - let last = chain.last_call().unwrap(); - assert_eq!(last.chain_id, 1); - assert_eq!(last.method, "eth_blockNumber"); - } - - #[test] - fn chain_unconfigured_method_returns_unsupported() { - let chain = MockChain::default(); - let err = chain.request(1, "eth_call", "[]").unwrap_err(); - let ChainError::Fault(Fault::Unsupported(msg)) = err else { - panic!("expected Unsupported fault, got {err:?}"); - }; - assert!(msg.contains("MockChain")); - assert_eq!(chain.call_count(), 1); - } - - #[test] - fn local_store_round_trips() { - let store = MockLocalStore::default(); - store.set("k", b"v").unwrap(); - assert_eq!(store.get("k").unwrap().as_deref(), Some(&b"v"[..])); - store.delete("k").unwrap(); - assert!(store.get("k").unwrap().is_none()); - } - - #[test] - fn local_store_list_keys_prefix_scan() { - let store = MockLocalStore::default(); - store.set("watch:a:1", b"").unwrap(); - store.set("watch:a:2", b"").unwrap(); - store.set("submitted:1", b"").unwrap(); - let keys = store.list_keys("watch:").unwrap(); - assert_eq!(keys, vec!["watch:a:1", "watch:a:2"]); - } - - #[test] - fn logging_captures_lines_and_filters_by_level() { - let log = MockLogging::default(); - log.log(Level::INFO, "hello"); - log.log(Level::WARN, "uh oh"); - log.log(Level::INFO, "still here"); - - assert_eq!(log.lines().len(), 3); - assert_eq!(log.count_at(Level::INFO), 2); - assert_eq!(log.count_at(Level::WARN), 1); - assert!(log.contains("uh oh")); - } - - #[test] - fn local_store_error_injection() { - let store = MockLocalStore::default(); - store.fail_on("bad:", Fault::Internal("injected".into())); - // Non-matching keys work fine. - store.set("good:k", b"v").unwrap(); - assert_eq!(store.get("good:k").unwrap().as_deref(), Some(&b"v"[..])); - // Matching keys trigger the error. - assert!(store.set("bad:k", b"v").is_err()); - assert!(store.get("bad:k").is_err()); - assert!(store.delete("bad:k").is_err()); - assert!(store.list_keys("bad:").is_err()); - } - - #[test] - fn local_store_max_entries_enforced() { - let store = MockLocalStore::default(); - store.set_max_entries(2); - store.set("a", b"1").unwrap(); - store.set("b", b"2").unwrap(); - // Updating an existing key is OK even at the limit. - store.set("b", b"3").unwrap(); - // Adding a new key exceeds the limit. - let err = store.set("c", b"4").unwrap_err(); - assert!(matches!(err, Fault::Internal(ref m) if m.contains("max entries"))); - assert_eq!(store.len(), 2); - } - - #[test] - fn mock_host_dispatches_through_supertrait() { - let host = MockHost::new(); - host.chain - .respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); - - // Through the `Host` supertrait. - let _: &dyn nexum_sdk::host::Host = &host; - host.set("key", b"val").unwrap(); - assert_eq!(host.get("key").unwrap().as_deref(), Some(&b"val"[..])); - assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); - host.log(Level::INFO, "happy path"); - - assert_eq!(host.chain.call_count(), 1); - assert_eq!(host.logging.lines().len(), 1); - assert_eq!(host.store.len(), 1); - } - - #[test] - fn capture_message_only_event_has_empty_fields() { - let (_, logs) = capture_tracing(|| tracing::info!("hello")); - let events = logs.events(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].level, Level::INFO); - assert_eq!(events[0].message, "hello"); - assert!(events[0].fields.is_empty()); - } - - #[test] - fn capture_fields_land_as_typed_values() { - let (_, logs) = capture_tracing(|| { - tracing::warn!( - name = "eth", - count = 7u64, - signed = -3i64, - ready = true, - answer = ?Some(9), - "changed", - ); - }); - let ev = logs.expect_one(|e| e.level == Level::WARN); - assert_eq!(ev.message, "changed"); - assert_eq!(ev.field("name"), Some(&FieldValue::Str("eth".to_owned()))); - assert_eq!(ev.field("count"), Some(&FieldValue::U64(7))); - assert_eq!(ev.field("signed"), Some(&FieldValue::I64(-3))); - assert_eq!(ev.field("ready"), Some(&FieldValue::Bool(true))); - assert_eq!( - ev.field("answer"), - Some(&FieldValue::Debug("Some(9)".to_owned())), - ); - } - - #[test] - fn capture_display_recorded_value_lands_as_debug() { - let (_, logs) = capture_tracing(|| tracing::info!(x = %42u32, "shown")); - let ev = logs.expect_one(|e| e.message == "shown"); - assert!(matches!(ev.field("x"), Some(FieldValue::Debug(_)))); - assert_eq!(ev.field_str("x").as_deref(), Some("42")); - } - - #[test] - fn events_outside_capture_are_dropped() { - // Prime the global default via one capture, then emit outside any. - let (_, _) = capture_tracing(|| tracing::info!("primed")); - tracing::info!("orphan"); - let (_, logs) = capture_tracing(|| tracing::info!("inside")); - let events = logs.events(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].message, "inside"); - } - - #[test] - fn concurrent_captures_are_thread_isolated() { - use std::sync::Barrier; - let barrier = Arc::new(Barrier::new(2)); - let other = Arc::clone(&barrier); - let handle = std::thread::spawn(move || { - let (_, logs) = capture_tracing(|| { - other.wait(); - tracing::info!("thread-one"); - }); - logs.events() - }); - let (_, main_logs) = capture_tracing(|| { - barrier.wait(); - tracing::info!("thread-two"); - }); - let thread_events = handle.join().unwrap(); - - assert_eq!(main_logs.events().len(), 1); - assert_eq!(main_logs.events()[0].message, "thread-two"); - assert_eq!(thread_events.len(), 1); - assert_eq!(thread_events[0].message, "thread-one"); - } -} diff --git a/crates/nexum-sdk/src/chain/mod.rs b/crates/nexum-sdk/src/chain/mod.rs deleted file mode 100644 index dd60ba0d..00000000 --- a/crates/nexum-sdk/src/chain/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! `chain::request` JSON plumbing. -//! -//! Build the `[{to, data}, "latest"]` params array for `eth_call` and -//! parse the `"0x..."` hex result string. Pure-logic helpers so a -//! module can plumb its own `chain::request` shim around them. - -pub mod chainlink; -pub mod eth_call; - -pub use eth_call::{eth_call_params, parse_eth_call_result}; diff --git a/crates/nexum-sdk/src/host.rs b/crates/nexum-sdk/src/host.rs deleted file mode 100644 index 6e0e71d5..00000000 --- a/crates/nexum-sdk/src/host.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! Host traits - the seam between strategy logic and the wit-bindgen -//! shims a module generates per-cdylib. -//! -//! Each trait mirrors one nexum host interface ([`ChainHost`] for -//! `nexum:host/chain`, [`LocalStoreHost`] for `nexum:host/local-store`, -//! [`LoggingHost`] for `nexum:host/logging`). A module that wants -//! host-free unit tests writes its strategy logic against the -//! [`Host`] supertrait and lets `nexum-sdk-test` slot in the -//! in-memory mocks. Domain SDKs bound extra host interfaces on top -//! with their own traits over the same [`Fault`]. -//! -//! ## Why a separate `Fault` -//! -//! `wit_bindgen::generate!` emits a `Fault` type into each module's -//! own crate, so its identity is per-module. The SDK exposes [`Fault`] -//! (this module) with the same case shape, so modules wire a one-liner -//! converter between the two and the traits stay world-neutral, letting -//! the mocks compile without a wasm toolchain. See `nexum-sdk-test`'s -//! crate docs for the adapter pattern. - -use alloy_primitives::Bytes; -use strum::IntoStaticStr; -use tracing_core::Level; - -/// The cross-domain failure vocabulary richer host interfaces embed as -/// a case, mirrored from `nexum:host/types.fault`. Typed per-interface -/// errors wrap this shared payload-bearing set so a caller recovers the -/// structured cause without a stringly-typed ladder. -/// -/// `#[non_exhaustive]` forces downstream `match` sites to carry a wildcard -/// arm, so the WIT can grow a case without breaking them. -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum Fault { - /// Capability declared but not provisioned by the operator. - #[error("unsupported: {0}")] - Unsupported(String), - /// Capability temporarily unavailable (RPC down, etc). - #[error("unavailable: {0}")] - Unavailable(String), - /// Capability declined the request (auth, allowlist, …). - #[error("denied: {0}")] - Denied(String), - /// Rate-limited by an upstream service; may carry backoff guidance - /// when the host knows the retry window. - #[error("rate limited")] - RateLimited(RateLimit), - /// Operation took too long. - #[error("timeout")] - Timeout, - /// Caller-supplied input did not parse / validate. - #[error("invalid input: {0}")] - InvalidInput(String), - /// Catch-all for host-side bugs. - #[error("internal: {0}")] - Internal(String), -} - -/// Backoff guidance carried by [`Fault::RateLimited`], mirrored from -/// `nexum:host/types.rate-limit`. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)] -pub struct RateLimit { - /// Host's suggested wait before retrying, in milliseconds, when known. - pub retry_after_ms: Option, -} - -/// Recovers the shared [`Fault`] from a richer, per-interface error. -/// -/// Typed interface errors that embed a fault case implement this so a -/// caller can dispatch on the structured cause and pull a stable -/// snake_case [`label`](HostFault::label) for logs and metrics without -/// matching the outer type. -pub trait HostFault { - /// The embedded fault, when this value represents one. - fn fault(&self) -> Option<&Fault>; - /// Stable snake_case label for logs and metrics. - fn label(&self) -> &'static str; -} - -impl HostFault for Fault { - fn fault(&self) -> Option<&Fault> { - Some(self) - } - - fn label(&self) -> &'static str { - self.into() - } -} - -/// A structured JSON-RPC error response, mirrored from -/// `nexum:host/chain.rpc-error`. `code` is the node-reported numeric -/// (typically `-32000` for an `eth_call` revert). `data` is the decoded -/// `error.data` payload: the host hex-decodes the upstream JSON string -/// once, so a strategy receives the raw abi-encoded revert bytes and -/// can hand them straight to a revert decoder. -/// -/// This is a world-neutral mirror, not `alloy_json_rpc::ErrorPayload`: -/// that type widens `code` to `i64` and carries `data` as raw JSON, and -/// depending on it would drag the JSON-RPC client stack into every wasm -/// guest, which only ever sees the host-decoded bytes over WIT. -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] -#[error("rpc error {code}: {message}")] -pub struct RpcError { - /// JSON-RPC error code from the node. - pub code: i32, - /// Human-readable detail. - pub message: String, - /// Decoded `error.data` bytes, when the node returned a hex payload. - /// `Bytes` so a guest hands the host-decoded buffer to a revert - /// decoder without re-copying it. - pub data: Option, -} - -/// Failure of a `nexum:host/chain` call, mirrored from -/// `nexum:host/chain.chain-error`: either a shared host [`Fault`] -/// (transport down, timed out, denied, ...) or a structured JSON-RPC -/// [`RpcError`] carrying the node code and any decoded revert payload. -/// -/// [`HostFault`] recovers the embedded [`Fault`] (present only on the -/// `Fault` case) and a stable snake_case label for logs and metrics. -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] -pub enum ChainError { - /// A shared host fault. - #[error(transparent)] - Fault(#[from] Fault), - /// A structured JSON-RPC error response. - #[error(transparent)] - Rpc(#[from] RpcError), -} - -impl HostFault for ChainError { - fn fault(&self) -> Option<&Fault> { - match self { - ChainError::Fault(f) => Some(f), - ChainError::Rpc(_) => None, - } - } - - fn label(&self) -> &'static str { - match self { - ChainError::Fault(f) => f.label(), - ChainError::Rpc(_) => "rpc", - } - } -} - -/// Fold a [`ChainError`] into the shared [`Fault`] a module returns -/// from `init` / `on_event`. The `fault` case passes through; a -/// structured JSON-RPC [`RpcError`] has no shared-vocabulary case, so -/// it becomes an [`Fault::Internal`] carrying the node code, message, -/// and any decoded revert bytes as a `0x` hex suffix. -impl From for Fault { - fn from(err: ChainError) -> Self { - match err { - ChainError::Fault(fault) => fault, - ChainError::Rpc(rpc) => { - let mut message = format!("rpc error {}: {}", rpc.code, rpc.message); - if let Some(data) = rpc.data { - message.push_str(" ("); - message.push_str(&alloy_primitives::hex::encode_prefixed(data)); - message.push(')'); - } - Fault::Internal(message) - } - } - } -} - -/// `nexum:host/chain` - raw JSON-RPC dispatch. -pub trait ChainHost { - /// Execute a JSON-RPC request against the given chain. The host - /// routes to its configured provider; the SDK does not care which - /// transport (HTTP / WebSocket / mock) implements the call. A - /// failure is a [`ChainError`]: a shared [`Fault`] or a structured - /// JSON-RPC [`RpcError`] carrying any decoded revert bytes. - fn request(&self, chain_id: u64, method: &str, params: &str) -> Result; -} - -/// `nexum:host/local-store` - per-module key-value persistence. -/// -/// The interface reports failures as a [`Fault`]: the interface is the -/// failure domain, so the case vocabulary alone carries the cause. A -/// strategy that aggregates store and chain calls into one [`Fault`] -/// return relies on the `From` fold for `?`. -pub trait LocalStoreHost { - /// Fetch a value. `Ok(None)` when the key is absent. - fn get(&self, key: &str) -> Result>, Fault>; - /// Insert or overwrite. - fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault>; - /// Delete. No-op if the key is absent. - fn delete(&self, key: &str) -> Result<(), Fault>; - /// Enumerate keys whose raw form starts with `prefix`. - fn list_keys(&self, prefix: &str) -> Result, Fault>; -} - -/// `nexum:host/logging` - structured runtime logs. -pub trait LoggingHost { - /// Emit a log line at the given [`Level`]. The bind macro maps it - /// onto the generated wire enum; the WIT edge is the only place a - /// non-`Level` severity type appears. - fn log(&self, level: Level, message: &str); -} - -/// Supertrait that bundles the core host interfaces a typical -/// strategy module exercises. Modules that want full host-free -/// integration tests take `&impl Host` (or a generic ``) in -/// their strategy function; `nexum-sdk-test::MockHost` is the -/// in-memory implementation. Strategies that reach a domain extension -/// bound its host trait as well (the CoW SDK's `CowHost`, say). -/// -/// A blanket impl is provided for any type that implements all three -/// component traits, so callers do not have to add a redundant -/// `impl Host for MyHost {}`. -/// -/// # Example -/// -/// Strategy functions are generic over [`Host`]. Production code plugs -/// the per-module `WitBindgenHost` adapter (see `modules/examples/`); -/// unit tests plug `nexum_sdk_test::MockHost`. -/// -/// ``` -/// use nexum_sdk::Level; -/// use nexum_sdk::host::{ -/// ChainError, ChainHost, Fault, Host, LocalStoreHost, LoggingHost, -/// }; -/// -/// /// Pure strategy logic - no wit-bindgen calls in here. -/// fn record_block(host: &H, chain_id: u64, key: &str) -> Result<(), Fault> { -/// host.log(Level::INFO, "recording block"); -/// host.set(key, b"")?; -/// let _block_number = host.request(chain_id, "eth_blockNumber", "[]")?; -/// Ok(()) -/// } -/// -/// // Minimal hand-rolled host so the doctest is self-contained. -/// // Real modules wire `nexum_sdk_test::MockHost` here. -/// # struct StubHost; -/// # impl ChainHost for StubHost { -/// # fn request(&self, _: u64, _: &str, _: &str) -> Result { -/// # Ok("\"0x0\"".into()) -/// # } -/// # } -/// # impl LocalStoreHost for StubHost { -/// # fn get(&self, _: &str) -> Result>, Fault> { Ok(None) } -/// # fn set(&self, _: &str, _: &[u8]) -> Result<(), Fault> { Ok(()) } -/// # fn delete(&self, _: &str) -> Result<(), Fault> { Ok(()) } -/// # fn list_keys(&self, _: &str) -> Result, Fault> { Ok(vec![]) } -/// # } -/// # impl LoggingHost for StubHost { -/// # fn log(&self, _: Level, _: &str) {} -/// # } -/// record_block(&StubHost, 1, "block:42").unwrap(); -/// ``` -pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} -impl Host for T {} - -#[cfg(test)] -mod tests { - use super::{ChainError, Fault, HostFault, RateLimit, RpcError}; - - #[test] - fn fault_labels_are_stable_snake_case() { - let cases: [(Fault, &str); 7] = [ - (Fault::Unsupported(String::new()), "unsupported"), - (Fault::Unavailable(String::new()), "unavailable"), - (Fault::Denied(String::new()), "denied"), - (Fault::RateLimited(RateLimit::default()), "rate_limited"), - (Fault::Timeout, "timeout"), - (Fault::InvalidInput(String::new()), "invalid_input"), - (Fault::Internal(String::new()), "internal"), - ]; - for (fault, label) in cases { - assert_eq!(fault.label(), label); - assert_eq!(fault.fault(), Some(&fault)); - } - } - - #[test] - fn host_fault_is_object_safe() { - let boxed: Box = Box::new(Fault::Timeout); - assert_eq!(boxed.label(), "timeout"); - } - - #[test] - fn chain_error_recovers_embedded_fault() { - let fault = ChainError::Fault(Fault::Timeout); - assert_eq!(fault.fault(), Some(&Fault::Timeout)); - assert_eq!(fault.label(), "timeout"); - - let rpc = ChainError::Rpc(RpcError { - code: -32000, - message: "execution reverted".into(), - data: Some(vec![0xde, 0xad].into()), - }); - assert_eq!(rpc.fault(), None); - assert_eq!(rpc.label(), "rpc"); - } - - #[test] - fn chain_error_rpc_folds_to_internal_fault_with_hex_data() { - let fault = Fault::from(ChainError::Rpc(RpcError { - code: -32000, - message: "execution reverted".into(), - data: Some(vec![0x08, 0xc3, 0x79, 0xa0].into()), - })); - let Fault::Internal(message) = fault else { - panic!("rpc folds to internal, got {fault:?}"); - }; - assert!(message.contains("-32000")); - assert!(message.contains("0x08c379a0")); - } - - #[test] - fn chain_error_fault_folds_through_unchanged() { - let fault = Fault::from(ChainError::Fault(Fault::Unavailable("rpc down".into()))); - assert_eq!(fault, Fault::Unavailable("rpc down".into())); - } -} diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs deleted file mode 100644 index 06d1cd9e..00000000 --- a/crates/nexum-sdk/src/keeper.rs +++ /dev/null @@ -1,294 +0,0 @@ -//! Strategy-keeper stores: the persistent-state conventions shared by -//! conditional-commitment modules, expressed over [`LocalStoreHost`] -//! alone so they compile for any world and test against the in-memory -//! mocks. -//! -//! Three stores cover the machinery watcher modules hand-roll: -//! -//! - [`WatchSet`] - the watch-set registry, one `watch:{owner}:{hash}` -//! row per conditional commitment. -//! - [`Gates`] - `next_block:` / `next_epoch:` gate keys holding a -//! u64 little-endian threshold, with an -//! [`is_ready`](Gates::is_ready) predicate the poll loop consults. -//! - [`Journal`] - the receipt-keyed idempotency journal of -//! `submitted:` / `observed:` presence markers. -//! -//! [`WatchRef`] ties the first two together: gate keys are derived -//! from the exact hex substrings of the stored watch key, and -//! [`WatchSet::remove`] drops a watch together with all of its gate -//! keys so no failure path can orphan a gate. -//! -//! ``` -//! use nexum_sdk::keeper::{Gates, Journal, WatchRef, WatchSet}; -//! use nexum_sdk::host::{Fault, LocalStoreHost}; -//! use nexum_sdk::prelude::*; -//! -//! # use std::cell::RefCell; -//! # use std::collections::BTreeMap; -//! # #[derive(Default)] -//! # struct StubStore(RefCell>>); -//! # impl LocalStoreHost for StubStore { -//! # fn get(&self, key: &str) -> Result>, Fault> { -//! # Ok(self.0.borrow().get(key).cloned()) -//! # } -//! # fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { -//! # self.0.borrow_mut().insert(key.into(), value.into()); -//! # Ok(()) -//! # } -//! # fn delete(&self, key: &str) -> Result<(), Fault> { -//! # self.0.borrow_mut().remove(key); -//! # Ok(()) -//! # } -//! # fn list_keys(&self, prefix: &str) -> Result, Fault> { -//! # Ok(self -//! # .0 -//! # .borrow() -//! # .keys() -//! # .filter(|k| k.starts_with(prefix)) -//! # .cloned() -//! # .collect()) -//! # } -//! # } -//! let host = StubStore::default(); -//! let watches = WatchSet::new(&host); -//! let key = watches.put(&Address::ZERO, &B256::ZERO, b"params")?; -//! let watch = WatchRef::parse(&key).expect("well-formed key"); -//! -//! let gates = Gates::new(&host); -//! gates.set_next_block(watch, 100)?; -//! assert!(!gates.is_ready(watch, 99, 0)?); -//! assert!(gates.is_ready(watch, 100, 0)?); -//! -//! let journal = Journal::submitted(&host); -//! journal.record("0xuid")?; -//! assert!(journal.contains("0xuid")?); -//! -//! watches.remove(watch)?; -//! assert!(watches.list()?.is_empty()); -//! # Ok::<(), Fault>(()) -//! ``` - -use alloy_primitives::{Address, B256}; - -use crate::host::{Fault, LocalStoreHost}; - -/// Prefix of every watch-set row. -pub const WATCH_PREFIX: &str = "watch:"; -/// Prefix of the block-height gate row paired with a watch. -pub const NEXT_BLOCK_PREFIX: &str = "next_block:"; -/// Prefix of the Unix-seconds gate row paired with a watch. -pub const NEXT_EPOCH_PREFIX: &str = "next_epoch:"; -/// Journal prefix for receipts the module posted upstream itself: the -/// submit path recorded that it has sent an order on. -pub const SUBMITTED_PREFIX: &str = "submitted:"; -/// Journal prefix for receipts the module confirmed but did not post: -/// the observe-and-verify path (e.g. ethflow) recorded an existing -/// upstream order as seen. -pub const OBSERVED_PREFIX: &str = "observed:"; - -/// Borrowed view of a watch key's two hex halves, parsed from a -/// `watch:{owner}:{hash}` row. Gate keys are derived from the exact -/// substrings of the stored key, so a parse-then-derive round trip is -/// byte-stable regardless of how the original writer cased the hex. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct WatchRef<'k> { - owner_hex: &'k str, - hash_hex: &'k str, -} - -impl<'k> WatchRef<'k> { - /// Parse a `watch:{owner}:{hash}` key. `None` when the prefix or - /// the separating colon is missing, or when either half is empty - /// (an empty half would derive a degenerate gate key like - /// `next_block::`). - pub fn parse(key: &'k str) -> Option { - let rest = key.strip_prefix(WATCH_PREFIX)?; - let (owner_hex, hash_hex) = rest.split_once(':')?; - if owner_hex.is_empty() || hash_hex.is_empty() { - return None; - } - Some(Self { - owner_hex, - hash_hex, - }) - } - - /// The owner half, verbatim from the key. - pub fn owner_hex(&self) -> &'k str { - self.owner_hex - } - - /// The commitment-hash half, verbatim from the key. - pub fn hash_hex(&self) -> &'k str { - self.hash_hex - } - - /// Rebuild the full watch key. - pub fn key(&self) -> String { - format!("{WATCH_PREFIX}{}:{}", self.owner_hex, self.hash_hex) - } - - /// The `next_block:` gate key paired with this watch. - pub fn next_block_key(&self) -> String { - format!("{NEXT_BLOCK_PREFIX}{}:{}", self.owner_hex, self.hash_hex) - } - - /// The `next_epoch:` gate key paired with this watch. - pub fn next_epoch_key(&self) -> String { - format!("{NEXT_EPOCH_PREFIX}{}:{}", self.owner_hex, self.hash_hex) - } -} - -/// Watch-set registry: one row per conditional commitment, keyed -/// `watch:{owner}:{hash}` with the encoded commitment parameters as -/// the value. -pub struct WatchSet<'h, H> { - host: &'h H, -} - -impl<'h, H: LocalStoreHost> WatchSet<'h, H> { - /// Registry view over the given host. - pub fn new(host: &'h H) -> Self { - Self { host } - } - - /// Canonical key for an owner / commitment-hash pair (lowercase - /// `0x`-prefixed hex on both halves). - pub fn key(owner: &Address, hash: &B256) -> String { - format!("{WATCH_PREFIX}{owner:#x}:{hash:#x}") - } - - /// Insert or overwrite the watch row; returns the key written. - /// Overwriting in place makes re-indexing a replayed log a no-op. - pub fn put(&self, owner: &Address, hash: &B256, value: &[u8]) -> Result { - let key = Self::key(owner, hash); - self.host.set(&key, value)?; - Ok(key) - } - - /// The stored value. `Ok(None)` when the watch is absent. - pub fn get(&self, watch: WatchRef<'_>) -> Result>, Fault> { - self.host.get(&watch.key()) - } - - /// Every watch key currently registered. - pub fn list(&self) -> Result, Fault> { - self.host.list_keys(WATCH_PREFIX) - } - - /// Drop the watch together with both of its gate keys. Gates go - /// first: a fault part-way leaves the watch row behind so a retry - /// re-drops it, and a gate key can never outlive its watch. - pub fn remove(&self, watch: WatchRef<'_>) -> Result<(), Fault> { - Gates::new(self.host).clear(watch)?; - self.host.delete(&watch.key()) - } -} - -/// Gate-key discipline: `next_block:{owner}:{hash}` and -/// `next_epoch:{owner}:{hash}` rows holding a u64 little-endian -/// threshold. A malformed or absent row reads as "no gate", so a -/// corrupt value can only make a watch poll sooner, never wedge it. -pub struct Gates<'h, H> { - host: &'h H, -} - -impl<'h, H: LocalStoreHost> Gates<'h, H> { - /// Gate view over the given host. - pub fn new(host: &'h H) -> Self { - Self { host } - } - - /// Skip polls until the chain reaches `block`. - pub fn set_next_block(&self, watch: WatchRef<'_>, block: u64) -> Result<(), Fault> { - self.host.set(&watch.next_block_key(), &block.to_le_bytes()) - } - - /// Skip polls until the Unix-seconds clock reaches `epoch_s`. - pub fn set_next_epoch(&self, watch: WatchRef<'_>, epoch_s: u64) -> Result<(), Fault> { - self.host - .set(&watch.next_epoch_key(), &epoch_s.to_le_bytes()) - } - - /// Whether the watch is clear to poll at the given block height - /// and Unix-seconds timestamp. Both gates must pass; each is - /// inclusive at its threshold. - #[must_use = "the readiness verdict gates the poll; `?` alone drops the inner bool"] - pub fn is_ready(&self, watch: WatchRef<'_>, block: u64, epoch_s: u64) -> Result { - if let Some(next) = self.read_u64(&watch.next_block_key())? - && block < next - { - return Ok(false); - } - if let Some(next) = self.read_u64(&watch.next_epoch_key())? - && epoch_s < next - { - return Ok(false); - } - Ok(true) - } - - /// Delete both gate keys. No-op for gates never set. - pub fn clear(&self, watch: WatchRef<'_>) -> Result<(), Fault> { - self.host.delete(&watch.next_block_key())?; - self.host.delete(&watch.next_epoch_key()) - } - - fn read_u64(&self, key: &str) -> Result, Fault> { - // Absent key: silently no gate. Present but wrong length: the - // value is corrupt, so warn before falling open to no gate - - // fail-open is deliberate (a corrupt value can only make the - // watch poll sooner), but it must not pass unobserved. - let Some(b) = self.host.get(key)? else { - return Ok(None); - }; - match <[u8; 8]>::try_from(b.as_slice()) { - Ok(bytes) => Ok(Some(u64::from_le_bytes(bytes))), - Err(_) => { - tracing::warn!(%key, len = b.len(), "gate value corrupt; treating as absent"); - Ok(None) - } - } - } -} - -/// Receipt-keyed idempotency journal: presence markers under a fixed -/// prefix. The marker value is empty - presence of the key is the -/// receipt - so re-recording is idempotent by construction. -pub struct Journal<'h, H> { - host: &'h H, - prefix: &'static str, -} - -impl<'h, H: LocalStoreHost> Journal<'h, H> { - /// Journal of receipts this module has submitted upstream - /// (`submitted:` markers). - pub fn submitted(host: &'h H) -> Self { - Self { - host, - prefix: SUBMITTED_PREFIX, - } - } - - /// Journal of receipts this module has observed upstream - /// (`observed:` markers). - pub fn observed(host: &'h H) -> Self { - Self { - host, - prefix: OBSERVED_PREFIX, - } - } - - /// Record the receipt. - pub fn record(&self, receipt: &str) -> Result<(), Fault> { - self.host.set(&format!("{}{receipt}", self.prefix), b"") - } - - /// Whether the receipt is already journalled. - pub fn contains(&self, receipt: &str) -> Result { - Ok(self - .host - .get(&format!("{}{receipt}", self.prefix))? - .is_some()) - } -} diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs deleted file mode 100644 index ac6531fe..00000000 --- a/crates/nexum-sdk/src/lib.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! # nexum-sdk -//! -//! Guest-side SDK for nexum runtime modules. The helpers here are -//! host-neutral and domain-free: any module targeting the runtime can -//! use them regardless of which world it exports. Domain layers such as -//! the CoW SDK depend on this crate and add their own surface on top. -//! -//! The crate is the shared companion to the per-module -//! `wit_bindgen::generate!` invocation: modules keep their own -//! wit-bindgen call (which emits the world-specific `Guest` trait, -//! `Fault` shape, and host import shims into the module's own -//! crate) and pull helpers and canonical primitive types from here. -//! -//! ## What lives here -//! -//! - [`prelude`] - `use nexum_sdk::prelude::*` imports the alloy -//! primitives ([`Address`], [`B256`], [`Bytes`], [`U256`], -//! [`keccak256`]). -//! -//! - [`host`] - host trait seam ([`Host`] / [`ChainHost`] / -//! [`LocalStoreHost`] / [`LoggingHost`]) plus the host-neutral -//! [`Fault`] vocabulary. Modules that want host-free tests structure -//! their strategy logic against these traits and slot in the -//! `nexum-sdk-test` mocks. See the host module docs for the -//! wit-bindgen adapter pattern. -//! -//! - [`bind_host_via_wit_bindgen!`](crate::bind_host_via_wit_bindgen) - -//! generates the per-module `WitBindgenHost` adapter over the -//! wit-bindgen import shims. -//! -//! - [`keeper`] - strategy-keeper stores over [`LocalStoreHost`]: -//! the watch-set registry ([`WatchSet`]), block/epoch gate keys -//! ([`Gates`]) and the receipt-keyed idempotency journal -//! ([`Journal`]). -//! -//! - [`chain`] - `eth_call` JSON plumbing ([`eth_call_params`], -//! [`parse_eth_call_result`]) and the Chainlink AggregatorV3 reader -//! ([`read_latest_answer`]). -//! -//! - [`events`] - chain-log delivery: the native alloy [`Log`] modules -//! handle plus [`ChainLogParts`], the WIT-edge `From` input the bind -//! macro fills to rebuild it from the wire record. -//! -//! - [`config`] - `(key, value)` config-table lookups and decimal -//! scaling ([`get_required`], [`get_optional`], [`scale_decimal`]). -//! -//! - [`address`] - EVM address parsing with typed errors -//! ([`parse_address`], [`parse_address_list`]). -//! -//! - [`http`] - outbound HTTP over wasi:http in the standard `http` -//! crate's request/response vocabulary: a synchronous [`fetch`] -//! helper (guest target only), the [`Fetch`] trait seam for host-free -//! strategy tests, and a [`FetchError`] that distinguishes allowlist -//! denials from transport failures. -//! -//! - [`tracing`] - guest-side `tracing` facade: an events-only -//! subscriber plus panic hook that forward through a [`LogSink`] -//! seam. The bind macro wires the bound host logging call into the -//! sink so module authors emit `tracing::info!(...)` with no host -//! parameter to thread. -//! -//! ## Why no `wit_bindgen::generate!` here -//! -//! The macro emits types into the calling crate (the module's -//! cdylib). Re-exporting wit-bindgen output from a library crate -//! would duplicate symbols and break the component-export contract. -//! Helpers in this SDK therefore take primitive types (`&[u8]`, -//! `Option<&str>`, slices) rather than the per-module `Fault` -//! type; modules unpack their `Fault` on the way in. -//! -//! [`Address`]: alloy_primitives::Address -//! [`B256`]: alloy_primitives::B256 -//! [`Bytes`]: alloy_primitives::Bytes -//! [`U256`]: alloy_primitives::U256 -//! [`keccak256`]: alloy_primitives::keccak256 -//! [`Host`]: host::Host -//! [`ChainHost`]: host::ChainHost -//! [`LocalStoreHost`]: host::LocalStoreHost -//! [`LoggingHost`]: host::LoggingHost -//! [`Fault`]: host::Fault -//! [`WatchSet`]: keeper::WatchSet -//! [`Gates`]: keeper::Gates -//! [`Journal`]: keeper::Journal -//! [`eth_call_params`]: chain::eth_call_params -//! [`parse_eth_call_result`]: chain::parse_eth_call_result -//! [`read_latest_answer`]: chain::chainlink::read_latest_answer -//! [`Log`]: events::Log -//! [`ChainLogParts`]: events::ChainLogParts -//! [`get_required`]: config::get_required -//! [`get_optional`]: config::get_optional -//! [`scale_decimal`]: config::scale_decimal -//! [`parse_address`]: address::parse_address -//! [`parse_address_list`]: address::parse_address_list -//! [`fetch`]: http::Fetch::fetch -//! [`Fetch`]: http::Fetch -//! [`FetchError`]: http::FetchError -//! [`LogSink`]: tracing::LogSink - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![warn(missing_docs)] -#![cfg_attr(docsrs, feature(doc_cfg))] - -pub mod address; -pub mod chain; -pub mod config; -pub mod events; -pub mod host; -pub mod http; -pub mod keeper; -pub mod prelude; -pub mod tracing; -pub mod wit_bindgen_macro; - -/// The level vocabulary for every SDK log path: the host logging trait, -/// the guest tracing facade sink, and the module mocks all speak -/// `tracing_core::Level`. Its `Ord` is filter-oriented (`ERROR` is the -/// least verbose), so compare with that in mind rather than as severity. -pub use tracing_core::Level; - -#[cfg(test)] -mod proptests; diff --git a/crates/nexum-sdk/src/prelude.rs b/crates/nexum-sdk/src/prelude.rs deleted file mode 100644 index ef4bcc1f..00000000 --- a/crates/nexum-sdk/src/prelude.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Bulk-imports the primitives every module uses on every other line. -//! `use nexum_sdk::prelude::*` covers the alloy address / hash / -//! numeric types the chain helpers consume. -//! -//! The wit-bindgen-generated types (`Guest`, `Fault`, `Event`, ...) -//! are **not** re-exported here because they live in each module's own -//! crate (one `wit_bindgen::generate!` call per cdylib). Domain SDKs -//! ship their own prelude for their protocol surface. - -pub use alloy_primitives::{Address, B256, Bytes, U256, address, b256, hex, keccak256}; diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs deleted file mode 100644 index 3e33c68c..00000000 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ /dev/null @@ -1,220 +0,0 @@ -//! Declarative macro that generates the `WitBindgenHost` adapter -//! every module ships in `lib.rs`. -//! -//! Before this macro existed, each module hand-rolled ~80 lines of -//! mechanical glue: the `struct WitBindgenHost;` plus the core trait -//! impls (`ChainHost`, `LocalStoreHost`, `LoggingHost`) plus -//! `convert_chain_err` / `convert_fault` / `sdk_fault_into_wit` / -//! `convert_level`. The code differed across modules in zero places -//! that were not bugs. -//! -//! The macro assumes the module compiles against a world that -//! includes `nexum:host/event-module` with `wit_bindgen::generate!({ -//! ..., generate_all })`, so the standard wit-bindgen output paths -//! (`nexum::host::chain`, `nexum::host::local_store`, etc., plus the -//! crate-root `Fault`) are in scope at the call site. Modules -//! using a different world need to keep their own adapter for now. -//! -//! A domain SDK layers its own interfaces on top by invoking this -//! macro and adding trait impls for the same `WitBindgenHost` (the -//! CoW SDK's `bind_cow_host_via_wit_bindgen!` does exactly that). -//! -//! Usage in a module's `lib.rs`: -//! -//! ```ignore -//! wit_bindgen::generate!({ /* ... */ }); -//! nexum_sdk::bind_host_via_wit_bindgen!(); -//! // `WitBindgenHost`, `convert_chain_err`, `convert_fault`, -//! // `sdk_fault_into_wit`, `convert_level`, `HostLogSink`, and -//! // `install_tracing` are now in -//! // scope, with the wit-bindgen and SDK types tied together through -//! // identifier resolution. Call `install_tracing()` once at the top -//! // of `Guest::init` to route `tracing::info!(...)` to the host. A -//! // `From for nexum_sdk::events::Log` is also emitted so -//! // `on_event` maps a chain-logs batch straight to `Vec`. -//! ``` - -/// Generate `WitBindgenHost` + the core `*Host` trait impls + the -/// error / level converters. See module docs. -/// -/// Macro hygiene note: `macro_rules!` is not hygienic for type names -/// or function items, so the names `WitBindgenHost`, `convert_chain_err`, -/// `convert_fault`, `sdk_fault_into_wit`, `convert_level`, `HostLogSink`, -/// and `install_tracing` are intentionally visible in the caller's scope. -#[macro_export] -macro_rules! bind_host_via_wit_bindgen { - () => { - /// Wraps the module's per-cdylib wit-bindgen imports so the - /// strategy can hold a `&impl Host` instead of dispatching on - /// the free functions directly. Generated by - /// `nexum_sdk::bind_host_via_wit_bindgen!`. - struct WitBindgenHost; - - impl $crate::host::ChainHost for WitBindgenHost { - fn request( - &self, - chain_id: u64, - method: &str, - params: &str, - ) -> ::core::result::Result<::std::string::String, $crate::host::ChainError> { - nexum::host::chain::request(chain_id, method, params).map_err(convert_chain_err) - } - } - - impl $crate::host::LocalStoreHost for WitBindgenHost { - fn get( - &self, - key: &str, - ) -> ::core::result::Result< - ::core::option::Option<::std::vec::Vec>, - $crate::host::Fault, - > { - nexum::host::local_store::get(key).map_err(convert_fault) - } - fn set( - &self, - key: &str, - value: &[u8], - ) -> ::core::result::Result<(), $crate::host::Fault> { - nexum::host::local_store::set(key, value).map_err(convert_fault) - } - fn delete(&self, key: &str) -> ::core::result::Result<(), $crate::host::Fault> { - nexum::host::local_store::delete(key).map_err(convert_fault) - } - fn list_keys( - &self, - prefix: &str, - ) -> ::core::result::Result<::std::vec::Vec<::std::string::String>, $crate::host::Fault> - { - nexum::host::local_store::list_keys(prefix).map_err(convert_fault) - } - } - - impl $crate::host::LoggingHost for WitBindgenHost { - fn log(&self, level: $crate::Level, message: &str) { - nexum::host::logging::log(convert_level(level), message); - } - } - - /// Lift the wit-bindgen `chain.chain-error` (per-cdylib) into - /// the SDK's host-neutral `ChainError`. Exhaustive on both the - /// `Fault` vocabulary and the `RpcError` shape. - fn convert_chain_err(e: nexum::host::chain::ChainError) -> $crate::host::ChainError { - match e { - nexum::host::chain::ChainError::Fault(f) => { - $crate::host::ChainError::Fault(convert_fault(f)) - } - nexum::host::chain::ChainError::Rpc(r) => { - $crate::host::ChainError::Rpc($crate::host::RpcError { - code: r.code, - message: r.message, - data: r.data.map(::core::convert::Into::into), - }) - } - } - } - - /// Lift the wit-bindgen `types.fault` (per-cdylib) into the - /// SDK's `Fault`. Exhaustive on the seven-case vocabulary; the - /// `rate-limited` backoff record maps field for field. - fn convert_fault(f: nexum::host::types::Fault) -> $crate::host::Fault { - match f { - nexum::host::types::Fault::Unsupported(s) => $crate::host::Fault::Unsupported(s), - nexum::host::types::Fault::Unavailable(s) => $crate::host::Fault::Unavailable(s), - nexum::host::types::Fault::Denied(s) => $crate::host::Fault::Denied(s), - nexum::host::types::Fault::RateLimited(rl) => { - $crate::host::Fault::RateLimited($crate::host::RateLimit { - retry_after_ms: rl.retry_after_ms, - }) - } - nexum::host::types::Fault::Timeout => $crate::host::Fault::Timeout, - nexum::host::types::Fault::InvalidInput(s) => $crate::host::Fault::InvalidInput(s), - nexum::host::types::Fault::Internal(s) => $crate::host::Fault::Internal(s), - } - } - - /// Reverse direction: lower the SDK [`Fault`]( - /// $crate::host::Fault) back into the per-cdylib wit-bindgen - /// `Fault` so `Guest::init` / `Guest::on_event` can return what - /// the export signature expects. - /// - /// Carries a wildcard arm because `$crate::host::Fault` is - /// `#[non_exhaustive]`: a future SDK-side case must compile in - /// module crates without source changes. Falls back to - /// `internal` carrying the `Display` detail. - fn sdk_fault_into_wit(f: $crate::host::Fault) -> nexum::host::types::Fault { - use nexum::host::types::{Fault as WitFault, RateLimit as WitRateLimit}; - match f { - $crate::host::Fault::Unsupported(s) => WitFault::Unsupported(s), - $crate::host::Fault::Unavailable(s) => WitFault::Unavailable(s), - $crate::host::Fault::Denied(s) => WitFault::Denied(s), - $crate::host::Fault::RateLimited(rl) => WitFault::RateLimited(WitRateLimit { - retry_after_ms: rl.retry_after_ms, - }), - $crate::host::Fault::Timeout => WitFault::Timeout, - $crate::host::Fault::InvalidInput(s) => WitFault::InvalidInput(s), - $crate::host::Fault::Internal(s) => WitFault::Internal(s), - // `$crate::host::Fault` is `#[non_exhaustive]`; a future - // SDK case lands here as `internal`. - other => WitFault::Internal(::std::string::ToString::to_string(&other)), - } - } - - /// Translate a `tracing_core::Level` into the wit-bindgen - /// `logging::Level` wire enum. `Level` is a set of associated - /// consts, not a matchable enum, so compare rather than match; - /// the five tiers are total, so the final arm is `Trace`. - fn convert_level(level: $crate::Level) -> nexum::host::logging::Level { - use $crate::Level; - if level == Level::ERROR { - nexum::host::logging::Level::Error - } else if level == Level::WARN { - nexum::host::logging::Level::Warn - } else if level == Level::INFO { - nexum::host::logging::Level::Info - } else if level == Level::DEBUG { - nexum::host::logging::Level::Debug - } else { - nexum::host::logging::Level::Trace - } - } - - /// Rebuild the native alloy log from the per-cdylib wit-bindgen - /// `chain-log` record. The one conversion home for the guest WIT - /// edge: strategies receive `nexum_sdk::events::Log`, never the - /// wire record. Assembly logic lives in `nexum_sdk::events`. - impl ::core::convert::From for $crate::events::Log { - fn from(log: nexum::host::types::ChainLog) -> Self { - $crate::events::ChainLogParts { - address: &log.address, - topics: &log.topics, - data: &log.data, - block_hash: log.block_hash.as_deref(), - block_number: log.block_number, - block_timestamp: log.block_timestamp, - transaction_hash: log.transaction_hash.as_deref(), - transaction_index: log.transaction_index, - log_index: log.log_index, - removed: log.removed, - } - .into() - } - } - - /// Routes guest `tracing` events to the bound host logging call. - struct HostLogSink; - - impl $crate::tracing::LogSink for HostLogSink { - fn log(&self, level: $crate::Level, message: &str) { - ::log(&WitBindgenHost, level, message); - } - } - - /// Install the guest tracing facade and panic hook, forwarding - /// through the bound host logging call. Call once at the top of - /// `Guest::init` so `tracing::info!(...)` reaches the host. - fn install_tracing() { - $crate::tracing::init(HostLogSink); - } - }; -} diff --git a/crates/nexum-sdk/tests/keeper.rs b/crates/nexum-sdk/tests/keeper.rs deleted file mode 100644 index 5ac54c87..00000000 --- a/crates/nexum-sdk/tests/keeper.rs +++ /dev/null @@ -1,353 +0,0 @@ -//! Keeper-store acceptance tests against the composed CoW -//! `shepherd_sdk_test::MockHost` - the same host the flagship modules -//! test with. These live as an integration test (not `#[cfg(test)]`) -//! because the mock crate links `nexum-sdk` externally, and the -//! external and unit-test copies of the host traits are distinct types. - -use alloy_primitives::{Address, B256, address, b256}; -use nexum_sdk::host::{Fault, LocalStoreHost as _}; -use nexum_sdk::keeper::{ - Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, WATCH_PREFIX, WatchRef, WatchSet, -}; -use shepherd_sdk_test::MockHost; - -fn sample_owner() -> Address { - address!("00112233445566778899aabbccddeeff00112233") -} - -fn sample_hash() -> B256 { - b256!("0202020202020202020202020202020202020202020202020202020202020202") -} - -// ---- watch keys ---- - -#[test] -fn watch_key_is_lowercase_prefixed_hex() { - let key = WatchSet::::key(&sample_owner(), &sample_hash()); - assert_eq!( - key, - concat!( - "watch:0x00112233445566778899aabbccddeeff00112233:", - "0x0202020202020202020202020202020202020202020202020202020202020202", - ), - ); -} - -#[test] -fn watch_key_round_trips_via_parse() { - let key = WatchSet::::key(&sample_owner(), &sample_hash()); - let watch = WatchRef::parse(&key).expect("parse"); - assert_eq!( - watch.owner_hex().parse::
().unwrap(), - sample_owner() - ); - assert_eq!(watch.hash_hex().parse::().unwrap(), sample_hash()); - assert_eq!(watch.key(), key); -} - -#[test] -fn parse_rejects_missing_prefix_or_separator() { - assert_eq!(WatchRef::parse("gate:0xaa:0xbb"), None); - assert_eq!(WatchRef::parse("watch:0xaa0xbb"), None); - assert_eq!(WatchRef::parse(""), None); -} - -#[test] -fn parse_rejects_empty_halves() { - // `watch::` splits into two empty halves, which would derive - // degenerate gate keys like `next_block::`; reject it outright. - assert_eq!(WatchRef::parse("watch::"), None); - assert_eq!(WatchRef::parse("watch:0xaa:"), None); - assert_eq!(WatchRef::parse("watch::0xbb"), None); - // A well-formed key with both halves still parses. - assert!(WatchRef::parse("watch:0xaa:0xbb").is_some()); -} - -#[test] -fn parse_preserves_key_substrings_verbatim() { - // A foreign writer may have cased the hex differently; gate keys - // must derive from the stored substrings, not from a re-rendered - // canonical form. - let watch = WatchRef::parse("watch:0xAABB:0xCCDD").expect("parse"); - assert_eq!(watch.owner_hex(), "0xAABB"); - assert_eq!(watch.hash_hex(), "0xCCDD"); - assert_eq!(watch.next_block_key(), "next_block:0xAABB:0xCCDD"); - assert_eq!(watch.next_epoch_key(), "next_epoch:0xAABB:0xCCDD"); -} - -// ---- watch-set registry ---- - -#[test] -fn put_get_list_round_trip() { - let host = MockHost::new(); - let watches = WatchSet::new(&host); - - let key = watches - .put(&sample_owner(), &sample_hash(), b"params") - .unwrap(); - assert_eq!(watches.list().unwrap(), vec![key.clone()]); - - let watch = WatchRef::parse(&key).unwrap(); - assert_eq!(watches.get(watch).unwrap().as_deref(), Some(&b"params"[..])); -} - -#[test] -fn put_overwrites_in_place() { - let host = MockHost::new(); - let watches = WatchSet::new(&host); - - watches - .put(&sample_owner(), &sample_hash(), b"one") - .unwrap(); - let key = watches - .put(&sample_owner(), &sample_hash(), b"two") - .unwrap(); - - assert_eq!(host.store.len(), 1, "re-put must not duplicate the row"); - let watch = WatchRef::parse(&key).unwrap(); - assert_eq!(watches.get(watch).unwrap().as_deref(), Some(&b"two"[..])); -} - -#[test] -fn get_absent_watch_is_none() { - let host = MockHost::new(); - let watches = WatchSet::new(&host); - let key = WatchSet::::key(&sample_owner(), &sample_hash()); - let watch = WatchRef::parse(&key).unwrap(); - assert_eq!(watches.get(watch).unwrap(), None); -} - -#[test] -fn list_scans_only_the_watch_prefix() { - let host = MockHost::new(); - let watches = WatchSet::new(&host); - let key = watches - .put(&sample_owner(), &sample_hash(), b"params") - .unwrap(); - Journal::submitted(&host).record("0xuid").unwrap(); - - assert_eq!(watches.list().unwrap(), vec![key]); -} - -// ---- atomic delete ---- - -#[test] -fn remove_drops_watch_and_all_gate_keys() { - let host = MockHost::new(); - let watches = WatchSet::new(&host); - let gates = Gates::new(&host); - - let key = watches - .put(&sample_owner(), &sample_hash(), b"params") - .unwrap(); - let watch = WatchRef::parse(&key).unwrap(); - gates.set_next_block(watch, 500).unwrap(); - gates.set_next_epoch(watch, 1_700_000_000).unwrap(); - assert_eq!(host.store.len(), 3); - - watches.remove(watch).unwrap(); - - assert!(host.store.is_empty(), "watch and both gates must go"); -} - -#[test] -fn remove_without_gates_is_clean() { - let host = MockHost::new(); - let watches = WatchSet::new(&host); - let key = watches - .put(&sample_owner(), &sample_hash(), b"params") - .unwrap(); - watches.remove(WatchRef::parse(&key).unwrap()).unwrap(); - assert!(host.store.is_empty()); -} - -#[test] -fn remove_clears_gates_before_the_watch_row() { - // A fault on the watch delete must still find the gates gone: the - // retryable leftover is the watch row, never an orphaned gate. - let host = MockHost::new(); - let watches = WatchSet::new(&host); - let gates = Gates::new(&host); - let key = watches - .put(&sample_owner(), &sample_hash(), b"params") - .unwrap(); - let watch = WatchRef::parse(&key).unwrap(); - gates.set_next_block(watch, 500).unwrap(); - gates.set_next_epoch(watch, 1_700_000_000).unwrap(); - - host.store - .fail_on(WATCH_PREFIX, Fault::Unavailable("injected".into())); - - watches.remove(watch).unwrap_err(); - - let snapshot = host.store.snapshot(); - assert!( - !snapshot - .keys() - .any(|k| k.starts_with(NEXT_BLOCK_PREFIX) || k.starts_with(NEXT_EPOCH_PREFIX)), - "gates must already be gone when the watch delete faults", - ); - assert!( - snapshot.contains_key(&key), - "the watch row stays behind so a retry can re-drop it", - ); -} - -#[test] -fn remove_propagates_a_gate_delete_fault_and_keeps_the_watch() { - let host = MockHost::new(); - let watches = WatchSet::new(&host); - let key = watches - .put(&sample_owner(), &sample_hash(), b"params") - .unwrap(); - let watch = WatchRef::parse(&key).unwrap(); - - host.store - .fail_on(NEXT_BLOCK_PREFIX, Fault::Unavailable("injected".into())); - - watches.remove(watch).unwrap_err(); - - assert!( - host.store.snapshot().contains_key(&key), - "a gate-delete fault must leave the watch for a retry", - ); -} - -// ---- gates ---- - -#[test] -fn ready_with_no_gates_set() { - let host = MockHost::new(); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - assert!(Gates::new(&host).is_ready(watch, 0, 0).unwrap()); -} - -#[test] -fn next_block_gate_is_inclusive_at_threshold() { - let host = MockHost::new(); - let gates = Gates::new(&host); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - gates.set_next_block(watch, 500).unwrap(); - - assert!(!gates.is_ready(watch, 499, u64::MAX).unwrap()); - assert!(gates.is_ready(watch, 500, u64::MAX).unwrap()); - assert!(gates.is_ready(watch, 501, u64::MAX).unwrap()); -} - -#[test] -fn next_epoch_gate_is_inclusive_at_threshold() { - let host = MockHost::new(); - let gates = Gates::new(&host); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - gates.set_next_epoch(watch, 1_700_000_000).unwrap(); - - assert!(!gates.is_ready(watch, u64::MAX, 1_699_999_999).unwrap()); - assert!(gates.is_ready(watch, u64::MAX, 1_700_000_000).unwrap()); -} - -#[test] -fn both_gates_must_pass() { - let host = MockHost::new(); - let gates = Gates::new(&host); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - gates.set_next_block(watch, 100).unwrap(); - gates.set_next_epoch(watch, 2_000).unwrap(); - - assert!(!gates.is_ready(watch, 100, 1_999).unwrap()); - assert!(!gates.is_ready(watch, 99, 2_000).unwrap()); - assert!(gates.is_ready(watch, 100, 2_000).unwrap()); -} - -#[test] -fn gate_values_are_u64_le() { - let host = MockHost::new(); - let gates = Gates::new(&host); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - gates.set_next_block(watch, 0x0102_0304_0506_0708).unwrap(); - - assert_eq!( - host.store.snapshot().get("next_block:0xaa:0xbb").unwrap(), - &0x0102_0304_0506_0708_u64.to_le_bytes().to_vec(), - ); -} - -#[test] -fn malformed_gate_value_reads_as_no_gate() { - let host = MockHost::new(); - let gates = Gates::new(&host); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - host.store.set("next_block:0xaa:0xbb", b"not8b").unwrap(); - - assert!( - gates.is_ready(watch, 0, 0).unwrap(), - "a corrupt gate can only make the watch poll sooner", - ); -} - -#[test] -fn clear_removes_both_gate_keys() { - let host = MockHost::new(); - let gates = Gates::new(&host); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - gates.set_next_block(watch, 1).unwrap(); - gates.set_next_epoch(watch, 2).unwrap(); - - gates.clear(watch).unwrap(); - - assert!(host.store.is_empty()); - // And clearing again stays a no-op. - gates.clear(watch).unwrap(); -} - -#[test] -fn gate_fault_propagates_from_is_ready() { - let host = MockHost::new(); - let gates = Gates::new(&host); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - host.store - .fail_on(NEXT_EPOCH_PREFIX, Fault::Unavailable("injected".into())); - - gates.is_ready(watch, 0, 0).unwrap_err(); -} - -// ---- journal ---- - -#[test] -fn journal_round_trips_a_receipt() { - let host = MockHost::new(); - let journal = Journal::submitted(&host); - - assert!(!journal.contains("0xuid").unwrap()); - journal.record("0xuid").unwrap(); - assert!(journal.contains("0xuid").unwrap()); -} - -#[test] -fn journal_marker_is_an_empty_presence_row() { - let host = MockHost::new(); - Journal::submitted(&host).record("0xuid").unwrap(); - assert_eq!( - host.store.snapshot().get("submitted:0xuid").unwrap(), - &Vec::::new(), - ); -} - -#[test] -fn journal_record_is_idempotent() { - let host = MockHost::new(); - let journal = Journal::observed(&host); - journal.record("0xuid").unwrap(); - journal.record("0xuid").unwrap(); - assert_eq!(host.store.len(), 1); -} - -#[test] -fn submitted_and_observed_keyspaces_are_disjoint() { - let host = MockHost::new(); - Journal::submitted(&host).record("0xuid").unwrap(); - - assert!(!Journal::observed(&host).contains("0xuid").unwrap()); - let snapshot = host.store.snapshot(); - assert!(snapshot.contains_key("submitted:0xuid")); - assert!(!snapshot.contains_key("observed:0xuid")); -} diff --git a/crates/shepherd-backtest/Cargo.toml b/crates/shepherd-backtest/Cargo.toml deleted file mode 100644 index 2ec14aa6..00000000 --- a/crates/shepherd-backtest/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "shepherd-backtest" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Offline replay harness for Shepherd modules — drives strategy code against a fixtures dump of real Sepolia events via MockHost." - -[[bin]] -name = "shepherd-backtest" -path = "src/main.rs" - -[dependencies] -# Strategy code under test. ethflow-watcher exposes a native rlib -# (alongside its wasm cdylib) specifically so this crate can drive -# `strategy::on_chain_logs` directly without an embedded runtime. -ethflow-watcher = { path = "../../modules/ethflow-watcher" } -nexum-sdk = { path = "../nexum-sdk" } -shepherd-sdk-test = { path = "../shepherd-sdk-test" } - -anyhow.workspace = true -clap.workspace = true -serde.workspace = true -serde_json = { workspace = true, features = ["std"] } -hex.workspace = true diff --git a/crates/shepherd-backtest/src/fixtures.rs b/crates/shepherd-backtest/src/fixtures.rs deleted file mode 100644 index 92f1f0f4..00000000 --- a/crates/shepherd-backtest/src/fixtures.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! JSON deserialization for the Python collector's -//! `tools/backtest-collect/fixtures-YYYY-MM-DD.json` output. -//! -//! Mirrors `tools/backtest-collect/backtest_collect.py` exactly: -//! every field present in the JSON must round-trip into a -//! [`Fixtures`] without information loss, since the replay -//! harness relies on raw `eth_getLogs` topics + data to reconstruct -//! a faithful alloy log. TWAP fields are deserialised but not yet -//! consumed by the replay (Phase 2B); keep them on the struct so -//! the fixture file is the canonical schema. - -#![allow(dead_code)] - -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -pub struct Fixtures { - pub metadata: Metadata, - pub ethflow_orders: Vec, - pub twap_conditionals: Vec, -} - -#[derive(Debug, Deserialize)] -pub struct Metadata { - pub collected_at: String, - pub chain_id: u64, - pub chain_name: String, - pub window_days: u32, - pub from_block: u64, - pub to_block: u64, - pub rpc_url: String, - pub cow_api: String, - pub ethflow_owner: String, - pub composable_cow: String, - #[serde(default)] - pub notes: Vec, -} - -#[derive(Debug, Deserialize)] -pub struct EthFlowFixture { - pub uid: String, - pub block_number: u64, - pub block_timestamp: u64, - pub tx_hash: Option, - pub log_index: u64, - pub contract: String, - pub sender: Option, - pub app_data_hash: String, - /// Resolved app_data document fetched from - /// `GET /api/v1/app_data/{hash}` at collection time. `None` if - /// the hash 404'd (no mirror in the orderbook's app_data store). - pub app_data_resolved: Option, - pub raw_log: RawLog, -} - -#[derive(Debug, Deserialize)] -pub struct TwapFixture { - pub owner: Option, - pub block_number: u64, - pub block_timestamp: u64, - pub tx_hash: Option, - pub log_index: u64, - pub params: TwapParams, - pub raw_log: RawLog, -} - -#[derive(Debug, Deserialize)] -pub struct TwapParams { - pub handler: String, - pub salt: String, - pub static_input: String, -} - -#[derive(Debug, Deserialize)] -pub struct RawLog { - /// Each topic is a 32-byte hex string with `0x` prefix. The - /// `OrderPlacement` and `ConditionalOrderCreated` events both - /// carry exactly 2 topics: `topic0` (the signature hash) and - /// `topic1` (the indexed `sender` / `owner` address). - pub topics: Vec, - /// ABI-encoded payload, hex-prefixed. - pub data: String, -} - -impl RawLog { - /// Decode each `0x...` topic into a 32-byte vector. The strategy - /// layer reads topics as `&[u8]` (right-padded address in topic1 - /// for indexed parameters), so we preserve the byte order. - pub fn topics_bytes(&self) -> Result>, hex::FromHexError> { - self.topics - .iter() - .map(|t| hex::decode(t.strip_prefix("0x").unwrap_or(t.as_str()))) - .collect() - } - - /// Decode the `data` hex string. - pub fn data_bytes(&self) -> Result, hex::FromHexError> { - hex::decode(self.data.strip_prefix("0x").unwrap_or(self.data.as_str())) - } -} - -/// Decode a `0x...` address string into the 20-byte representation -/// the strategy consumes. Thin wrapper around the shared -/// [`nexum_sdk::address::parse_address`] helper (JC5 -/// consolidation) so this crate, balance-tracker, and any future -/// strategy module surface the same typed error. -pub fn parse_address(s: &str) -> Result<[u8; 20], nexum_sdk::address::AddressParse> { - nexum_sdk::address::parse_address(s).map(|addr| addr.into_array()) -} diff --git a/crates/shepherd-backtest/src/main.rs b/crates/shepherd-backtest/src/main.rs deleted file mode 100644 index 45d446b6..00000000 --- a/crates/shepherd-backtest/src/main.rs +++ /dev/null @@ -1,139 +0,0 @@ -//! # shepherd-backtest -//! -//! Offline replay harness for Shepherd modules. Loads a fixtures -//! JSON produced by `tools/backtest-collect/backtest_collect.py`, -//! drives each on-chain event through the production strategy code -//! via `shepherd_sdk_test::MockHost`, classifies the result, and -//! emits a Markdown report at -//! `docs/operations/backtest-reports/backtest-7d-YYYY-MM-DD.md`. -//! -//! ## Scope -//! -//! v1 covers the EthFlow lane end-to-end. The TWAP lane requires -//! per-part eth_call walking against an archive RPC which the -//! current public-tier endpoints refuse (see the -//! `tools/baseline-latency` finding). TWAP fixtures are -//! still loaded and counted in the report so the gap is visible, -//! but the replay is gated on a paid endpoint (Phase 2B). - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] - -use std::path::PathBuf; - -use clap::Parser; - -mod fixtures; -mod replay; -mod report; - -use fixtures::Fixtures; -use replay::{Classification, replay_ethflow}; - -#[derive(Parser, Debug)] -#[command( - name = "shepherd-backtest", - about = "Replay collected Sepolia events through production strategies" -)] -struct Args { - /// Fixtures JSON produced by `tools/backtest-collect/backtest_collect.py`. - #[arg(long)] - fixtures: PathBuf, - - /// Markdown report output. The default path follows the - /// `backtest-{window}d-{date}.md` convention the - /// `docs/operations/backtest-reports/` directory expects. - #[arg(long)] - out: Option, - - /// Acceptance threshold for the report's sign-off line. The - /// acceptance criterion is ≥ 95% of replayed events - /// land in `Observed` or `RejectedExpected`; the threshold is - /// surfaced as a CLI flag so a soak-team override is possible - /// without re-editing the binary. - #[arg(long, default_value_t = 0.95)] - accept_threshold: f64, -} - -fn main() -> anyhow::Result<()> { - let args = Args::parse(); - eprintln!( - "=== shepherd-backtest - loading {} ===", - args.fixtures.display() - ); - let raw = std::fs::read_to_string(&args.fixtures)?; - let fx: Fixtures = serde_json::from_str(&raw)?; - eprintln!( - " chain: {} (id={}) window: {}d blocks {}..{}", - fx.metadata.chain_name, - fx.metadata.chain_id, - fx.metadata.window_days, - fx.metadata.from_block, - fx.metadata.to_block, - ); - eprintln!(" ethflow fixtures: {}", fx.ethflow_orders.len()); - eprintln!(" twap fixtures: {}", fx.twap_conditionals.len()); - - // ---- replay EthFlow ---- - let mut outcomes = Vec::with_capacity(fx.ethflow_orders.len()); - for (idx, order) in fx.ethflow_orders.iter().enumerate() { - let outcome = replay_ethflow(order, fx.metadata.chain_id); - if idx < 3 || idx == fx.ethflow_orders.len() - 1 { - eprintln!( - " [{}/{}] {} {}", - idx + 1, - fx.ethflow_orders.len(), - outcome.class.label(), - outcome.uid, - ); - } - outcomes.push(outcome); - } - - let report_md = report::render(&fx, &outcomes, args.accept_threshold); - let out_path = args.out.unwrap_or_else(|| { - let date = fx - .metadata - .collected_at - .split('T') - .next() - .unwrap_or("unknown"); - PathBuf::from(format!( - "docs/operations/backtest-reports/backtest-{}d-{}.md", - fx.metadata.window_days, date - )) - }); - if let Some(parent) = out_path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&out_path, &report_md)?; - eprintln!("\nreport written: {}", out_path.display()); - - // ---- summary + exit code ---- - let total = outcomes.len(); - let accepted = outcomes - .iter() - .filter(|o| { - matches!( - o.class, - Classification::Observed | Classification::RejectedExpected(_) - ) - }) - .count(); - let ratio = if total == 0 { - 0.0 - } else { - accepted as f64 / total as f64 - }; - eprintln!( - "summary: {}/{} ({:.1}%) Observed+RejectedExpected (threshold {:.1}%)", - accepted, - total, - ratio * 100.0, - args.accept_threshold * 100.0, - ); - if total > 0 && ratio < args.accept_threshold { - eprintln!("FAIL: below threshold"); - std::process::exit(1); - } - Ok(()) -} diff --git a/crates/shepherd-backtest/src/replay.rs b/crates/shepherd-backtest/src/replay.rs deleted file mode 100644 index 71a0aef1..00000000 --- a/crates/shepherd-backtest/src/replay.rs +++ /dev/null @@ -1,194 +0,0 @@ -//! Per-event replay against `ethflow_watcher::strategy::on_chain_logs`. -//! -//! Each [`EthFlowFixture`] is driven through the production strategy -//! exactly the way the live engine does it: a fresh [`MockHost`] is -//! constructed, a catch-all 200 response is programmed for any -//! `cow_api_request` call (the observe+verify strategy GETs -//! `/api/v1/orders/{uid}` to confirm the orderbook has indexed the -//! order), and `strategy::on_chain_logs` is invoked with an alloy -//! `Log` reconstructed from the raw `eth_getLogs` payload. -//! -//! The classification falls into one of the four buckets defined in -//! the issue: -//! -//! - `Observed`: the strategy verified the order with exactly one -//! `GET /api/v1/orders/{uid}` and wrote `observed:{uid}` to the -//! local store. This is the success case under the observe+verify -//! strategy. -//! - `RejectedExpected`: the strategy returned without observing in a -//! documented case (reserved for fixtures where the mock returns 404 -//! — not applicable when all fixtures program 200). -//! - `RejectedUnexpected`: the strategy returned Ok but the observe -//! contract was violated (no `observed:{uid}` marker, an unexpected -//! orderbook call shape, or a `submit_order` attempt); a follow-up -//! should be filed before the report closes. -//! - `StrategyError`: `on_chain_logs` returned `Err(fault)`. A test -//! bug or an `unreachable!` we want to investigate. - -use ethflow_watcher::strategy; -use shepherd_sdk_test::MockHost; - -use crate::fixtures::{EthFlowFixture, parse_address}; - -/// The collected outcome for one replayed event. -#[derive(Debug)] -pub struct ReplayOutcome { - pub uid: String, - pub block_number: u64, - pub block_timestamp: u64, - pub class: Classification, - /// Log lines the strategy emitted while processing this fixture. - /// Surfaced in the report for failure triage. - pub log_lines: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Classification { - Observed, - /// Reserved for any documented skip path (e.g. a fixture where the mock - /// returns 404 for an un-indexed order). Not emitted in the current batch; - /// retained so the acceptance-ratio formula is complete. - #[allow(dead_code)] - RejectedExpected(String), - RejectedUnexpected(String), - StrategyError(String), -} - -impl Classification { - pub fn label(&self) -> &'static str { - match self { - Classification::Observed => "Observed", - Classification::RejectedExpected(_) => "RejectedExpected", - Classification::RejectedUnexpected(_) => "RejectedUnexpected", - Classification::StrategyError(_) => "StrategyError", - } - } - - pub fn detail(&self) -> &str { - match self { - Classification::Observed => "", - Classification::RejectedExpected(d) - | Classification::RejectedUnexpected(d) - | Classification::StrategyError(d) => d, - } - } -} - -/// Replay one EthFlow fixture through the production strategy. -pub fn replay_ethflow(fx: &EthFlowFixture, chain_id: u64) -> ReplayOutcome { - let host = MockHost::new(); - - // Program a catch-all 200 response for any cow_api_request. In - // the observe+verify strategy the module GETs - // `/api/v1/orders/{uid}` to confirm the orderbook has indexed the - // order. In backtest context all fixtures are confirmed real orders, - // so the mock orderbook always returns 200 (indexed). - host.cow_api.respond_to_request(Ok("{}".to_string())); - - // Reconstruct the log fields. Topics + data come straight from the - // collector's `raw_log`; the contract address is the EthFlow - // owner the fixture pins. - let topics = match fx.raw_log.topics_bytes() { - Ok(t) => t, - Err(e) => { - return error_outcome(fx, format!("topics hex decode: {e}")); - } - }; - let data = match fx.raw_log.data_bytes() { - Ok(d) => d, - Err(e) => { - return error_outcome(fx, format!("data hex decode: {e}")); - } - }; - let address = match parse_address(&fx.contract) { - Ok(a) => a, - Err(e) => { - return error_outcome(fx, format!("contract address: {e}")); - } - }; - // Assemble the alloy log the strategy consumes, threading the - // fixture's block-scoped fields through the same WIT-edge conversion - // the runtime uses. - let log: nexum_sdk::events::Log = nexum_sdk::events::ChainLogParts { - address: &address, - topics: &topics, - data: &data, - block_number: Some(fx.block_number), - block_timestamp: Some(fx.block_timestamp), - log_index: Some(fx.log_index), - ..Default::default() - } - .into(); - - // Drive the strategy. - let result = strategy::on_chain_logs(&host, chain_id, &[log]); - let log_lines: Vec = host - .logging - .lines() - .into_iter() - .map(|l| format!("[{:?}] {}", l.level, l.message)) - .collect(); - - let class = match result { - Err(e) => Classification::StrategyError(e.to_string()), - Ok(()) => classify_ok(&host, &fx.uid, &log_lines), - }; - - ReplayOutcome { - uid: fx.uid.clone(), - block_number: fx.block_number, - block_timestamp: fx.block_timestamp, - class, - log_lines, - } -} - -fn error_outcome(fx: &EthFlowFixture, reason: String) -> ReplayOutcome { - ReplayOutcome { - uid: fx.uid.clone(), - block_number: fx.block_number, - block_timestamp: fx.block_timestamp, - class: Classification::StrategyError(reason), - log_lines: vec![], - } -} - -/// Classify an `Ok(())` replay by inspecting the mock's recorded -/// side effects, independent of the strategy's own logging. -/// -/// `Observed` demands the full observe contract, not just the marker: -/// exactly one orderbook call, shaped `GET /api/v1/orders/{uid}`, -/// zero `submit_order` attempts, and the `observed:{uid}` store key. -/// The exact-UID match (never an `observed:` prefix scan) means a -/// compute_uid divergence between module and collector cannot produce -/// a false `Observed`. -fn classify_ok(host: &MockHost, uid: &str, log_lines: &[String]) -> Classification { - if host.cow_api.call_count() > 0 { - return Classification::RejectedUnexpected( - "strategy called submit_order; observe+verify must never submit".into(), - ); - } - let requests = host.cow_api.request_calls(); - let expected_path = format!("/api/v1/orders/{uid}"); - if requests.len() != 1 || requests[0].method != "GET" || requests[0].path != expected_path { - let shapes: Vec = requests - .iter() - .map(|r| format!("{} {}", r.method, r.path)) - .collect(); - return Classification::RejectedUnexpected(format!( - "expected exactly one GET {expected_path}; saw [{}]", - shapes.join(", ") - )); - } - if host - .store - .snapshot() - .contains_key(&format!("observed:{uid}")) - { - return Classification::Observed; - } - // The strategy returned Ok without writing an observed marker. - // Surface for triage. - let last_log = log_lines.last().cloned().unwrap_or_default(); - Classification::RejectedUnexpected(format!("Ok with no observed marker; last log: {last_log}")) -} diff --git a/crates/shepherd-backtest/src/report.rs b/crates/shepherd-backtest/src/report.rs deleted file mode 100644 index 9e64b9fd..00000000 --- a/crates/shepherd-backtest/src/report.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! Markdown report renderer for the backtest run. Modelled on the -//! E2E report shape - run metadata, per-module counts, -//! per-event appendix table, anomalies, sign-off. - -use std::collections::BTreeMap; - -use crate::fixtures::Fixtures; -use crate::replay::{Classification, ReplayOutcome}; - -pub fn render(fx: &Fixtures, outcomes: &[ReplayOutcome], threshold: f64) -> String { - let mut by_class: BTreeMap<&'static str, usize> = BTreeMap::new(); - for o in outcomes { - *by_class.entry(o.class.label()).or_default() += 1; - } - let total = outcomes.len(); - let accepted = outcomes - .iter() - .filter(|o| { - matches!( - o.class, - Classification::Observed | Classification::RejectedExpected(_) - ) - }) - .count(); - let ratio = if total == 0 { - 0.0 - } else { - accepted as f64 / total as f64 - }; - let pass = total == 0 || ratio >= threshold; - - let now = chrono_like_now(); - let mut out = String::new(); - out.push_str(&format!( - "# Pre-soak backtest - {}d window on {} ({})\n\n", - fx.metadata.window_days, fx.metadata.chain_name, now, - )); - out.push_str( - "Replays every collected EthFlow `OrderPlacement` event through the production \ - `ethflow_watcher::strategy::on_chain_logs` code path via `shepherd_sdk_test::MockHost`. \ - The orderbook is **never hit**: the MockHost programs a catch-all 200 for all \ - `cow_api_request` calls so the observe+verify strategy sees every fixture as \ - already indexed. Success is measured by whether the strategy wrote the exact \ - `observed:{uid}` marker to the local store after the 200 confirmation.\n\n", - ); - out.push_str("## Run metadata\n\n"); - out.push_str("| Field | Value |\n|---|---|\n"); - out.push_str(&format!( - "| Chain | {} (id={}) |\n", - fx.metadata.chain_name, fx.metadata.chain_id - )); - out.push_str(&format!( - "| Window | {}d ({}..{}) |\n", - fx.metadata.window_days, fx.metadata.from_block, fx.metadata.to_block - )); - out.push_str(&format!( - "| Collected at | {} |\n", - fx.metadata.collected_at - )); - out.push_str(&format!("| RPC | `{}` |\n", fx.metadata.rpc_url)); - out.push_str(&format!("| Orderbook | `{}` |\n", fx.metadata.cow_api)); - out.push_str(&format!( - "| EthFlow owner | `{}` |\n", - fx.metadata.ethflow_owner - )); - out.push_str(&format!( - "| ComposableCoW | `{}` |\n", - fx.metadata.composable_cow - )); - out.push_str(&format!( - "| Accept threshold | {:.0}% |\n", - threshold * 100.0 - )); - out.push('\n'); - - if !fx.metadata.notes.is_empty() { - out.push_str("### Collector notes\n\n"); - for n in &fx.metadata.notes { - out.push_str(&format!("- {n}\n")); - } - out.push('\n'); - } - - out.push_str("## EthFlow replay summary\n\n"); - out.push_str(&format!("- Events replayed: **{total}**\n")); - for (label, count) in &by_class { - out.push_str(&format!( - "- {label}: **{count}** ({:.1}%)\n", - *count as f64 / total.max(1) as f64 * 100.0 - )); - } - out.push_str(&format!( - "\nAccepted (Observed + RejectedExpected): **{accepted}/{total} = {:.1}%** - {} threshold ({:.0}%).\n\n", - ratio * 100.0, - if pass { "PASS vs." } else { "**FAIL** vs." }, - threshold * 100.0, - )); - - // ---- anomalies ---- - let anomalies: Vec<&ReplayOutcome> = outcomes - .iter() - .filter(|o| { - matches!( - o.class, - Classification::RejectedUnexpected(_) | Classification::StrategyError(_) - ) - }) - .collect(); - out.push_str("## Anomalies\n\n"); - if anomalies.is_empty() { - out.push_str("None. Every replayed event landed in `Observed` or `RejectedExpected`.\n\n"); - } else { - out.push_str(&format!( - "**{} event(s) need a follow-up before this report can be signed off.** \ - File one issue per uid (use the gitBranchName conventions).\n\n", - anomalies.len(), - )); - out.push_str("| uid | block | class | detail | last log |\n"); - out.push_str("|---|---:|---|---|---|\n"); - for o in anomalies { - let last_log = o.log_lines.last().map(String::as_str).unwrap_or(""); - out.push_str(&format!( - "| `{}` | {} | {} | {} | {} |\n", - shorten(&o.uid), - o.block_number, - o.class.label(), - escape_md(o.class.detail()), - escape_md(last_log), - )); - } - out.push('\n'); - } - - // ---- TWAP lane status ---- - out.push_str("## TWAP lane status\n\n"); - out.push_str(&format!( - "{} `ConditionalOrderCreated` events were collected in this window. \ - **Replay deferred to Phase 2B** because driving `twap_monitor::strategy::on_block` \ - requires walking each watch's `eth_call(getTradeableOrderWithSignature)` per-block - \ - a workload public-tier RPCs refuse (see the baseline-latency finding). The \ - fixtures are committed for the future re-run; the TWAP gap on the sign-off is \ - intentional and tracked separately.\n\n", - fx.twap_conditionals.len(), - )); - - // ---- sign-off ---- - out.push_str("## Sign-off\n\n"); - if pass { - out.push_str(&format!( - "**PASS.** EthFlow replay clears the {:.0}% acceptance bar with no \ - outstanding anomalies. All fixtures were Observed (strategy wrote \ - `observed:{{uid}}` to local store). Soak is unblocked from the backtest \ - side; remaining blockers are external (paid RPC + VM for the wall-clock run).\n\n", - threshold * 100.0, - )); - } else { - out.push_str(&format!( - "**FAIL.** EthFlow replay landed at {:.1}%, below the {:.0}% bar. \ - Anomalies above must be resolved (or formally classified as \ - RejectedExpected with a corresponding code change in the strategy) before \ - this report can be re-rendered.\n\n", - ratio * 100.0, - threshold * 100.0, - )); - } - - out.push_str("## Reproducing\n\n```bash\n"); - out.push_str(&format!( - "python3 tools/backtest-collect/backtest_collect.py --days {}\n", - fx.metadata.window_days, - )); - out.push_str( - "cargo run -p shepherd-backtest -- \\\n --fixtures tools/backtest-collect/fixtures-YYYY-MM-DD.json\n```\n\n", - ); - - out.push_str("## Appendix: per-event classification\n\n"); - out.push_str("| # | uid | block | timestamp | class |\n|---:|---|---:|---:|---|\n"); - for (i, o) in outcomes.iter().enumerate() { - out.push_str(&format!( - "| {} | `{}` | {} | {} | {} |\n", - i + 1, - shorten(&o.uid), - o.block_number, - o.block_timestamp, - o.class.label(), - )); - } - out.push('\n'); - out -} - -fn shorten(uid: &str) -> String { - if uid.len() > 18 { - format!("{}..{}", &uid[..10], &uid[uid.len() - 6..]) - } else { - uid.to_owned() - } -} - -fn escape_md(s: &str) -> String { - s.replace('|', "\\|").replace('\n', " ") -} - -fn chrono_like_now() -> String { - // Avoid pulling chrono just for a UTC string; UNIX epoch + ISO - // formatter the report renderer doesn't need to be wall-clock - // accurate to the second. - use std::time::{SystemTime, UNIX_EPOCH}; - let secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - // YYYY-MM-DDTHH:MM:SSZ - derived without leap-year handling - // because the report only uses this for a header line; the - // ground truth is the fixtures' `collected_at` field. - let days = secs / 86400; - let day_secs = secs % 86400; - let (h, m, s) = (day_secs / 3600, (day_secs % 3600) / 60, day_secs % 60); - // 1970-01-01 + days; rough date for the report timestamp. Good - // enough to grep on. - let (year, month, day) = days_to_ymd(days as i64); - format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z") -} - -fn days_to_ymd(mut days: i64) -> (i32, u32, u32) { - // Adapted from the classic civil-date conversion. - days += 719468; - let era = if days >= 0 { days } else { days - 146096 } / 146097; - let doe = (days - era * 146097) as u64; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let y = if m <= 2 { y + 1 } else { y }; - (y as i32, m, d) -} diff --git a/crates/shepherd-cow-host/Cargo.toml b/crates/shepherd-cow-host/Cargo.toml deleted file mode 100644 index a5113911..00000000 --- a/crates/shepherd-cow-host/Cargo.toml +++ /dev/null @@ -1,53 +0,0 @@ -[package] -name = "shepherd-cow-host" -version = "0.2.0" -edition.workspace = true -license.workspace = true -repository.workspace = true - -[lints] -workspace = true - -[lib] -path = "src/lib.rs" - -[dependencies] -# The host runtime this extension plugs into: HostState, the RuntimeTypes -# lattice, the extension seam, the capability namespace, and the shared -# `nexum:host/types` bindgen module the cow world binds through `with`. -nexum-runtime = { path = "../nexum-runtime" } - -# `cow-api` backend. cowprotocol pulls `OrderBookApi`, `OrderCreation`, -# `OrderUid`, the orderbook base URL table per `Chain`, and the typed -# error surface the host re-projects into the cow-api-error variant. -cowprotocol.workspace = true -# REST passthrough for `cow_api::request`. -reqwest.workspace = true -# Typed HTTP method for the CoW passthrough. -http.workspace = true -# Typed EIP-155 chain ids for the orderbook pool keys. -alloy-chains.workspace = true -# `hex::encode_prefixed` for the returned OrderUid. -alloy-primitives.workspace = true - -# WASM Component Model linker the extension hook writes into. -wasmtime.workspace = true - -# `strum::IntoStaticStr` on the error enum for metric labels. -strum.workspace = true -thiserror.workspace = true -anyhow.workspace = true -tracing.workspace = true -metrics.workspace = true -serde_json = { workspace = true, features = ["std"] } -url.workspace = true -# `[extensions.cow]` config table: serde derive for `CowConfig`, toml -# for the opaque `toml::Value` handed over by the engine config. -serde.workspace = true -toml.workspace = true - -[dev-dependencies] -tokio.workspace = true -wiremock.workspace = true -tempfile.workspace = true -alloy-rpc-types-eth.workspace = true diff --git a/crates/shepherd-cow-host/src/config.rs b/crates/shepherd-cow-host/src/config.rs deleted file mode 100644 index f0612b25..00000000 --- a/crates/shepherd-cow-host/src/config.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! The `[extensions.cow]` config table, owned by this extension. -//! -//! `engine.toml` stays domain-free: the engine hands every -//! `[extensions.]` table to the composition root verbatim, and -//! this module parses the `cow` one. - -use std::collections::HashMap; - -use alloy_chains::Chain; -use nexum_runtime::engine_config::EngineConfig; -use serde::Deserialize; -use strum::IntoStaticStr; -use thiserror::Error; - -/// The `[extensions.cow]` table from `engine.toml`. -/// -/// ```toml -/// [extensions.cow.orderbook_urls] -/// 11155111 = "http://localhost:9999" -/// ``` -#[derive(Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CowConfig { - /// Per-chain orderbook base URL overrides keyed by EIP-155 chain - /// id (numeric or named, as with `[chains.]`). Chains without - /// an entry use the canonical `cowprotocol::Chain` URL. - #[serde(default)] - pub orderbook_urls: HashMap, -} - -/// Boot-time errors from parsing the cow extension's config. -/// -/// `IntoStaticStr` exposes the snake_case variant name for -/// structured-log `error_kind` fields, matching the other host-side -/// error enums. -#[derive(Debug, Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum CowConfigError { - /// The `[extensions.cow]` table failed to deserialize. - #[error("parse [extensions.cow]: {0}")] - Section(#[from] toml::de::Error), -} - -impl TryFrom<&EngineConfig> for CowConfig { - type Error = CowConfigError; - - /// Parse the `[extensions.cow]` table. An absent table yields an - /// empty override set, so every chain uses its canonical URL. - fn try_from(cfg: &EngineConfig) -> Result { - match cfg.extensions.get("cow") { - Some(section) => Ok(section.clone().try_into()?), - None => Ok(Self::default()), - } - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/shepherd-cow-host/src/config/tests.rs b/crates/shepherd-cow-host/src/config/tests.rs deleted file mode 100644 index b91b8c77..00000000 --- a/crates/shepherd-cow-host/src/config/tests.rs +++ /dev/null @@ -1,45 +0,0 @@ -use super::*; - -fn engine_cfg(toml_src: &str) -> EngineConfig { - toml::from_str(toml_src).expect("engine config parses") -} - -fn mainnet() -> Chain { - Chain::from_id(1) -} - -#[test] -fn extension_table_resolves() { - let cfg = engine_cfg( - r#" -[extensions.cow.orderbook_urls] -1 = "http://localhost:8888" -"#, - ); - let cow = CowConfig::try_from(&cfg).expect("extension table parses"); - assert_eq!( - cow.orderbook_urls.get(&mainnet()).map(String::as_str), - Some("http://localhost:8888"), - ); -} - -#[test] -fn absent_config_yields_no_overrides() { - let cow = CowConfig::try_from(&EngineConfig::default()).expect("empty config parses"); - assert!(cow.orderbook_urls.is_empty()); -} - -#[test] -fn misspelled_extension_key_errors() { - // deny_unknown_fields turns a typo inside the new table into a - // boot-time error instead of a silent fall-through to the live - // orderbook. - let cfg = engine_cfg( - r#" -[extensions.cow] -orderbook_url = "http://localhost:9999" -"#, - ); - let err = CowConfig::try_from(&cfg).expect_err("unknown key in [extensions.cow] rejected"); - assert!(matches!(err, CowConfigError::Section(_))); -} diff --git a/crates/shepherd-cow-host/src/cow.rs b/crates/shepherd-cow-host/src/cow.rs deleted file mode 100644 index d1f0041e..00000000 --- a/crates/shepherd-cow-host/src/cow.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! CoW orderbook seam: REST passthrough plus typed order submission, -//! mirroring the inherent `OrderBookPool` API. - -use std::future::Future; - -use alloy_chains::Chain; -use cowprotocol::OrderUid; - -use crate::cow_orderbook::{CowApiError, OrderBookPool}; - -/// Async CoW orderbook backend. `get` (concrete client lookup) is -/// deliberately not part of the seam; it leaks `OrderBookApi`. -pub trait CowApi { - /// REST passthrough against the chain's orderbook base URL. - fn request( - &self, - chain: Chain, - method: http::Method, - path: &str, - body: Option<&str>, - ) -> impl Future> + Send; - - /// Typed submission of a JSON-encoded `OrderCreation`. - fn submit_order_json( - &self, - chain: Chain, - body: &[u8], - ) -> impl Future> + Send; -} - -impl CowApi for OrderBookPool { - fn request( - &self, - chain: Chain, - method: http::Method, - path: &str, - body: Option<&str>, - ) -> impl Future> + Send { - OrderBookPool::request(self, chain, method, path, body) - } - - fn submit_order_json( - &self, - chain: Chain, - body: &[u8], - ) -> impl Future> + Send { - OrderBookPool::submit_order_json(self, chain, body) - } -} diff --git a/crates/shepherd-cow-host/src/cow_orderbook.rs b/crates/shepherd-cow-host/src/cow_orderbook.rs deleted file mode 100644 index 61202dc0..00000000 --- a/crates/shepherd-cow-host/src/cow_orderbook.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! `shepherd:cow/cow-api` backend. -//! -//! Two responsibilities: -//! -//! 1. `request` - generic REST passthrough. Module gives the HTTP -//! method, path (relative to the chain's orderbook base URL), and -//! optional JSON body. We dispatch via `reqwest`, return the -//! response body verbatim. -//! 2. `submit_order` - typed submission. Module gives a JSON-encoded -//! `cowprotocol::OrderCreation`; we parse, dispatch via -//! `cowprotocol::OrderBookApi::post_order`, return the assigned -//! `OrderUid` as a `0x`-prefixed hex string. -//! -//! Per-chain `OrderBookApi` instances are constructed once at engine -//! boot from the discriminated chain set in `cowprotocol::Chain`. -//! Chains the SDK does not know about return `Unsupported` at the -//! host call boundary. - -use std::collections::HashMap; -use std::time::Duration; - -use alloy_chains::Chain; -use cowprotocol::{Chain as CowChain, OrderBookApi, OrderCreation, OrderUid}; -use nexum_runtime::engine_config::EngineConfig; -use strum::IntoStaticStr; -use thiserror::Error; - -use crate::config::{CowConfig, CowConfigError}; - -/// Process-wide pool of `OrderBookApi` clients keyed by chain. -#[derive(Debug, Clone)] -pub struct OrderBookPool { - clients: HashMap, - http: reqwest::Client, -} - -/// Canonical CoW Protocol chain set the engine ships clients for. -/// -/// Both `Default::default()` and `OrderBookPool::from_config` walk -/// this single source of truth so a new chain joining CoW protocol -/// only needs a one-line addition here instead of two parallel -/// arrays. -const DEFAULT_CHAINS: &[CowChain] = &[ - CowChain::Mainnet, - CowChain::Gnosis, - CowChain::Sepolia, - CowChain::ArbitrumOne, - CowChain::Base, -]; - -impl Default for OrderBookPool { - /// Build a pool covering every `cowprotocol::Chain` variant. Each entry - /// uses the canonical `api.cow.fi/{slug}/api/v1` base URL from the SDK. - /// Override individual entries via `OrderBookApi::new_with_base_url` for - /// barn or staging targets. - fn default() -> Self { - let http = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .expect("reqwest client builder"); - let clients = DEFAULT_CHAINS - .iter() - .map(|c| (Chain::from_id(c.id()), OrderBookApi::new(*c))) - .collect(); - Self { clients, http } - } -} - -impl OrderBookPool { - /// Build a pool from engine config, honouring the - /// `[extensions.cow.orderbook_urls]` overrides. Chains without an - /// override fall back to the canonical `cowprotocol::Chain` URLs - /// (same as [`OrderBookPool::default`]). - /// - /// Used by the load test to point all submissions at - /// `tools/orderbook-mock`, and by staging/barn deployments that - /// run against a non-production orderbook. - pub fn from_config(cfg: &EngineConfig) -> Result { - let cow_cfg = CowConfig::try_from(cfg)?; - let http = reqwest::Client::new(); - let mut clients: HashMap = DEFAULT_CHAINS - .iter() - .map(|c| (Chain::from_id(c.id()), OrderBookApi::new(*c))) - .collect(); - // Sort by numeric id so override logs are deterministic - // (`Chain` is not `Ord`). - let mut entries: Vec<_> = cow_cfg.orderbook_urls.iter().collect(); - entries.sort_by_key(|(c, _)| c.id()); - for (chain, url) in entries { - let chain_id = chain.id(); - match url.parse::() { - Ok(parsed) => { - tracing::info!(chain_id, url, "cow-api: orderbook URL override"); - clients.insert(*chain, OrderBookApi::new_with_base_url(parsed)); - } - Err(e) => { - tracing::warn!(chain_id, url, error = %e, "cow-api: bad orderbook_url, falling back to canonical"); - } - } - } - Ok(Self { clients, http }) - } - - /// Look up the client for a chain. - pub fn get(&self, chain: Chain) -> Result<&OrderBookApi, CowApiError> { - self.clients - .get(&chain) - .ok_or(CowApiError::UnknownChain(chain)) - } - - /// REST passthrough. The base URL is whichever URL the pool's - /// `OrderBookApi` client carries - overrides set via - /// `OrderBookApi::new_with_base_url` (staging, wiremock) flow - /// through here too, which keeps the passthrough and the typed - /// `submit_order_json` path aimed at the same orderbook. - pub async fn request( - &self, - chain: Chain, - method: http::Method, - path: &str, - body: Option<&str>, - ) -> Result { - use http::Method; - let api = self.get(chain)?; - let base = api.base_url().clone(); - // `path` may or may not lead with a slash; `Url::join` handles - // both, but we strip a single leading `/` so consumers can - // write either `/orders/...` or `orders/...` interchangeably. - let trimmed = path.strip_prefix('/').unwrap_or(path); - let url = base - .join(trimmed) - .map_err(|e| CowApiError::BadPath(format!("{path:?}: {e}")))?; - - if ![Method::GET, Method::POST, Method::PUT, Method::DELETE].contains(&method) { - return Err(CowApiError::BadMethod(method)); - } - // `reqwest::Method` is `http::Method`, so the typed method flows - // straight through. - let request = self.http.request(method, url); - let request = if let Some(body) = body { - request - .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(body.to_owned()) - } else { - request - }; - - let response = request.send().await.map_err(CowApiError::Network)?; - let status = response.status().as_u16(); - let text = response.text().await.map_err(CowApiError::Network)?; - // Non-2xx responses are surfaced as HttpError so the guest can - // distinguish 404 (not found) from 200 (success) via the - // cow-api-error http case status. - // The full response body is preserved in the error for structured - // decoding (e.g. `{"errorType": "...", "description": "..."}`). - if status >= 400 { - return Err(CowApiError::HttpError { status, body: text }); - } - Ok(text) - } - - /// Typed submission. `body` is the JSON encoding of - /// `cowprotocol::OrderCreation`. The chain's orderbook validates - /// `from`, the EIP-712 hash, and (if `Eip1271`) the contract - /// signature; we return whatever UID it assigns. - pub async fn submit_order_json( - &self, - chain: Chain, - body: &[u8], - ) -> Result { - let creation: OrderCreation = serde_json::from_slice(body).map_err(CowApiError::Decode)?; - let api = self.get(chain)?; - let uid = api.post_order(&creation).await?; - Ok(uid) - } -} - -/// `IntoStaticStr` exposes the snake_case variant name as a -/// `&'static str` (`"unknown_chain"`, `"bad_method"`, ...) so the -/// `shepherd_cow_api_*` metric labels and structured-log fields stay -/// in sync with the Rust source of truth instead of growing a -/// `match err { ... => "decode" ... }` ladder per call site. -#[derive(Debug, Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum CowApiError { - #[error("unknown chain {0} (no cowprotocol::Chain variant)")] - UnknownChain(Chain), - #[error("bad HTTP method `{0}` (expected GET/POST/PUT/DELETE)")] - BadMethod(http::Method), - #[error("invalid path: {0}")] - BadPath(String), - #[error("HTTP {status}")] - HttpError { status: u16, body: String }, - #[error("network: {0}")] - Network(#[from] reqwest::Error), - #[error("decode OrderCreation JSON: {0}")] - Decode(#[from] serde_json::Error), - #[error("orderbook: {0}")] - Orderbook(#[from] cowprotocol::Error), -} - -#[cfg(test)] -mod tests; diff --git a/crates/shepherd-cow-host/src/cow_orderbook/tests.rs b/crates/shepherd-cow-host/src/cow_orderbook/tests.rs deleted file mode 100644 index f4155888..00000000 --- a/crates/shepherd-cow-host/src/cow_orderbook/tests.rs +++ /dev/null @@ -1,362 +0,0 @@ -use super::*; -use http::Method; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -/// The canonical CoW mainnet chain, as the pool keys it (alloy `Chain` -/// derived from the cowprotocol id). -fn mainnet() -> Chain { - Chain::from_id(CowChain::Mainnet.id()) -} - -#[test] -fn pool_indexes_default_chains() { - let pool = OrderBookPool::default(); - assert!(pool.get(Chain::from_id(1)).is_ok(), "mainnet present"); - assert!(pool.get(Chain::from_id(100)).is_ok(), "gnosis present"); - assert!( - pool.get(Chain::from_id(11_155_111)).is_ok(), - "sepolia present" - ); - assert!(pool.get(Chain::from_id(42_161)).is_ok(), "arbitrum present"); - assert!(pool.get(Chain::from_id(8_453)).is_ok(), "base present"); -} - -#[test] -fn from_config_applies_extension_override() { - let cfg: EngineConfig = toml::from_str( - r#" -[extensions.cow.orderbook_urls] -1 = "http://localhost:9999" -"#, - ) - .expect("engine config parses"); - let pool = OrderBookPool::from_config(&cfg).expect("pool builds"); - let api = pool.get(mainnet()).expect("mainnet client"); - assert!( - api.base_url().as_str().starts_with("http://localhost:9999"), - "extension override applied, got {}", - api.base_url(), - ); -} - -#[test] -fn unknown_chain_surfaces_typed_error() { - let pool = OrderBookPool::default(); - assert!(matches!( - pool.get(Chain::from_id(99_999)), - Err(CowApiError::UnknownChain(c)) if c == Chain::from_id(99_999) - )); -} - -/// Build a pool whose Mainnet entry points at `mock.uri()`. -/// `OrderBookApi::new_with_base_url` ships in cowprotocol; we -/// rely on it so wiremock-driven tests can exercise the full -/// request path without re-implementing the HTTP client. -fn pool_with_mainnet_at(mock: &MockServer) -> OrderBookPool { - let mut clients = std::collections::HashMap::new(); - clients.insert( - mainnet(), - OrderBookApi::new_with_base_url(mock.uri().parse().expect("mock uri parses")), - ); - OrderBookPool { - clients, - http: reqwest::Client::new(), - } -} - -#[tokio::test] -async fn request_passes_get_path_through() { - let mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/v1/version")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"version":"x.y.z"}"#)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let body = pool - .request(mainnet(), Method::GET, "/api/v1/version", None) - .await - .expect("request succeeds"); - assert_eq!(body, r#"{"version":"x.y.z"}"#); -} - -#[tokio::test] -async fn request_relative_path_works() { - // Module passes a path without a leading slash. The - // passthrough should still resolve against the orderbook - // base URL. - let mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/v1/native_price/0xabc")) - .respond_with(ResponseTemplate::new(200).set_body_string("1.23")) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let body = pool - .request(mainnet(), Method::GET, "api/v1/native_price/0xabc", None) - .await - .expect("relative path resolves"); - assert_eq!(body, "1.23"); -} - -#[tokio::test] -async fn request_rejects_unknown_method() { - let pool = OrderBookPool::default(); - let err = pool - .request(mainnet(), Method::PATCH, "/x", None) - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::BadMethod(_))); -} - -#[tokio::test] -async fn request_post_with_body_is_forwarded() { - let mock = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/v1/quote")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"quote":"ok"}"#)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let body = pool - .request( - mainnet(), - Method::POST, - "/api/v1/quote", - Some(r#"{"sellToken":"0x01"}"#), - ) - .await - .expect("post with body succeeds"); - assert_eq!(body, r#"{"quote":"ok"}"#); -} - -#[tokio::test] -async fn request_4xx_response_surfaces_http_error_with_body() { - let mock = MockServer::start().await; - let error_body = r#"{"errorType":"InsufficientFee","description":"fee too low"}"#; - Mock::given(method("POST")) - .and(path("/api/v1/orders")) - .respond_with(ResponseTemplate::new(400).set_body_string(error_body)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let err = pool - .request( - mainnet(), - Method::POST, - "/api/v1/orders", - Some(r#"{"test":true}"#), - ) - .await - .unwrap_err(); - match err { - CowApiError::HttpError { status, body } => { - assert_eq!(status, 400); - assert_eq!(body, error_body); - } - other => panic!("expected HttpError, got: {other:?}"), - } -} - -#[tokio::test] -async fn request_rejects_unknown_chain() { - let pool = OrderBookPool::default(); - let err = pool - .request(Chain::from_id(99_999), Method::GET, "/x", None) - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::UnknownChain(c) if c == Chain::from_id(99_999))); -} - -#[tokio::test] -async fn submit_order_propagates_orderbook_envelope() { - // The orderbook rejects with a typed envelope. The pool must - // surface `cowprotocol::Error::OrderbookApi { status, api }` - // so the WIT adapter can forward `api` to the cow-api-error - // rejected case. The string - // `DuplicatedOrder` is what the live - // Sepolia orderbook returns for an already-submitted order; - // it parses as `ApiError` even though the retriable-error - // classifier does not recognise the spelling. - let mock = MockServer::start().await; - let envelope = r#"{"errorType":"DuplicatedOrder","description":"order already exists"}"#; - Mock::given(method("POST")) - .and(path("/api/v1/orders")) - .respond_with(ResponseTemplate::new(400).set_body_string(envelope)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let err = pool - .submit_order_json(mainnet(), sample_order_json().as_bytes()) - .await - .expect_err("orderbook 400 surfaces as error"); - - match err { - CowApiError::Orderbook(cowprotocol::Error::OrderbookApi { status, api }) => { - assert_eq!(status, 400); - assert_eq!(api.error_type, "DuplicatedOrder"); - assert_eq!(api.description, "order already exists"); - } - other => panic!("expected OrderbookApi envelope, got {other:?}"), - } -} - -#[tokio::test] -async fn submit_order_propagates_orderbook_response() { - let mock = MockServer::start().await; - let body_json = sample_order_json(); - // cowprotocol POST /api/v1/orders returns the order UID - // (56-byte hex) as a JSON string body. - let returned_uid = format!("\"0x{}\"", "ab".repeat(56)); - Mock::given(method("POST")) - .and(path("/api/v1/orders")) - .respond_with(ResponseTemplate::new(201).set_body_string(returned_uid.clone())) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let uid = pool - .submit_order_json(mainnet(), body_json.as_bytes()) - .await - .expect("submit succeeds"); - assert_eq!(uid.as_slice().len(), 56); - assert_eq!(uid.as_slice(), &[0xab; 56]); -} - -/// A minimal but accepted-by-cowprotocol OrderCreation JSON. We -/// generate it inside the test so the JSON shape stays in lockstep -/// with the published `cowprotocol` version. -fn sample_order_json() -> String { - use alloy_primitives::{Address, U256}; - use cowprotocol::OrderCreation; - use cowprotocol::app_data::{EMPTY_APP_DATA_HASH, EMPTY_APP_DATA_JSON}; - use cowprotocol::order::{BuyTokenDestination, OrderData, OrderKind, SellTokenSource}; - use cowprotocol::signature::Signature; - use cowprotocol::signing_scheme::SigningScheme; - - let order_data = OrderData { - sell_token: Address::from([0x01; 20]), - buy_token: Address::from([0x02; 20]), - receiver: None, - sell_amount: U256::from(100u64), - buy_amount: U256::from(99u64), - valid_to: u32::MAX, - app_data: EMPTY_APP_DATA_HASH, - fee_amount: U256::ZERO, - kind: OrderKind::Sell, - partially_fillable: false, - sell_token_balance: SellTokenSource::Erc20, - buy_token_balance: BuyTokenDestination::Erc20, - }; - let signature = Signature::from_bytes(SigningScheme::PreSign, &[]).expect("presign empty"); - let creation = OrderCreation::new( - &order_data, - signature, - Address::from([0x03; 20]), - EMPTY_APP_DATA_JSON.to_owned(), - None, - ) - .expect("valid OrderCreation"); - serde_json::to_string(&creation).expect("serialise OrderCreation") -} - -#[tokio::test] -async fn request_rejects_malformed_path() { - // `Url::join` is very lenient for valid UTF-8 inputs. The - // `BadPath` variant fires only when `Url::join` returns a parse - // error, which is hard to provoke. Using a bare scheme-like - // string (`"://not-a-path"`) is NOT rejected because after - // stripping the leading `/` it is treated as a relative path - // component. Instead, feed a string that *will* reach the - // network but is handled by wiremock with a 404, confirming the - // passthrough returns Ok even for nonsensical paths. - let mock = MockServer::start().await; - let pool = pool_with_mainnet_at(&mock); - // wiremock returns 404 for any un-mocked route, now surfaced - // as HttpError (not Ok) since we distinguish HTTP status codes. - let err = pool - .request(mainnet(), Method::GET, "://not-a-path", None) - .await - .unwrap_err(); - assert!( - matches!(err, CowApiError::HttpError { status: 404, .. }), - "Url::join treats this as a relative path; wiremock 404 surfaces as HttpError" - ); -} - -#[tokio::test] -async fn request_network_error_on_dead_server() { - // Build the pool against a port that no one is listening on. - // We use port 1 (TCP echo / privileged) which is never bound - // by user-space processes, guaranteeing a connection-refused. - let mut clients = std::collections::HashMap::new(); - clients.insert( - mainnet(), - OrderBookApi::new_with_base_url("http://127.0.0.1:1/".parse().expect("valid url")), - ); - let pool = OrderBookPool { - clients, - http: reqwest::Client::new(), - }; - let err = pool - .request(mainnet(), Method::GET, "/api/v1/version", None) - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::Network(_))); -} - -#[tokio::test] -async fn request_5xx_response_surfaces_http_error_with_body() { - let mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/v1/health")) - .respond_with(ResponseTemplate::new(500).set_body_string(r#"{"error":"internal"}"#)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let err = pool - .request(mainnet(), Method::GET, "/api/v1/health", None) - .await - .unwrap_err(); - match err { - CowApiError::HttpError { status, body } => { - assert_eq!(status, 500); - assert_eq!(body, r#"{"error":"internal"}"#); - } - other => panic!("expected HttpError, got: {other:?}"), - } -} - -#[tokio::test] -async fn submit_order_rejects_invalid_json() { - let pool = OrderBookPool::default(); - let err = pool - .submit_order_json(mainnet(), b"not json") - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::Decode(_))); -} - -#[tokio::test] -async fn submit_order_rejects_wrong_schema() { - let pool = OrderBookPool::default(); - let err = pool - .submit_order_json(mainnet(), br#"{"valid":"json"}"#) - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::Decode(_))); -} diff --git a/crates/shepherd-cow-host/src/ext_cow.rs b/crates/shepherd-cow-host/src/ext_cow.rs deleted file mode 100644 index 55336bf5..00000000 --- a/crates/shepherd-cow-host/src/ext_cow.rs +++ /dev/null @@ -1,297 +0,0 @@ -//! The cow-api extension: `shepherd:cow/cow-api` wired through the -//! extension seam rather than hard-linked into the core host. -//! -//! Shape: a local `bindgen!` for the extension world, a `Host` impl for -//! the foreign `HostState` reached through [`ExtState`], a payload -//! trait ([`CowBackend`]) the lattice `Ext` member satisfies, and an -//! [`Extension`] bundling the linker hook with the capability namespace. -//! -//! The bindgen shares `nexum:host/types` with the core bindings via -//! `with`, so the `fault` the extension's `cow-api-error` embeds is the -//! same type the core host constructs. - -use std::time::Instant; - -use alloy_chains::Chain; -use nexum_runtime::bindings::nexum::host::types::Fault; -use nexum_runtime::host::component::{BuilderContext, ComponentBuilder, RuntimeTypes}; -use nexum_runtime::host::extension::Extension; -use nexum_runtime::host::state::{ExtState, HostState}; -use nexum_runtime::manifest::NamespaceCaps; -use wasmtime::component::HasSelf; - -use crate::cow::CowApi; -use crate::cow_orderbook::{CowApiError, OrderBookPool}; - -mod bindings { - wasmtime::component::bindgen!({ - path: ["../../wit/nexum-host", "../../wit/shepherd-cow"], - world: "shepherd:cow/cow-ext", - imports: { default: async }, - with: { "nexum:host/types": nexum_runtime::bindings::nexum::host::types }, - }); -} - -use bindings::shepherd::cow::cow_api::{ - CowApiError as WitCowApiError, HttpFailure, OrderRejection, -}; - -/// Capability namespace this extension owns. Merged into capability -/// enforcement so a module importing `shepherd:cow/cow-api` validates. -pub const COW_CAPABILITIES: NamespaceCaps = NamespaceCaps { - prefix: "shepherd:cow/", - ifaces: &["cow-api"], -}; - -/// Extension payload providing a cow-api backend. The lattice `Ext` member -/// implements this so the `Host` impl can extract the backend generically. -pub trait CowBackend { - /// The cow orderbook backend type. - type Cow: CowApi; - /// Borrow the cow backend. - fn cow(&self) -> &Self::Cow; -} - -/// The cow-api payload the reference engine ships in its `Ext` slot. -#[derive(Clone)] -pub struct ReferenceExt { - /// `cow-api` backend - per-chain `OrderBookApi` clients + reqwest. - pub cow: OrderBookPool, -} - -impl CowBackend for ReferenceExt { - type Cow = OrderBookPool; - fn cow(&self) -> &OrderBookPool { - &self.cow - } -} - -/// Builds the reference `Ext` payload: the cow orderbook pool from -/// `[extensions.cow]`. Lives here because the cow cone (and so the -/// [`OrderBookPool`] it opens) belongs to this extension crate, not the -/// core runtime. -pub struct ReferenceExtBuilder; - -impl ComponentBuilder for ReferenceExtBuilder { - type Output = ReferenceExt; - - async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result { - let cow = OrderBookPool::from_config(ctx.config)?; - Ok(ReferenceExt { cow }) - } -} - -/// Build the cow extension for a lattice whose `Ext` payload carries a cow -/// backend. Wired at the composition root into `build_linker` and -/// capability enforcement. -pub fn extension() -> Extension -where - T: RuntimeTypes, - T::Ext: CowBackend, -{ - Extension { - link: std::sync::Arc::new(|linker| { - // Link only the cow-api interface. The whole-world - // `CowExt::add_to_linker` would also re-add the shared - // `nexum:host/types` instance, which the core event-module - // linker already provides, tripping a "defined twice" error. - bindings::shepherd::cow::cow_api::add_to_linker::, HasSelf>>( - linker, - |s| s, - )?; - Ok(()) - }), - capabilities: COW_CAPABILITIES, - } -} - -/// Project the backend [`CowApiError`] into the WIT `cow-api-error`. -/// -/// Local-shape failures (unknown chain, bad method/path, decode) become -/// a shared [`Fault`]; a transport-layer HTTP failure becomes an -/// [`Http`](bindings::shepherd::cow::cow_api::CowApiError::Http) case; an -/// orderbook rejection envelope is parsed once here into a -/// [`Rejected`](bindings::shepherd::cow::cow_api::CowApiError::Rejected) -/// case so the guest never re-decodes the failure body. -fn cow_error_to_wit(err: CowApiError) -> WitCowApiError { - match err { - CowApiError::UnknownChain(chain) => WitCowApiError::Fault(Fault::Unsupported(format!( - "chain {chain} not in cowprotocol" - ))), - CowApiError::BadMethod(m) => { - WitCowApiError::Fault(Fault::InvalidInput(format!("unsupported HTTP method: {m}"))) - } - CowApiError::BadPath(msg) => WitCowApiError::Fault(Fault::InvalidInput(msg)), - CowApiError::HttpError { status, body } => WitCowApiError::Http(HttpFailure { - status, - body: Some(body), - }), - CowApiError::Network(e) => WitCowApiError::Fault(Fault::Unavailable(e.to_string())), - CowApiError::Decode(e) => WitCowApiError::Fault(Fault::InvalidInput(format!( - "invalid OrderCreation JSON: {e}" - ))), - CowApiError::Orderbook(e) => orderbook_error_to_wit(e), - } -} - -/// Map a `cowprotocol::Error` to WIT form. -/// -/// An `OrderbookApi` reply is parsed once into a typed -/// [`OrderRejection`] carrying the orderbook's `errorType` / -/// `description` plus its optional structured `data` payload, -/// re-encoded as a JSON string. A non-2xx reply with an unparseable -/// body becomes an [`HttpFailure`]. Everything else is a host-side -/// [`Fault::Internal`]. -fn orderbook_error_to_wit(err: cowprotocol::Error) -> WitCowApiError { - match err { - cowprotocol::Error::OrderbookApi { status, api } => { - WitCowApiError::Rejected(OrderRejection { - status, - error_type: api.error_type, - description: api.description, - data: api.data.map(|d| d.to_string()), - }) - } - cowprotocol::Error::UnexpectedStatus { status, body } => { - WitCowApiError::Http(HttpFailure { - status, - body: Some(body), - }) - } - other => WitCowApiError::Fault(Fault::Internal(other.to_string())), - } -} - -impl bindings::shepherd::cow::cow_api::Host for HostState -where - T: RuntimeTypes, - T::Ext: CowBackend, -{ - async fn request( - &mut self, - chain_id: u64, - method: String, - path: String, - body: Option, - ) -> Result { - let start = Instant::now(); - let chain = Chain::from_id(chain_id); - tracing::debug!(chain_id, %method, %path, "cow-api::request"); - // The guest hands us a free-form method string; normalise to - // uppercase so `get` and `GET` both resolve, then type it. The - // allowlist itself lives behind the seam. - let method = match http::Method::from_bytes(method.to_ascii_uppercase().as_bytes()) { - Ok(m) => m, - Err(_) => { - return Err(WitCowApiError::Fault(Fault::InvalidInput(format!( - "unsupported HTTP method: {method}" - )))); - } - }; - let result = self - .ext() - .cow() - .request(chain, method, &path, body.as_deref()) - .await - .map_err(cow_error_to_wit); - tracing::trace!(elapsed_ms = ?start.elapsed(), "cow-api::request done"); - result - } - - async fn submit_order( - &mut self, - chain_id: u64, - order_data: Vec, - ) -> Result { - let start = Instant::now(); - let chain = Chain::from_id(chain_id); - tracing::debug!(chain_id, bytes = order_data.len(), "cow-api::submit-order"); - let result = self - .ext() - .cow() - .submit_order_json(chain, &order_data) - .await - .map(|uid| alloy_primitives::hex::encode_prefixed(uid.as_slice())) - .map_err(cow_error_to_wit); - tracing::trace!(elapsed_ms = ?start.elapsed(), "cow-api::submit-order done"); - let outcome = if result.is_ok() { "ok" } else { "err" }; - metrics::counter!( - "shepherd_cow_api_submit_total", - "chain_id" => chain_id.to_string(), - "outcome" => outcome, - ) - .increment(1); - result - } -} - -#[cfg(test)] -mod tests { - use super::*; - use cowprotocol::error::ApiError; - - #[test] - fn orderbook_api_error_becomes_typed_rejection() { - // The orderbook rejects with a typed envelope. The mapping - // parses it once, host-side, into an `order-rejection` so the - // guest dispatches on `error-type` without a second decode. - let api = ApiError { - error_type: "DuplicatedOrder".to_owned(), - description: "order already exists".to_owned(), - data: Some(serde_json::json!({"min_fee": "1234"})), - }; - let err = cowprotocol::Error::OrderbookApi { status: 400, api }; - - let WitCowApiError::Rejected(rejection) = orderbook_error_to_wit(err) else { - panic!("orderbook envelope must project to a typed rejection"); - }; - assert_eq!(rejection.status, 400); - assert_eq!(rejection.error_type, "DuplicatedOrder"); - assert_eq!(rejection.description, "order already exists"); - // The envelope's structured payload survives as a JSON string. - assert_eq!(rejection.data.as_deref(), Some(r#"{"min_fee":"1234"}"#)); - } - - #[test] - fn unexpected_status_becomes_http_failure() { - // A non-2xx reply with an unparseable body carries no typed - // rejection; it surfaces as a raw http-failure with the body - // preserved for diagnostics. - let err = cowprotocol::Error::UnexpectedStatus { - status: 502, - body: "upstream".to_owned(), - }; - - let WitCowApiError::Http(http) = orderbook_error_to_wit(err) else { - panic!("unexpected-status must project to an http-failure"); - }; - assert_eq!(http.status, 502); - assert_eq!(http.body.as_deref(), Some("upstream")); - } - - #[test] - fn backend_http_error_projects_to_http_failure() { - // The passthrough backend surfaces a non-2xx as `HttpError`; - // it must reach the guest as an http-failure so a 404 is - // matchable on `status`. - let err = CowApiError::HttpError { - status: 404, - body: "not found".to_owned(), - }; - - let WitCowApiError::Http(http) = cow_error_to_wit(err) else { - panic!("backend HttpError must project to an http-failure"); - }; - assert_eq!(http.status, 404); - assert_eq!(http.body.as_deref(), Some("not found")); - } - - #[test] - fn unknown_chain_projects_to_unsupported_fault() { - let err = CowApiError::UnknownChain(Chain::from_id(9999)); - assert!(matches!( - cow_error_to_wit(err), - WitCowApiError::Fault(Fault::Unsupported(_)), - )); - } -} diff --git a/crates/shepherd-cow-host/src/lib.rs b/crates/shepherd-cow-host/src/lib.rs deleted file mode 100644 index 2980ec7e..00000000 --- a/crates/shepherd-cow-host/src/lib.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! The cow-api host extension: `shepherd:cow/cow-api` wired into the nexum -//! runtime through the linker extension seam. -//! -//! The core runtime knows nothing of CoW Protocol; this crate owns the -//! `cowprotocol` dependency, the `shepherd:cow/cow-ext` bindgen, the -//! orderbook backend, and the [`Extension`](nexum_runtime::host::extension::Extension) -//! value the composition root assembles into the linker and capability -//! registry. It depends on the runtime (for `HostState`, the extension -//! seam, and the shared `nexum:host/types` bindgen); the runtime never -//! depends on it, so the CoW cone stays out of the bare engine. - -pub mod config; -mod cow; -pub mod cow_orderbook; -pub mod ext_cow; - -pub use config::{CowConfig, CowConfigError}; -pub use cow::CowApi; -pub use cow_orderbook::{CowApiError, OrderBookPool}; -pub use ext_cow::{CowBackend, ReferenceExt, ReferenceExtBuilder, extension}; diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs deleted file mode 100644 index f3a75d9a..00000000 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! Boot-order coverage for the cow-api extension: a module that imports -//! `shepherd:cow/cow-api` boots and dispatches once the extension is wired -//! at the composition root. The negative direction (fails to boot without -//! the extension) lives in the runtime's own supervisor tests. -//! -//! These exercise the real wit-bindgen + supervisor path against pre-built -//! wasm artefacts and skip gracefully when the artefact is absent. - -use std::path::{Path, PathBuf}; - -use alloy_chains::Chain; -use nexum_runtime::bindings::nexum; -use nexum_runtime::engine_config::{EngineConfig, ModuleLimits}; -use nexum_runtime::host::component::{Components, RuntimeTypes}; -use nexum_runtime::host::extension::Extension; -use nexum_runtime::host::local_store_redb::LocalStore; -use nexum_runtime::host::provider_pool::ProviderPool; -use nexum_runtime::host::state::HostState; -use nexum_runtime::supervisor::{Supervisor, build_linker}; -use shepherd_cow_host::{OrderBookPool, ReferenceExt, extension}; -use wasmtime::component::Linker; - -const SEPOLIA: u64 = 11_155_111; - -/// Reference-shaped lattice: the core backends plus the cow-api payload in -/// the extension slot, matching what the CLI composition root assembles. -#[derive(Debug, Clone, Copy, Default)] -struct CowTestTypes; - -impl RuntimeTypes for CowTestTypes { - type Chain = ProviderPool; - type Store = LocalStore; - type Ext = ReferenceExt; -} - -fn cow_extensions() -> Vec> { - vec![extension::()] -} - -fn make_wasmtime_engine() -> wasmtime::Engine { - let mut config = wasmtime::Config::new(); - config.wasm_component_model(true); - config.consume_fuel(true); - wasmtime::Engine::new(&config).expect("wasmtime engine") -} - -fn make_linker(engine: &wasmtime::Engine) -> Linker> { - build_linker::(engine, &cow_extensions()).expect("build_linker") -} - -/// A chainless provider pool: no `[chains]` entries, so every -/// `chain::request` surfaces `UnknownChain`. Enough to prove boot and -/// dispatch without a live RPC endpoint. -async fn chainless_pool() -> ProviderPool { - ProviderPool::from_config(&EngineConfig::default()) - .await - .expect("chainless provider pool") -} - -async fn test_components(store: LocalStore) -> Components { - Components { - chain: chainless_pool().await, - store, - ext: ReferenceExt { - cow: OrderBookPool::default(), - }, - logs: nexum_runtime::host::logs::LogPipeline::in_memory(ModuleLimits::default().logs()), - } -} - -fn temp_local_store() -> (tempfile::TempDir, LocalStore) { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("ls.redb"); - let store = LocalStore::open(path).expect("local store"); - (dir, store) -} - -/// Path to a module's `.wasm` artefact under the workspace target dir. -/// `CARGO_MANIFEST_DIR` is `crates/shepherd-cow-host`; two parents up is -/// the workspace root, mirroring the runtime's own helper. -fn module_wasm(module_name: &str) -> PathBuf { - let artifact = module_name.replace('-', "_"); - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join(format!("target/wasm32-wasip2/release/{artifact}.wasm")) -} - -fn module_wasm_or_skip(module_name: &str) -> Option { - let p = module_wasm(module_name); - if p.exists() { - Some(p) - } else { - eprintln!( - "SKIP: {} not found - build with `cargo build -p {module_name} --target wasm32-wasip2 --release`", - p.display() - ); - None - } -} - -fn production_module_toml(relative_path: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join(relative_path) -} - -fn synthetic_sepolia_block() -> nexum::host::types::Block { - nexum::host::types::Block { - chain_id: SEPOLIA, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - } -} - -async fn boot_production_module( - engine: &wasmtime::Engine, - linker: &Linker>, - local_store: &LocalStore, - wasm: &Path, - manifest: &Path, -) -> Supervisor { - let components = test_components(local_store.clone()).await; - let limits = ModuleLimits::default(); - Supervisor::boot_single( - engine, - linker, - wasm, - Some(manifest), - &components, - &limits, - &cow_extensions(), - None, - ) - .await - .expect("boot_single") -} - -/// twap-monitor imports `shepherd:cow/cow-api`; with the cow extension -/// registered it boots, and a block dispatch reaches it and keeps it alive. -#[tokio::test] -async fn e2e_twap_monitor_block_dispatch() { - let Some(wasm) = module_wasm_or_skip("twap-monitor") else { - return; - }; - let manifest = production_module_toml("modules/twap-monitor/module.toml"); - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - - let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - assert_eq!(supervisor.module_count(), 1); - assert_eq!(supervisor.alive_count(), 1); - - // twap-monitor subscribes to Sepolia blocks (poll path). A real poll - // would call chain::request, which ProviderPool::empty() does not - // satisfy - the module surfaces a fault and warns; the supervisor - // must keep the module alive because the strategy catches the error - // and returns Ok(()). - let dispatched = supervisor.dispatch_block(synthetic_sepolia_block()).await; - assert_eq!(dispatched, 1); - assert_eq!(supervisor.alive_count(), 1); -} - -/// ethflow-watcher imports `shepherd:cow/cow-api` and subscribes to logs; -/// it boots with the cow extension and a synthetic log is delivered. -#[tokio::test] -async fn e2e_ethflow_watcher_log_dispatch() { - let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { - return; - }; - let manifest = production_module_toml("modules/ethflow-watcher/module.toml"); - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - - let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - assert_eq!(supervisor.alive_count(), 1); - - // A log with an unrecognised topic is silently skipped by the module's - // decoder (returns `None` from `decode_order_placement`), so the test - // only proves: supervisor delivered, module did not trap, module stayed - // alive. - let synthetic_log = alloy_rpc_types_eth::Log::default(); - let dispatched = supervisor - .dispatch_chain_log( - "ethflow-watcher", - Chain::from_id(SEPOLIA), - synthetic_log, - None, - ) - .await; - assert!(dispatched); - assert_eq!(supervisor.alive_count(), 1); -} - -/// stop-loss imports `shepherd:cow/cow-api`; it boots with the cow -/// extension and a block dispatch reaches it. -#[tokio::test] -async fn e2e_stop_loss_block_dispatch() { - let Some(wasm) = module_wasm_or_skip("stop-loss") else { - return; - }; - let manifest = production_module_toml("modules/examples/stop-loss/module.toml"); - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - - 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/crates/shepherd-sdk-test/Cargo.toml b/crates/shepherd-sdk-test/Cargo.toml deleted file mode 100644 index a09c388f..00000000 --- a/crates/shepherd-sdk-test/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "shepherd-sdk-test" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "In-memory CoW host mock for Shepherd module unit tests. Implements shepherd_sdk::cow::CowApiHost and composes the nexum-sdk-test mocks." - -[lib] -# Plain library, host-only - module Cargo.toml lists this under -# [dev-dependencies] so it never ships in the wasm bundle. - -[dependencies] -nexum-sdk = { path = "../nexum-sdk" } -nexum-sdk-test = { path = "../nexum-sdk-test" } -shepherd-sdk = { path = "../shepherd-sdk" } -serde_json = { workspace = true, features = ["std"] } diff --git a/crates/shepherd-sdk-test/src/lib.rs b/crates/shepherd-sdk-test/src/lib.rs deleted file mode 100644 index e1289cd0..00000000 --- a/crates/shepherd-sdk-test/src/lib.rs +++ /dev/null @@ -1,289 +0,0 @@ -//! # shepherd-sdk-test -//! -//! In-memory implementation of the CoW-domain -//! [`shepherd_sdk::cow::CowApiHost`] trait, plus a [`MockHost`] that -//! composes it with the generic `nexum-sdk-test` mocks so a CoW module -//! can write integration tests for its strategy logic without -//! `wit-bindgen`, `wasmtime`, or a network round-trip. -//! -//! ## Usage -//! -//! Add as a dev-dep on the module crate and test against [`MockHost`]: -//! -//! ```rust -//! // Glob-import the host traits so the method shortcuts resolve. -//! use nexum_sdk::host::*; -//! use shepherd_sdk::cow::CowApiHost as _; -//! use shepherd_sdk_test::MockHost; -//! -//! let host = MockHost::new(); -//! host.cow_api.respond(Ok("0xuid".into())); -//! -//! assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); -//! assert_eq!(host.cow_api.call_count(), 1); -//! ``` -//! -//! Modules that never touch the orderbook use `nexum-sdk-test`'s -//! `MockHost` directly instead. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![warn(missing_docs)] - -use std::cell::RefCell; - -use nexum_sdk::Level; -use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost, LoggingHost}; -use nexum_sdk_test::{MockChain, MockLocalStore, MockLogging}; -use shepherd_sdk::cow::{CowApiError, CowApiHost}; - -/// Composed in-memory host for CoW modules: the generic per-trait -/// mocks plus [`MockCowApi`]. Each field exposes the per-trait mock so -/// tests can program responses and assert on calls. -#[derive(Default)] -pub struct MockHost { - /// `nexum:host/chain` mock. - pub chain: MockChain, - /// `nexum:host/local-store` mock. - pub store: MockLocalStore, - /// `shepherd:cow/cow-api` mock. - pub cow_api: MockCowApi, - /// `nexum:host/logging` mock. - pub logging: MockLogging, -} - -impl MockHost { - /// Fresh empty host. Equivalent to `Default::default`. - pub fn new() -> Self { - Self::default() - } -} - -impl ChainHost for MockHost { - fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { - self.chain.request(chain_id, method, params) - } -} - -impl LocalStoreHost for MockHost { - fn get(&self, key: &str) -> Result>, Fault> { - self.store.get(key) - } - fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { - self.store.set(key, value) - } - fn delete(&self, key: &str) -> Result<(), Fault> { - self.store.delete(key) - } - fn list_keys(&self, prefix: &str) -> Result, Fault> { - self.store.list_keys(prefix) - } -} - -impl CowApiHost for MockHost { - fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { - self.cow_api.submit_order(chain_id, body) - } - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - body: Option<&str>, - ) -> Result { - self.cow_api.cow_api_request(chain_id, method, path, body) - } -} - -impl LoggingHost for MockHost { - fn log(&self, level: Level, message: &str) { - self.logging.log(level, message); - } -} - -// ---------------------------------------------------------------- cow-api - -/// In-memory [`CowApiHost`] that captures every submission and returns -/// a programmable response. -#[derive(Default)] -pub struct MockCowApi { - response: RefCell>>, - calls: RefCell>, - /// `cow_api_request` mock state. Keyed by `(method, path)` so - /// tests can program different responses for `GET - /// /api/v1/app_data/0x...` vs other endpoints. Falls back to the - /// unkeyed `request_response` if no key matches. - request_responses: - RefCell>>, - request_response: RefCell>>, - request_calls: RefCell>, -} - -/// One recorded [`MockCowApi::submit_order`] invocation. -#[derive(Clone, Debug)] -pub struct SubmitCall { - /// Chain the guest targeted. - pub chain_id: u64, - /// Raw `OrderCreation` JSON body. - pub body: Vec, -} - -/// One recorded [`MockCowApi::cow_api_request`] invocation. -#[derive(Clone, Debug)] -pub struct RequestCall { - /// Chain the guest targeted. - pub chain_id: u64, - /// HTTP-style verb. - pub method: String, - /// Absolute orderbook path, e.g. `/api/v1/app_data/0xabcd...`. - pub path: String, - /// Optional JSON body (for POST/PUT). - pub body: Option, -} - -impl MockCowApi { - /// Program the response the mock returns on every subsequent - /// `submit_order` call. Defaults to an `Unsupported` fault if - /// unset. - pub fn respond(&self, result: Result) { - *self.response.borrow_mut() = Some(result); - } - - /// All submissions, in arrival order. - pub fn calls(&self) -> Vec { - self.calls.borrow().clone() - } - - /// Last submission, if any. - pub fn last_call(&self) -> Option { - self.calls.borrow().last().cloned() - } - - /// Convenience: parse the most recent body as JSON. - pub fn last_body_as_json(&self) -> Option { - self.last_call() - .and_then(|c| serde_json::from_slice(&c.body).ok()) - } - - /// Count of submissions. - pub fn call_count(&self) -> usize { - self.calls.borrow().len() - } -} - -impl MockCowApi { - /// Program a response for a specific `(method, path)` pair. - /// Highest priority - used when both this and `respond_to_request` - /// are set. - pub fn respond_to_request_for( - &self, - method: impl Into, - path: impl Into, - result: Result, - ) { - self.request_responses - .borrow_mut() - .insert((method.into(), path.into()), result); - } - - /// Program the catch-all response for `cow_api_request` calls - /// that don't match a specific `(method, path)` key. Defaults - /// to an `Unsupported` fault. - pub fn respond_to_request(&self, result: Result) { - *self.request_response.borrow_mut() = Some(result); - } - - /// All `cow_api_request` invocations, in arrival order. - pub fn request_calls(&self) -> Vec { - self.request_calls.borrow().clone() - } -} - -impl CowApiHost for MockCowApi { - fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { - self.calls.borrow_mut().push(SubmitCall { - chain_id, - body: body.to_vec(), - }); - self.response.borrow().clone().unwrap_or_else(|| { - Err(CowApiError::Fault(Fault::Unsupported( - "MockCowApi: no response configured".to_string(), - ))) - }) - } - - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - body: Option<&str>, - ) -> Result { - self.request_calls.borrow_mut().push(RequestCall { - chain_id, - method: method.to_string(), - path: path.to_string(), - body: body.map(str::to_string), - }); - if let Some(r) = self - .request_responses - .borrow() - .get(&(method.to_string(), path.to_string())) - .cloned() - { - return r; - } - self.request_response.borrow().clone().unwrap_or_else(|| { - Err(CowApiError::Fault(Fault::Unsupported( - "MockCowApi: no cow_api_request response configured".to_string(), - ))) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cow_api_captures_body_and_returns_uid() { - let api = MockCowApi::default(); - api.respond(Ok("0xdeadbeef".into())); - let uid = api.submit_order(1, b"{\"x\":1}").unwrap(); - assert_eq!(uid, "0xdeadbeef"); - let last = api.last_call().unwrap(); - assert_eq!(last.chain_id, 1); - assert_eq!(last.body, b"{\"x\":1}"); - assert_eq!(api.last_body_as_json().unwrap()["x"], 1); - } - - #[test] - fn cow_api_default_response_is_unsupported() { - let api = MockCowApi::default(); - let err = api.submit_order(1, b"{}").unwrap_err(); - assert!( - matches!(err, CowApiError::Fault(Fault::Unsupported(_))), - "got {err:?}", - ); - } - - #[test] - fn mock_host_dispatches_through_cow_host_bound() { - let host = MockHost::new(); - host.chain - .respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); - host.cow_api.respond(Ok("0xuid".into())); - - // Through the `CowHost` bound. - let _: &dyn shepherd_sdk::cow::CowHost = &host; - host.set("key", b"val").unwrap(); - assert_eq!(host.get("key").unwrap().as_deref(), Some(&b"val"[..])); - assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); - assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); - host.log(Level::INFO, "happy path"); - - assert_eq!(host.chain.call_count(), 1); - assert_eq!(host.cow_api.call_count(), 1); - assert_eq!(host.logging.lines().len(), 1); - assert_eq!(host.store.len(), 1); - } -} diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml deleted file mode 100644 index fe8e1af3..00000000 --- a/crates/shepherd-sdk/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "shepherd-sdk" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, order bridging, revert decoding, and prelude on top of cowprotocol types." - -[lib] -# Plain library - modules link this and emit their own cdylib for the -# WASM Component. Building shepherd-sdk on the host target is also -# supported so the helpers are unit-testable without a wasm toolchain. - -[dependencies] -nexum-sdk = { path = "../nexum-sdk" } -cowprotocol = { version = "0.2.0", default-features = false } -alloy-primitives.workspace = true -alloy-sol-types.workspace = true -strum.workspace = true -thiserror.workspace = true - -[dev-dependencies] -proptest.workspace = true diff --git a/crates/shepherd-sdk/README.md b/crates/shepherd-sdk/README.md deleted file mode 100644 index a93d3dfb..00000000 --- a/crates/shepherd-sdk/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# shepherd-sdk - -CoW-domain guest SDK for [Shepherd](https://github.com/nullislabs/shepherd) modules. - -`shepherd-sdk` layers the CoW Protocol surface on top of the generic -`nexum-sdk`: the module keeps its own `wit_bindgen::generate!` call -(which emits the world-specific `Guest` trait and host-import shims -into the module's own crate), pulls the host trait seam and generic -helpers from `nexum-sdk`, and pulls the CoW types and helpers from -here. Nothing is re-exported between the two crates; modules import -each directly. - -## Quick tour - -```rust -use nexum_sdk::prelude::*; -use shepherd_sdk::prelude::*; -use shepherd_sdk::cow::{gpv2_to_order_data, classify_api_error, RetryAction}; -``` - -| Module | What it provides | -|---|---| -| `prelude` | One-liner `use ::*` for cowprotocol order / signing / orderbook surface (alloy primitives come from `nexum_sdk::prelude`). | -| `cow` | `CowApiHost` trait for `shepherd:cow/cow-api` + the `CowHost` bound over the core `nexum_sdk::host::Host`. | -| `cow::order` | `gpv2_to_order_data` - `GPv2OrderData` -> typed `OrderData`. | -| `cow::composable` | `sol! IConditionalOrder` errors + `PollOutcome` + `decode_revert` + `decode_revert_hex`. | -| `cow::error` | `CowApiError` (mirror of `cow-api-error`: `Fault` / `Http` / `Rejected`) + `RetryAction` enum + `classify_api_error` over an `OrderRejection`. | -| `wit_bindgen_macro` | `bind_cow_host_via_wit_bindgen!` - the generic `WitBindgenHost` adapter plus the `CowApiHost` impl. | - -## Testing modules host-free - -Add the companion `shepherd-sdk-test` crate as a dev-dep and write -your strategy function against `&impl shepherd_sdk::cow::CowHost` -(or `&impl nexum_sdk::host::Host` if it never touches the -orderbook). Tests against `MockHost` then run without `wit-bindgen` -or `wasmtime`: - -```rust,ignore -let host = shepherd_sdk_test::MockHost::new(); -host.cow_api.respond(Ok("0xuid".into())); -submit_watch(&host, 1).unwrap(); -assert_eq!(host.cow_api.call_count(), 1); -``` - -## Why no `wit_bindgen::generate!` in the SDK - -The macro emits types into the calling crate (the module's cdylib). -Re-exporting wit-bindgen output from a library would duplicate -symbols and break the component-export contract. Helpers in this -SDK take primitive arguments (`&[u8]`, `&str`, `Option<&str>`) so -the SDK stays world-neutral; modules unpack their wit-bindgen -`Fault` / `Log` into primitives at the call site. Trade-off -documented in ADR-0006 and ADR-0007 in `docs/adr/`. - -## Layout - -``` -crates/shepherd-sdk/ -├── src/ -│ ├── lib.rs crate root + intra-doc links -│ ├── prelude.rs cowprotocol bulk re-exports -│ ├── cow/ -│ │ ├── mod.rs CowApiHost + CowHost -│ │ ├── order.rs gpv2_to_order_data -│ │ ├── composable.rs IConditionalOrder + PollOutcome + decode_revert(_hex) -│ │ └── error.rs RetryAction + classify_api_error -│ └── wit_bindgen_macro.rs bind_cow_host_via_wit_bindgen! -└── README.md you are here - -(The generic surface - host trait seam, chain / config / address -helpers, http, tracing - lives in the sibling `nexum-sdk` crate.) -``` - -## Generating docs locally - -```sh -RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk --no-deps --open -``` - -The CI gate `cargo doc -p shepherd-sdk --no-deps` runs under those -flags, so all public items carry doc comments and intra-doc links -resolve. diff --git a/crates/shepherd-sdk/src/cow/composable.rs b/crates/shepherd-sdk/src/cow/composable.rs deleted file mode 100644 index 212e1240..00000000 --- a/crates/shepherd-sdk/src/cow/composable.rs +++ /dev/null @@ -1,190 +0,0 @@ -//! ComposableCoW poll-revert decoding. -//! -//! `ComposableCoW.getTradeableOrderWithSignature` reverts with one of -//! five custom errors when the conditional order is not ready, expired, -//! or otherwise non-tradeable. This module mirrors that error surface -//! and maps each revert to the typed [`PollOutcome`] every TWAP / -//! strategy module dispatches on. -//! -//! Source for the Solidity errors: -//! `cowprotocol/composable-cow/src/interfaces/IConditionalOrder.sol`. - -use alloy_primitives::{Bytes, U256}; -use alloy_sol_types::{SolError, sol}; -use cowprotocol::GPv2OrderData; - -sol! { - /// Five custom errors `IConditionalOrder.verify` reverts with. - /// Selector source for [`decode_revert`]. The wire shape mirrors - /// the Solidity definitions verbatim so the four-byte selectors - /// computed here match what the contract emits. - #[derive(Debug)] - interface IConditionalOrder { - /// `OrderNotValid(string)` - the order condition is permanently - /// not met. Watch towers drop. - error OrderNotValid(string reason); - /// `PollTryNextBlock(string)` - try again on the next block. - error PollTryNextBlock(string reason); - /// `PollTryAtBlock(uint256, string)` - try at or after the - /// given block number. - error PollTryAtBlock(uint256 blockNumber, string reason); - /// `PollTryAtEpoch(uint256, string)` - try at or after the - /// given Unix timestamp (seconds). - error PollTryAtEpoch(uint256 timestamp, string reason); - /// `PollNever(string)` - the conditional order is dead. - error PollNever(string reason); - } -} - -/// Outcome of a single watch poll. Mirrors the enum shape: -/// `Ready` carries the materials the submit path needs; the other -/// variants drive the lifecycle handler. -/// -/// `Ready` is intentionally never produced by [`decode_revert`] - it -/// only comes from the successful return path the poll module -/// constructs at the call site. -#[derive(Debug)] -pub enum PollOutcome { - /// Conditional order is tradeable now; submit `order` with the - /// embedded EIP-1271 `signature` blob. `GPv2OrderData` is boxed - /// to keep the enum cache-friendly (~300 bytes vs. ~8 for the - /// other variants). - Ready { - /// The 12-field order ready to submit. - order: Box, - /// EIP-1271 wire-form signature (raw verifier bytes; the - /// orderbook prepends `from` before settlement). - signature: Bytes, - }, - /// Retry on the very next block - typical for time-sliced TWAP - /// schedules and other handlers that re-check on every tick. - TryNextBlock, - /// Retry once block number reaches the embedded value. - TryOnBlock(u64), - /// Retry once the wall clock (Unix seconds, UTC) reaches the - /// embedded value. - TryAtEpoch(u64), - /// Order is dead - drop the watch. Aggregates `OrderNotValid` and - /// `PollNever` reverts; the original reason string is dropped - /// because the lifecycle handler does not key off it today. - DontTryAgain, -} - -/// Decode a `getTradeableOrderWithSignature` revert payload into a -/// [`PollOutcome`]. -/// -/// Returns `None` when the selector is not one of the five -/// [`IConditionalOrder`] errors - including a bare `Error(string)` -/// require-revert. Callers should treat that as `TryNextBlock` (the -/// safe default) so a transient RPC blip does not drop a still-valid -/// watch. -#[must_use] -pub fn decode_revert(data: &[u8]) -> Option { - if data.len() < 4 { - return None; - } - let selector: [u8; 4] = data[..4].try_into().ok()?; - let body = &data[4..]; - match selector { - s if s == IConditionalOrder::OrderNotValid::SELECTOR => Some(PollOutcome::DontTryAgain), - s if s == IConditionalOrder::PollTryNextBlock::SELECTOR => Some(PollOutcome::TryNextBlock), - s if s == IConditionalOrder::PollTryAtBlock::SELECTOR => { - let decoded = IConditionalOrder::PollTryAtBlock::abi_decode_raw(body).ok()?; - Some(PollOutcome::TryOnBlock(u256_to_u64_saturating( - decoded.blockNumber, - ))) - } - s if s == IConditionalOrder::PollTryAtEpoch::SELECTOR => { - let decoded = IConditionalOrder::PollTryAtEpoch::abi_decode_raw(body).ok()?; - Some(PollOutcome::TryAtEpoch(u256_to_u64_saturating( - decoded.timestamp, - ))) - } - s if s == IConditionalOrder::PollNever::SELECTOR => Some(PollOutcome::DontTryAgain), - _ => None, - } -} - -fn u256_to_u64_saturating(v: U256) -> u64 { - u64::try_from(v).unwrap_or(u64::MAX) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn order_not_valid_maps_to_drop() { - let err = IConditionalOrder::OrderNotValid { - reason: "expired".to_string(), - }; - assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::DontTryAgain) - )); - } - - #[test] - fn poll_never_maps_to_drop() { - let err = IConditionalOrder::PollNever { - reason: "cancelled".to_string(), - }; - assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::DontTryAgain) - )); - } - - #[test] - fn try_next_block() { - let err = IConditionalOrder::PollTryNextBlock { - reason: "noop".to_string(), - }; - assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::TryNextBlock) - )); - } - - #[test] - fn try_at_block_carries_number() { - let err = IConditionalOrder::PollTryAtBlock { - blockNumber: U256::from(12_345_678_u64), - reason: "wait".to_string(), - }; - assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::TryOnBlock(12_345_678)) - )); - } - - #[test] - fn try_at_epoch_carries_timestamp() { - let err = IConditionalOrder::PollTryAtEpoch { - timestamp: U256::from(1_700_000_000_u64), - reason: "soon".to_string(), - }; - assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::TryAtEpoch(1_700_000_000)) - )); - } - - #[test] - fn unknown_selector_returns_none() { - let mut data = vec![0xde, 0xad, 0xbe, 0xef]; - data.extend_from_slice(&[0u8; 32]); - assert!(decode_revert(&data).is_none()); - } - - #[test] - fn truncated_returns_none() { - assert!(decode_revert(&[0x01, 0x02]).is_none()); - } - - #[test] - fn u256_saturates_at_max() { - assert_eq!(u256_to_u64_saturating(U256::MAX), u64::MAX); - assert_eq!(u256_to_u64_saturating(U256::from(42_u64)), 42); - } -} diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs deleted file mode 100644 index 23360273..00000000 --- a/crates/shepherd-sdk/src/cow/error.rs +++ /dev/null @@ -1,249 +0,0 @@ -//! Typed `shepherd:cow/cow-api` error surface and orderbook rejection -//! classification. -//! -//! [`CowApiError`] mirrors the WIT `cow-api-error` variant: a shared -//! host [`Fault`], a raw [`HttpFailure`], or a typed [`OrderRejection`] -//! the host parsed once from the orderbook's `{errorType, description}` -//! envelope. The guest dispatches on the variant directly, so no -//! second JSON decode of a failure body happens strategy-side. -//! -//! [`classify_api_error`] maps a decoded [`OrderRejection`] into a -//! [`RetryAction`] the lifecycle layer dispatches on. - -use nexum_sdk::host::{Fault, HostFault}; -use strum::IntoStaticStr; - -/// A non-2xx orderbook reply with no typed rejection envelope. `body` -/// is the raw response text, foreign orderbook JSON kept verbatim: a -/// caller matches on `status` and reads `body` only for diagnostics. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct HttpFailure { - /// HTTP status code. - pub status: u16, - /// Raw response body, when the host captured one. - pub body: Option, -} - -/// A typed orderbook rejection of a submitted order, parsed once -/// host-side from the `{errorType, description, data}` envelope. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct OrderRejection { - /// HTTP status returned with the rejection. - pub status: u16, - /// Machine-readable `errorType` (e.g. `"InsufficientFee"`). - pub error_type: String, - /// Human-readable description. - pub description: String, - /// The envelope's optional structured payload (e.g. a minimum-fee - /// quote), serialised to a JSON string by the host via - /// `serde_json::Value::to_string`. - pub data: Option, -} - -/// Mirror of `shepherd:cow/cow-api.cow-api-error`. The domain-side -/// counterpart the [`bind_cow_host_via_wit_bindgen`](crate::bind_cow_host_via_wit_bindgen) -/// macro converts the per-cdylib wit-bindgen error into, so strategy -/// logic dispatches on one host-neutral type. -/// -/// `IntoStaticStr` exposes the variant name as a snake_case `&'static -/// str`; [`HostFault::label`] refines the [`Fault`] case to the -/// embedded fault's own label so metric and log labels stay granular. -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum CowApiError { - /// A shared host fault (unsupported, timeout, transport down, ...). - #[error(transparent)] - Fault(Fault), - /// A raw non-2xx HTTP reply without a typed rejection envelope. - #[error("orderbook http {}", .0.status)] - Http(HttpFailure), - /// A typed orderbook rejection of a submitted order. - #[error("orderbook rejected ({} {}): {}", .0.status, .0.error_type, .0.description)] - Rejected(OrderRejection), -} - -impl HostFault for CowApiError { - fn fault(&self) -> Option<&Fault> { - match self { - CowApiError::Fault(f) => Some(f), - _ => None, - } - } - - fn label(&self) -> &'static str { - match self { - CowApiError::Fault(f) => f.label(), - other => other.into(), - } - } -} - -/// What the lifecycle layer should do after a failed submission. -/// -/// Mirrors the retry contract: `TryNextBlock` / -/// `BackoffSeconds(s)` / `Drop`. The `Backoff` arm has no producer -/// today because the retry classifier is bool-only; the -/// variant is kept so dispatch can grow into it once a server -/// `Retry-After` hint shows up. -/// -/// `IntoStaticStr` exposes each variant as a snake_case `&'static -/// str` so the dispatch layer can record -/// `shepherd_cow_api_retry_total{action=...}` and surface the action -/// in `tracing::info!(retry_action = ...)` without an ad-hoc match -/// ladder. -#[derive(Debug, Eq, PartialEq, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum RetryAction { - /// Leave the watch / placement in place; the next event will - /// re-attempt. - TryNextBlock, - /// Persist `next_attempt = now + seconds`. Reserved - no producer - /// today (kept so the dispatch contract is stable). - #[allow(dead_code)] - Backoff { - /// Seconds to wait before retrying. - seconds: u64, - }, - /// Remove the watch / mark as terminally rejected. The orderbook - /// will not accept this body on a retry. - Drop, -} - -/// Classify a decoded orderbook [`OrderRejection`] into a -/// [`RetryAction`]. -/// -/// - Retriable `error_type`s (`InsufficientFee`, `TooManyLimitOrders`, -/// `PriceExceedsMarketPrice`) -> `TryNextBlock`. -/// - Every other (including unrecognised) kind -> `Drop`. -/// -/// Non-`Rejected` failures (transport faults, raw HTTP errors) carry -/// no `error_type` and are not classified here; the caller treats them -/// as transient (leave the watch in place) so a flaky orderbook does -/// not poison a still-valid order. -/// -/// # Example -/// -/// ``` -/// use shepherd_sdk::cow::{classify_api_error, OrderRejection, RetryAction}; -/// -/// // Transient: orderbook rejects with InsufficientFee -> retry next block. -/// let transient = OrderRejection { -/// status: 400, -/// error_type: "InsufficientFee".to_string(), -/// description: "fee too low".to_string(), -/// data: None, -/// }; -/// assert_eq!(classify_api_error(&transient), RetryAction::TryNextBlock); -/// -/// // Permanent: InvalidSignature -> drop the watch / placement. -/// let permanent = OrderRejection { -/// status: 400, -/// error_type: "InvalidSignature".to_string(), -/// description: "bad sig".to_string(), -/// data: None, -/// }; -/// assert_eq!(classify_api_error(&permanent), RetryAction::Drop); -/// ``` -pub fn classify_api_error(rejection: &OrderRejection) -> RetryAction { - if is_retriable(&rejection.error_type) { - RetryAction::TryNextBlock - } else { - RetryAction::Drop - } -} - -/// Orderbook `errorType` values the protocol treats as transient: a -/// fresh submission on a later block may succeed. Everything else -/// (including unrecognised types) is permanent. Mirrors the upstream -/// order-post retry classifier. -fn is_retriable(error_type: &str) -> bool { - matches!( - error_type, - "InsufficientFee" | "TooManyLimitOrders" | "PriceExceedsMarketPrice" - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use nexum_sdk::host::RateLimit; - - fn rejection(error_type: &str) -> OrderRejection { - OrderRejection { - status: 400, - error_type: error_type.to_string(), - description: "test".to_string(), - data: None, - } - } - - #[test] - fn retriable_kinds_yield_try_next_block() { - for kind in [ - "InsufficientFee", - "TooManyLimitOrders", - "PriceExceedsMarketPrice", - ] { - assert_eq!( - classify_api_error(&rejection(kind)), - RetryAction::TryNextBlock, - "{kind}", - ); - } - } - - #[test] - fn permanent_kinds_yield_drop() { - for kind in [ - "InvalidSignature", - "WrongOwner", - "DuplicateOrder", - "UnsupportedToken", - "InvalidAppData", - "InvalidErc1271Signature", - ] { - assert_eq!( - classify_api_error(&rejection(kind)), - RetryAction::Drop, - "{kind}", - ); - } - } - - #[test] - fn unknown_kind_yields_drop() { - assert_eq!( - classify_api_error(&rejection("NewlyMintedErrorType")), - RetryAction::Drop, - ); - } - - #[test] - fn fault_case_recovers_embedded_fault_and_label() { - let err = CowApiError::Fault(Fault::Timeout); - assert_eq!(err.fault(), Some(&Fault::Timeout)); - // Fault case refines the label to the embedded fault's own. - assert_eq!(err.label(), "timeout"); - - let rl = CowApiError::Fault(Fault::RateLimited(RateLimit { - retry_after_ms: Some(250), - })); - assert_eq!(rl.label(), "rate_limited"); - } - - #[test] - fn non_fault_cases_expose_variant_label_and_no_fault() { - let http = CowApiError::Http(HttpFailure { - status: 404, - body: None, - }); - assert_eq!(http.fault(), None); - assert_eq!(http.label(), "http"); - - let rejected = CowApiError::Rejected(rejection("InvalidSignature")); - assert_eq!(rejected.fault(), None); - assert_eq!(rejected.label(), "rejected"); - } -} diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs deleted file mode 100644 index 36cbdcca..00000000 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! CoW Protocol bridging. -//! -//! Type conversions and ABI decoding helpers that translate between -//! the on-chain shape (`GPv2OrderData`, `IConditionalOrder` reverts, -//! orderbook JSON) and the typed Rust surface (`OrderData`, -//! `PollOutcome`, `RetryAction`). -//! -//! Each submodule stays purely host-neutral: helpers take primitive -//! arguments (`&[u8]`, `Option<&str>`, slices) so they can be unit- -//! tested without wit-bindgen scaffolding and re-used unchanged by -//! TWAP, EthFlow, and future strategy modules. - -pub mod composable; -pub mod error; -pub mod order; - -pub use composable::{IConditionalOrder, PollOutcome, decode_revert}; -pub use error::{CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error}; -pub use order::gpv2_to_order_data; - -use nexum_sdk::host::Host; - -/// `shepherd:cow/cow-api` - orderbook submission path. The CoW-domain -/// sibling of the core host traits in [`nexum_sdk::host`]. -pub trait CowApiHost { - /// Submit an `OrderCreation` JSON body. The host returns the - /// canonical order UID on success. A rejection surfaces as a typed - /// [`CowApiError::Rejected`]; classify it with - /// [`classify_api_error`]. - fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result; - - /// REST-style request against the CoW Protocol orderbook for the - /// given chain. The host routes to the correct base URL - /// (`https://api.cow.fi//api/v1/...`). Returns the raw - /// response body. Strategies that need a typed surface should - /// wrap this in an SDK helper. - /// - /// `method` is `"GET" | "POST" | "PUT" | "DELETE"`. - /// `path` is the absolute orderbook path beginning with `/api/v1`. - /// `body` is an optional JSON request body (only used for POST/PUT). - /// - /// A non-2xx reply surfaces as [`CowApiError::Http`]; callers - /// distinguish "orderbook does not know this resource" from a - /// genuine upstream failure by matching `http.status == 404`. - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - body: Option<&str>, - ) -> Result; -} - -/// Host bound for strategies that reach the CoW Protocol orderbook. -pub trait CowHost: Host + CowApiHost {} -impl CowHost for T {} diff --git a/crates/shepherd-sdk/src/cow/order.rs b/crates/shepherd-sdk/src/cow/order.rs deleted file mode 100644 index d983d649..00000000 --- a/crates/shepherd-sdk/src/cow/order.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! `GPv2OrderData` -> `OrderData` bridging. -//! -//! ComposableCoW and CoWSwapEthFlow both emit / return the 12-field -//! `GPv2OrderData` Solidity tuple, with `kind` / `sellTokenBalance` / -//! `buyTokenBalance` as 32-byte keccak markers. The orderbook signs -//! against the typed `OrderData` shape, with those markers projected -//! into Rust enums. [`gpv2_to_order_data`] is the bridge. - -use alloy_primitives::Address; -use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderData, OrderKind, SellTokenSource}; - -/// Convert a freshly-polled / freshly-placed [`GPv2OrderData`] into the -/// typed [`OrderData`] shape `OrderCreation::from_signed_order_data` -/// expects. -/// -/// The `kind`, `sellTokenBalance`, and `buyTokenBalance` fields ride -/// the wire as `bytes32` markers (the `keccak256` of the lowercase -/// variant name). This helper hands them off to cowprotocol's -/// `from_contract_bytes` classifiers and returns `None` when the on- -/// chain payload carries a marker the SDK doesn't recognise - the -/// caller skips the order rather than ship a malformed body. -/// -/// `receiver = Address::ZERO` is normalised to `None`; `OrderCreation:: -/// from_signed_order_data` does the same downstream, but doing it here -/// keeps the EIP-712 hash inputs verbatim if a caller bypasses that -/// helper later. -/// -/// # Example -/// -/// ``` -/// use cowprotocol::{ -/// BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource, -/// }; -/// use shepherd_sdk::cow::gpv2_to_order_data; -/// use nexum_sdk::prelude::{Address, U256}; -/// -/// let gpv2 = GPv2OrderData { -/// sellToken: Address::repeat_byte(1), -/// buyToken: Address::repeat_byte(2), -/// receiver: Address::ZERO, // normalised to None -/// sellAmount: U256::from(1_000u64), -/// buyAmount: U256::from(999u64), -/// validTo: u32::MAX, -/// appData: cowprotocol::EMPTY_APP_DATA_HASH, -/// feeAmount: U256::ZERO, -/// kind: OrderKind::SELL, -/// partiallyFillable: false, -/// sellTokenBalance: SellTokenSource::ERC20, -/// buyTokenBalance: BuyTokenDestination::ERC20, -/// }; -/// -/// let order = gpv2_to_order_data(&gpv2).expect("known markers"); -/// assert_eq!(order.sell_amount, U256::from(1_000u64)); -/// assert_eq!(order.receiver, None); -/// ``` -#[must_use] -pub fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { - Some(OrderData { - sell_token: gpv2.sellToken, - buy_token: gpv2.buyToken, - receiver: (gpv2.receiver != Address::ZERO).then_some(gpv2.receiver), - sell_amount: gpv2.sellAmount, - buy_amount: gpv2.buyAmount, - valid_to: gpv2.validTo, - app_data: gpv2.appData, - fee_amount: gpv2.feeAmount, - kind: OrderKind::from_contract_bytes(gpv2.kind)?, - partially_fillable: gpv2.partiallyFillable, - sell_token_balance: SellTokenSource::from_contract_bytes(gpv2.sellTokenBalance)?, - buy_token_balance: BuyTokenDestination::from_contract_bytes(gpv2.buyTokenBalance)?, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_primitives::{B256, U256, address}; - - fn submittable_gpv2() -> GPv2OrderData { - GPv2OrderData { - sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), - buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), - receiver: address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"), - sellAmount: U256::from(1_000_000_u64), - buyAmount: U256::from(999_u64), - validTo: 0xffff_ffff, - appData: cowprotocol::EMPTY_APP_DATA_HASH, - feeAmount: U256::ZERO, - kind: OrderKind::SELL, - partiallyFillable: false, - sellTokenBalance: SellTokenSource::ERC20, - buyTokenBalance: BuyTokenDestination::ERC20, - } - } - - #[test] - fn happy_path_round_trips_markers() { - let g = submittable_gpv2(); - let od = gpv2_to_order_data(&g).expect("known markers"); - assert_eq!(od.sell_token, g.sellToken); - assert_eq!(od.buy_token, g.buyToken); - assert_eq!(od.kind, OrderKind::Sell); - assert_eq!(od.sell_token_balance, SellTokenSource::Erc20); - assert_eq!(od.buy_token_balance, BuyTokenDestination::Erc20); - } - - #[test] - fn zero_receiver_normalises_to_none() { - let mut g = submittable_gpv2(); - g.receiver = Address::ZERO; - assert_eq!(gpv2_to_order_data(&g).unwrap().receiver, None); - } - - #[test] - fn non_zero_receiver_preserved() { - let g = submittable_gpv2(); - assert_eq!(gpv2_to_order_data(&g).unwrap().receiver, Some(g.receiver)); - } - - #[test] - fn unknown_kind_marker_returns_none() { - let mut g = submittable_gpv2(); - g.kind = B256::repeat_byte(0x42); - assert!(gpv2_to_order_data(&g).is_none()); - } - - #[test] - fn unknown_sell_token_balance_returns_none() { - let mut g = submittable_gpv2(); - g.sellTokenBalance = B256::repeat_byte(0x99); - assert!(gpv2_to_order_data(&g).is_none()); - } - - #[test] - fn unknown_buy_token_balance_returns_none() { - let mut g = submittable_gpv2(); - g.buyTokenBalance = B256::repeat_byte(0x55); - assert!(gpv2_to_order_data(&g).is_none()); - } -} diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs deleted file mode 100644 index 86e00dea..00000000 --- a/crates/shepherd-sdk/src/lib.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! # shepherd-sdk -//! -//! CoW-domain SDK for Shepherd modules, layered on the generic -//! [`nexum_sdk`]. Everything host-neutral (the host trait seam, config -//! and address parsing, `eth_call` plumbing, HTTP, the tracing facade) -//! lives in `nexum-sdk`; this crate carries only the CoW Protocol -//! surface. Modules import both crates directly. -//! -//! ## What lives here -//! -//! - [`prelude`] - `use shepherd_sdk::prelude::*` imports cowprotocol's -//! order / signing surface ([`OrderCreation`], -//! [`OrderData`], [`OrderUid`], [`OrderKind`], [`Signature`], -//! [`Chain`], [`GPv2OrderData`], [`EMPTY_APP_DATA_JSON`]). -//! -//! - [`cow`] - the [`CowApiHost`] trait for `shepherd:cow/cow-api` -//! (and the [`CowHost`] bound over the core [`Host`]), -//! `GPv2OrderData` -> `OrderData` bridging ([`gpv2_to_order_data`]), -//! `IConditionalOrder` revert decoding ([`PollOutcome`] + -//! [`decode_revert`]), and the [`RetryAction`] classifier driving -//! submit-failure dispatch. -//! -//! - [`bind_cow_host_via_wit_bindgen!`](bind_cow_host_via_wit_bindgen) - -//! the CoW layering of `nexum_sdk::bind_host_via_wit_bindgen!`: -//! the generic `WitBindgenHost` adapter plus the `CowApiHost` impl. -//! -//! ## Why no `wit_bindgen::generate!` here -//! -//! The macro emits types into the calling crate (the module's -//! cdylib). Re-exporting wit-bindgen output from a library crate -//! would duplicate symbols and break the component-export contract. -//! Helpers in this SDK therefore take primitive types (`&[u8]`, -//! `Option<&str>`, slices) rather than the per-module `Fault` -//! type; modules unpack their `Fault` on the way in. Trade-off -//! documented in ADR-0006 / ADR-0007 - the SDK stays on the guest -//! side, neutral to which world the module exports. -//! -//! [`OrderCreation`]: cowprotocol::OrderCreation -//! [`OrderData`]: cowprotocol::OrderData -//! [`OrderUid`]: cowprotocol::OrderUid -//! [`OrderKind`]: cowprotocol::OrderKind -//! [`Signature`]: cowprotocol::Signature -//! [`Chain`]: cowprotocol::Chain -//! [`GPv2OrderData`]: cowprotocol::GPv2OrderData -//! [`EMPTY_APP_DATA_JSON`]: cowprotocol::EMPTY_APP_DATA_JSON -//! [`CowApiHost`]: cow::CowApiHost -//! [`CowHost`]: cow::CowHost -//! [`Host`]: nexum_sdk::host::Host -//! [`gpv2_to_order_data`]: cow::gpv2_to_order_data -//! [`PollOutcome`]: cow::PollOutcome -//! [`decode_revert`]: cow::decode_revert -//! [`RetryAction`]: cow::RetryAction - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![warn(missing_docs)] -#![cfg_attr(docsrs, feature(doc_cfg))] - -pub mod cow; -pub mod prelude; -pub mod wit_bindgen_macro; - -#[cfg(test)] -mod proptests; - -#[cfg(test)] -mod tests { - //! Locks the prelude's surface - the build itself proves the - //! re-exports compile against both `wasm32-wasip2` and the - //! host target. - - use crate::prelude::*; - - #[test] - fn prelude_re_exports_resolve() { - let _kind: OrderKind = OrderKind::Sell; - let _chain: Chain = Chain::Sepolia; - assert_eq!(EMPTY_APP_DATA_JSON, "{}"); - } -} diff --git a/crates/shepherd-sdk/src/prelude.rs b/crates/shepherd-sdk/src/prelude.rs deleted file mode 100644 index 6adbc23b..00000000 --- a/crates/shepherd-sdk/src/prelude.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Bulk-imports the CoW Protocol primitives every Shepherd module uses -//! on every other line. `use shepherd_sdk::prelude::*` covers -//! cowprotocol's order and signing surface; the alloy address / hash / -//! numeric types come from `nexum_sdk::prelude::*` alongside it. -//! -//! The wit-bindgen-generated types (`Guest`, `Fault`, `Event`, …) -//! are **not** re-exported here because they live in each module's own -//! crate (one `wit_bindgen::generate!` call per cdylib). The prelude -//! covers only the host-neutral protocol layer that the SDK helpers -//! consume by value. - -pub use cowprotocol::{ - BuyTokenDestination, - // App-data + chain + domain identity. - Chain, - DomainSeparator, - EMPTY_APP_DATA_HASH, - EMPTY_APP_DATA_JSON, - // Settlement primitives carried in event payloads and order bodies. - GPv2OrderData, - // Orderbook submission body + the parts every assembly path touches. - OrderCreation, - OrderData, - OrderKind, - // Order identity. - OrderUid, - SellTokenSource, - // Signing. - Signature, - SigningScheme, -}; diff --git a/crates/shepherd-sdk/src/proptests.rs b/crates/shepherd-sdk/src/proptests.rs deleted file mode 100644 index ea46e0e3..00000000 --- a/crates/shepherd-sdk/src/proptests.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Property-based regression tests for the CoW-domain codec surface. -//! Lives behind `#[cfg(test)]` so neither the wasm32-wasip2 builds nor -//! downstream consumers pay the proptest dep cost. -//! -//! Covered here: -//! -//! - `decode_revert` selector dispatch (no-panic guard). -//! - `gpv2_to_order_data` marker mapping (no-panic guard). -//! -//! The generic properties (`eth_call` round-trip, `scale_decimal`) -//! live in `nexum-sdk`. - -#![cfg(test)] - -use proptest::prelude::*; - -proptest! { - /// `decode_revert` on arbitrary revert bytes must never panic and - /// must return `None` for inputs shorter than the 4-byte EVM - /// selector. - #[test] - fn decode_revert_never_panics(bytes in proptest::collection::vec(any::(), 0..64)) { - let outcome = crate::cow::decode_revert(&bytes); - if bytes.len() < 4 { - prop_assert!(outcome.is_none()); - } - } -} - -proptest! { - /// `gpv2_to_order_data` is exhaustive over the marker enum; - /// fuzzing the inputs as raw u8 (not the typed enum) is the only - /// way to exercise the fallback path. Strategy: feed any 4 marker - /// bytes (kind + sellTokenSource + buyTokenDestination + - /// partiallyFillable) and assert either `Some` (recognised) or - /// `None` (unknown marker), never a panic. - #[test] - fn gpv2_marker_dispatch_never_panics( - kind in any::(), - sell in any::(), - buy in any::(), - fillable in any::(), - ) { - let _ = (kind, sell, buy, fillable); - // We do not call `gpv2_to_order_data` here because building - // a `GPv2OrderData` requires a full alloy-sol-encoded struct - // and the generators for that are extensive. The property - // test for the marker dispatch lives in `cow::order::tests` - // example-based; this proptest stands in as a no-panic - // guard for the inputs the strategy ABI can produce. - } -} diff --git a/crates/shepherd-sdk/src/wit_bindgen_macro.rs b/crates/shepherd-sdk/src/wit_bindgen_macro.rs deleted file mode 100644 index 0a4c9b14..00000000 --- a/crates/shepherd-sdk/src/wit_bindgen_macro.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! Declarative macro that generates the `WitBindgenHost` adapter for -//! CoW modules: the generic adapter plus the `CowApiHost` impl. -//! -//! Layers on `nexum_sdk::bind_host_via_wit_bindgen!`, which emits the -//! core adapter (`WitBindgenHost`, the `ChainHost` / `LocalStoreHost` -//! / `LoggingHost` impls, the error and level converters, and the -//! tracing wiring). This macro invokes it and adds the -//! [`CowApiHost`](crate::cow::CowApiHost) impl over the -//! `shepherd:cow/cow-api` import shims. -//! -//! The macro assumes the module compiles against `shepherd:cow/shepherd` -//! with `wit_bindgen::generate!({ ..., generate_all })`, so both the -//! `nexum::host::*` and `shepherd::cow::cow_api` output paths are in -//! scope at the call site, and that the module crate also depends on -//! `nexum-sdk` (the expansion names `::nexum_sdk` directly). -//! -//! Usage in a module's `lib.rs`: -//! -//! ```ignore -//! wit_bindgen::generate!({ /* ... */ }); -//! shepherd_sdk::bind_cow_host_via_wit_bindgen!(); -//! // Everything the generic macro emits is in scope, plus the -//! // `CowApiHost` impl for `WitBindgenHost`. -//! ``` - -/// Generate the generic `WitBindgenHost` adapter plus the `CowApiHost` -/// impl. See module docs. -#[macro_export] -macro_rules! bind_cow_host_via_wit_bindgen { - () => { - ::nexum_sdk::bind_host_via_wit_bindgen!(); - - /// Lift the per-cdylib wit-bindgen `cow-api-error` into the - /// SDK's [`CowApiError`]( - /// $crate::cow::CowApiError), projecting each case onto the - /// host-neutral mirror. - fn convert_cow_err(e: shepherd::cow::cow_api::CowApiError) -> $crate::cow::CowApiError { - match e { - shepherd::cow::cow_api::CowApiError::Fault(f) => { - $crate::cow::CowApiError::Fault(convert_fault(f)) - } - shepherd::cow::cow_api::CowApiError::Http(h) => { - $crate::cow::CowApiError::Http($crate::cow::HttpFailure { - status: h.status, - body: h.body, - }) - } - shepherd::cow::cow_api::CowApiError::Rejected(r) => { - $crate::cow::CowApiError::Rejected($crate::cow::OrderRejection { - status: r.status, - error_type: r.error_type, - description: r.description, - data: r.data, - }) - } - } - } - - impl $crate::cow::CowApiHost for WitBindgenHost { - fn submit_order( - &self, - chain_id: u64, - body: &[u8], - ) -> ::core::result::Result<::std::string::String, $crate::cow::CowApiError> { - shepherd::cow::cow_api::submit_order(chain_id, body).map_err(convert_cow_err) - } - - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - body: ::core::option::Option<&str>, - ) -> ::core::result::Result<::std::string::String, $crate::cow::CowApiError> { - shepherd::cow::cow_api::request(chain_id, method, path, body) - .map_err(convert_cow_err) - } - } - }; -} diff --git a/docker-compose.soak.yml b/docker-compose.soak.yml index 0921f186..b48a9027 100644 --- a/docker-compose.soak.yml +++ b/docker-compose.soak.yml @@ -1,7 +1,7 @@ # docker-compose.soak.yml — 7-day grant stability soak. # # Two services: -# engine — the nexum binary; restarts automatically on crash. +# engine — the shepherd binary; restarts automatically on crash. # snapshotter — Alpine loop that scrapes /metrics every hour and # writes evidence files to docs/operations/soak-reports/. # diff --git a/docs/00-overview.md b/docs/00-overview.md index e38b4425..aa7b76aa 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -1,29 +1,23 @@ # Nexum: Universal WASM Component Model Runtime -Nexum is a WASM Component Model runtime that provides secure, sandboxed execution for WebAssembly modules. Modules react to blockchain events, read chain state, persist data locally and to decentralised storage, communicate via decentralised messaging - all within a capability-based sandbox with zero implicit permissions. +Nexum is a WASM Component Model runtime that provides secure, sandboxed execution for WebAssembly modules. Modules react to blockchain events, read chain state, persist data locally and to decentralised storage, and communicate via decentralised messaging, all within a capability-based sandbox with zero implicit permissions. -**Shepherd** is the Nexum distribution that includes CoW Protocol extensions (`shepherd:cow` WIT package). A module compiled against the universal `nexum:host/event-module` world runs on any Nexum-compatible host. A module compiled against `shepherd:cow/shepherd` additionally gains access to CoW Protocol APIs and order submission - and requires a Shepherd host. +**Shepherd** is the Nexum distribution that adds CoW Protocol support. A module compiled against the universal `nexum:host/event-module` world runs on any Nexum-compatible host. CoW order submission is provided by the `videre:venue` venue-adapter layer (see doc 08), not by a domain-specific host interface. ### Vocabulary: engine vs. host (`nexum` vs. `nexum:host`) -Two project names look similar but mean different things - keeping them straight is load-bearing for everything that follows: - | Term | What it is | Where you find it | |---|---|---| -| **engine** (`nexum`) | A concrete *implementation* that loads and runs WASM components. The 0.2 reference engine is a wasmtime-based server daemon. Mobile / browser / embedded engines could exist later - each is a separate engine. | `crates/nexum-runtime/`, the `nexum` binary, `cargo run -p nexum-cli` | -| **host** (`nexum:host`) | The WIT *contract* - the set of host-imported interfaces (chain, identity, local-store, etc.), types, and worlds that every engine must implement and every module imports. The contract is one; engines are many. | `wit/nexum-host/`, `package nexum:host@0.2.0`, Rust path `nexum::host::*` | - -The relationship: an engine *implements* `nexum:host` so that modules *built against* `nexum:host` can run on it. The `nexum:host` package itself does not run anything - it's a specification. When this doc says "the host", it means whichever engine the module currently runs on, as seen through the `nexum:host` contract. - -The reference engine ships as two crates: the `nexum-runtime` library (embeddable, no CLI surface) and the `nexum` binary in `crates/nexum-cli`, a thin consumer of it. A Rust embedder skips the binary entirely, constructs an `EngineConfig` in code, and calls `nexum_runtime::bootstrap::run_from_config`. See `crates/nexum-runtime/examples/embed.rs` for a minimal end-to-end example. +| **engine** (`nexum`) | A concrete implementation that loads and runs WASM components. The 0.2 reference engine is a wasmtime-based server daemon. | `nexum/crates/nexum-runtime/`, the `nexum` binary, `cargo run -p nexum-cli` | +| **host** (`nexum:host`) | The WIT contract: the host-imported interfaces (chain, identity, local-store, ...), types, and worlds that every engine implements and every module imports. | `wit/nexum-host/`, `package nexum:host@0.1.0`, Rust path `nexum::host::*` | -> **Upgrading from 0.1?** See the [Migration Guide](migration/0.1-to-0.2.md) for the full rename table (`web3:runtime` → `nexum:host`, `csn` → `chain`, `msg` → `messaging`, `headless-module` → `event-module`, etc.), the per-interface typed error model over the shared `fault` vocabulary, and the manifest-driven capability negotiation introduced in 0.2. +An engine implements `nexum:host` so that modules built against `nexum:host` can run on it. The reference engine ships as two crates: the `nexum-runtime` library (embeddable, no CLI surface) and the `nexum` binary in `nexum/crates/nexum-cli`. A Rust embedder constructs an `EngineConfig` in code and calls `nexum_runtime::bootstrap::run_from_config`; see `nexum/crates/nexum-runtime/examples/embed.rs`. ## Architecture ```mermaid flowchart TB - disc["Module Discovery\nStatic · ENS · On-chain Registry"] --> mm + disc["Module Discovery\nStatic (0.2) · ENS · On-chain Registry (0.3)"] --> mm subgraph nexum["Nexum Runtime"] mm["Module Manager\nLoad → Init → Run → Restart → Dead"] @@ -36,7 +30,7 @@ flowchart TB subgraph host["Host API - WIT Interfaces"] uni["nexum:host\nchain · identity · local-store · remote-store · messaging · logging"] - ext["shepherd:cow\ncow-api"] + ext["videre:venue\nvenue adapters"] end subgraph back["Backends"] @@ -62,8 +56,8 @@ flowchart TB - **Component Model from day 1** - WIT-defined API contract; structural sandboxing (no filesystem, no ambient network); multi-language guests. - **Declarative subscriptions** - modules declare events in their manifest; the runtime wires sources. -- **Transactional state** - per-event all-or-nothing semantics; commit on success, rollback on trap. -- **Content-addressed distribution** - modules are fetched by hash (Swarm, IPFS, OCI, HTTPS); integrity always verified. +- **Durable state** - each local-store write is its own fsync-durable committed transaction; state survives traps and restarts. +- **Content-addressed distribution** - modules are fetched by hash; integrity always verified. - **Self-hosted** - no centralised dependency; operator runs their own node. ## The Six Primitives @@ -79,49 +73,24 @@ Every module has access to six orthogonal capabilities through the `nexum:host` | **Messaging** | `messaging` | Decentralised pub/sub messaging | Topic-based | Waku | | **Logging** | `logging` | Diagnostic output | Per-module | tracing | -These primitives are orthogonal: - -- **Chain** is the source of truth - the blockchain consensus state. Modules read chain state and (indirectly) write to it via order submission or transactions. -- **Identity** is cryptographic identity - key management and signing. The `chain` host implementation depends on `identity` internally: signing RPC methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) delegate to the identity backend. Modules can also import `identity` directly for raw signing operations. -- **Local Store** is the module's private scratchpad - fast, local, scoped to one module on one device. Does not replicate. -- **Remote Store** is shared persistent content - content-addressed, decentralised, survives independent of any device. Any module on any device can read what another module wrote. -- **Messaging** is real-time communication - ephemeral pub/sub messages between modules, devices, or users. Transient and topic-based. -- **Logging** is diagnostics - one-way output for debugging and monitoring. Not a data channel. +The `chain` host implementation depends on `identity`: signing RPC methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) delegate to the identity backend. Modules may also import `identity` directly for raw signing. ## Additive 0.2 Capabilities -In addition to the six core primitives, 0.2 introduces one optional capability that modules can declare in their manifest: +Beyond the six core primitives, 0.2 adds one optional capability modules declare in their manifest: -- **`http`** - allowlisted outbound HTTP via the standard `wasi:http/outgoing-handler` interface, gated by a `[capabilities.http].allow` domain list. The capability name lives in the manifest; the wire surface is plain wasi:http. The host MUST enforce the allowlist on every outgoing request: an off-list host is denied before any connection is made. The SDK's `http::fetch` helper wraps the interface for Rust guests. This replaces the 0.1 anti-pattern of tunnelling notifications through Waku. +- **`http`** - allowlisted outbound HTTP via `wasi:http/outgoing-handler`, gated by a `[capabilities.http].allow` domain list. The host enforces the allowlist on every request: an off-list host is denied before any connection is made. The SDK's `http::fetch` helper wraps the interface. -Time and secure randomness are WASI concerns rather than Nexum capabilities: `wasi:clocks` and `wasi:random` are linked into every module store ambiently. +Time and secure randomness are WASI concerns: `wasi:clocks` and `wasi:random` are linked into every module store ambiently. -0.2 also publishes (but does not yet host) the experimental **`query-module`** world for request/response modules (wallet rule evaluators, signature validators, pricing oracles). The WIT is stable enough to target with `MockHost` tests; production host support lands in 0.3. See the migration guide for the full WIT. +0.2 also publishes (but does not host) the experimental **`query-module`** world for request/response modules. The WIT is provisional and may change without a major bump; it is a target for mock-host tests only. ## WIT Worlds -The WIT is split into layered packages. The universal layer (`nexum:host`) provides blockchain-agnostic capabilities. Domain extensions (e.g. `shepherd:cow`) add protocol-specific interfaces. - -```mermaid -graph TB - subgraph l3["Layer 3 - Domain Extensions"] - cow["shepherd:cow\ncow-api"] - other["future:domain\nvault · strategy · …"] - end - - subgraph l1["Layer 1 - Universal Runtime"] - pkg["nexum:host"] - ifaces["chain · identity · local-store · remote-store · messaging · logging"] - exports["Exports: init · on-event"] - end +The WIT is layered. The universal `nexum:host` package provides blockchain-agnostic capabilities; domain packages layer on top. - cow -->|include event-module| l1 - other -->|include event-module| l1 ``` - -``` -// Universal layer - any platform, any blockchain app -package nexum:host@0.2.0 +package nexum:host@0.1.0 world event-module { import chain - consensus access (JSON-RPC passthrough) @@ -129,28 +98,18 @@ world event-module { import local-store - local key-value persistence import remote-store - decentralised storage (Swarm) import messaging - decentralised messaging (Waku) - import logging - log (trace/debug/info/warn/error) - - export init(config) - called once on load - export on_event(event) - called per subscribed event (block, logs, tick, message) -} + import logging - log (trace/debug/info/warn/error) -// CoW Protocol extension -package shepherd:cow@0.2.0 - -world shepherd { - include event-module - import cow-api - CoW Protocol REST API + order submission + export init(config) - called once on load + export on_event(event) - called per subscribed event (block, logs, tick, message) } ``` -The `event-module` world imports **six** interfaces - chain, identity, local-store, remote-store, messaging, logging. The 0.1 WIT framing claimed six primitives but only actually imported five; 0.2 brings `identity` into the world definition so the contract matches the documentation. - -The world imports no WASI interfaces. `wasi:clocks` and `wasi:random` are linked into every store ambiently, and modules that declare the `http` capability additionally import `wasi:http`; all other I/O is mediated through host interfaces. The `chain` interface exposes a single generic `request` function (plus an additive `request-batch` in 0.2) - the SDK implements alloy's `Transport` trait on top of it, giving modules the full alloy `Provider` API (80+ methods) with zero WIT churn. +The `event-module` world imports no WASI interfaces. `wasi:clocks` and `wasi:random` are linked ambiently, and modules that declare the `http` capability additionally import `wasi:http`. The `chain` interface exposes a single generic `request` function (plus an additive `request-batch`); the SDK implements alloy's `Transport` on top of it, giving modules the full alloy `Provider` API with zero WIT churn. -> Design rationale: [07-rpc-namespace-design.md](07-rpc-namespace-design.md) | Platform generalisation: [08-platform-generalisation.md](08-platform-generalisation.md) +CoW Protocol support is two packages. `shepherd:cow@0.1.0` carries `cow-events`, the canonical decoded on-chain event enum (topic-0 hashes for `ConditionalOrderCreated`, `ConditionalOrderRemoved`, `OrderPlacement`) that keepers and manifests are parity-tested against. Order submission is the `videre:venue@0.1.0` venue-adapter contract: a keeper drives venues through `videre:venue/client` by name, and each installed adapter component exports the provider face for one venue (the CoW venue is the `cow-venue` crate). See doc 08. --> Full WIT definition: [01-runtime-environment.md](01-runtime-environment.md) +> Design rationale: [07-rpc-namespace-design.md](07-rpc-namespace-design.md) | Platform generalisation and the venue layer: [08-platform-generalisation.md](08-platform-generalisation.md) | Full WIT: [01-runtime-environment.md](01-runtime-environment.md) ## Technology Stack @@ -158,7 +117,7 @@ The world imports no WASI interfaces. `wasi:clocks` and `wasi:random` are linked |---------|--------|---------| | Language | Rust | 1.90+ | | WASM runtime | wasmtime (Component Model) | 45.x | -| API contract | WIT (`nexum:host@0.2.0`, `shepherd:cow@0.2.0`) | - | +| API contract | WIT (`nexum:host@0.1.0`) | - | | Guest bindings | wit-bindgen | 0.57.x | | Async | Tokio | - | | Ethereum RPC | alloy | 1.5.x | @@ -179,9 +138,6 @@ name = "twap-monitor" version = "0.3.0" component = "sha256:9f86d081…" # content hash of module.wasm -[chains] -required = [42161] # must have RPC for these chains - [capabilities] required = ["chain", "local-store", "logging"] optional = ["messaging", "remote-store"] @@ -191,29 +147,20 @@ kind = "block" chain_id = 42161 [config] -cow_api_url = "https://api.cow.fi/arbitrum" -slippage_bps = 50 # integers stay integers in 0.2 +slippage_bps = 50 # integers stay integers ``` -The manifest declares identity, chain requirements, event subscriptions, capability grants, and typed module config - everything the runtime needs to load and run the module. In 0.2, `[capabilities]` is the canonical place to declare what host primitives a module needs; the engine cross-checks the component's WIT imports against `required` + `optional` at boot (link-time) and refuses to instantiate a module that imports an undeclared capability. Omitting `[capabilities]` falls back to "all imports required" with a deprecation warning. +The manifest declares chain requirements, event subscriptions, capability grants, and typed module config. `[capabilities]` is the canonical place to declare needed host primitives; the engine cross-checks the component's WIT imports against `required` + `optional` at boot (link-time) and refuses to instantiate a module that imports an undeclared capability. Omitting `[capabilities]` falls back to "all imports required" with a deprecation warning. -> Per-module resource caps (`[module.resources]`: `max_memory_bytes`, `max_fuel_per_event`, `max_state_bytes`) are **not in 0.2 scope** - the engine uses global defaults (`DEFAULT_FUEL_PER_EVENT = 1B`, `DEFAULT_MEMORY_LIMIT = 64 MiB`). Per-module overrides via the manifest are a future direction; today, an operator who needs different caps changes the global defaults at build time. The `optional` trap-stub fallback for absent host imports is also deferred to 0.3 - in 0.2, every linked import resolves to a real host function. +Resource caps are global in 0.2, set in `engine.toml` `[limits]` (`fuel_per_event`, default 1B; `memory_bytes`, default 64 MiB; `state_bytes`, default 50 MiB; `event_deadline_secs`, default 120). Per-module `[module.resources]` overrides are a 0.3 direction. -> Full spec: [02-modules-events-packaging.md](02-modules-events-packaging.md) ## Module Discovery -Three layers, from simplest to most decentralised: - -| Method | How it works | -|--------|-------------| -| **Static** | Operator points at a local manifest path | -| **ENS** | Module author sets ENS `contenthash` (ENSIP-7) to a Swarm/IPFS reference; runtime resolves and fetches | -| **On-chain registry** | Runtime watches contract events or ENS `TextChanged` events for module registrations | +0.2 loads modules from local filesystem paths listed in `engine.toml`. ENS `contenthash` resolution and on-chain registry discovery are a 0.3 design direction. -All methods converge: resolve content reference -> fetch via content store -> verify hash -> load. - --> Full design: [03-module-discovery.md](03-module-discovery.md) +-> Full design and current status: [03-module-discovery.md](03-module-discovery.md) ## Module Lifecycle @@ -233,12 +180,12 @@ stateDiagram-v2 Dead --> [*] ``` -- **Resolve**: fetch WASM by content hash from Swarm/IPFS/OCI/local. +- **Resolve**: fetch WASM by content hash (local in 0.2). - **Load**: compile `Component`, validate WIT world, create `InstancePre`. - **Init**: create `Store`, instantiate, call `init(config)`. - **Run**: dispatch subscribed events to `on_event`. Each call gets a fuel budget. -- **Restart**: on crash - exponential backoff (1s -> 5min cap), fresh `Store`, state persists. -- **Dead**: after N consecutive failures (poison pill) - requires manual intervention. +- **Restart**: on crash, exponential backoff (1s -> 5min cap), fresh `Store`, state persists. +- **Dead**: after N consecutive failures (poison pill), requires manual intervention. -> Full lifecycle: [02-modules-events-packaging.md](02-modules-events-packaging.md) @@ -247,105 +194,50 @@ stateDiagram-v2 - **Sources**: `block` (new heads via `eth_subscribe`), `chain-log` (filtered contract events), `cron` (schedule-based), `message` (Waku content topics). - **Shared subscriptions**: one block subscription per chain, fanned out to all subscribed modules. - **Dispatch**: concurrent across modules, sequential within a module (ordered delivery). -- **Declared in manifest**: `[[subscription]]` blocks - the runtime wires sources, not the module. +- **Declared in manifest**: `[[subscription]]` blocks; the runtime wires sources. -> Full design: [02-modules-events-packaging.md](02-modules-events-packaging.md) ## Local Store - **Backend**: redb (pure Rust, ACID, MVCC, crash-safe). -- **Isolation**: one database file per module; modules cannot access each other's state. -- **Transactions**: each `on_event` runs in an implicit write transaction - commit on success, rollback on failure. -- **Survives restarts**: state is external to WASM instance. -- **Size enforcement**: `max_state_bytes` from manifest, enforced host-side. -- **Prefix scanning**: `list-keys(prefix)` for namespaced key organisation. +- **Isolation**: modules cannot access each other's state. +- **Transactions**: each store call commits its own redb transaction; there is no per-event atomic rollback. +- **Survives restarts**: state is external to the WASM instance. +- **Isolation**: single redb file, 32-byte `keccak256(module_name)` key prefix (ADR-0003). +- **Size enforcement**: `[limits].state_bytes` quota, enforced host-side. +- **Prefix scanning**: `list-keys(prefix)` for namespaced keys. -> Full design: [04-state-store.md](04-state-store.md) ## SDK -The SDK ships as two crate pairs: `nexum-sdk`, the generic module-author SDK (host trait seam, bind macro, chain / config / address helpers, wasi:http `http::fetch`, tracing facade) with `nexum-sdk-test` providing the generic mock-host surface, and `shepherd-sdk`, the CoW-domain layer (cow-api trait, order bridging, revert decoding) with `shepherd-sdk-test` providing the CoW mock host. Modules that never touch the orderbook depend only on the nexum pair. See [ADR-0009](adr/0009-host-trait-surface.md) for the shipped host-trait seam that replaces the proc-macro design described in earlier drafts of doc 05. - -| Crate | Provides | -|-------|----------| -| `nexum-sdk` | `host::{ChainHost, LocalStoreHost, LoggingHost, Host}` - per-capability traits + supertrait, the seam modules implement against | -| | `Fault` + the `HostFault` trait - the shared failure vocabulary and per-interface typed errors (`ChainError`) with `?` support | -| | `chain::{eth_call_params, parse_eth_call_result}` + `chain::chainlink` - JSON-RPC plumbing helpers | -| | `config` / `address` - config-table lookups, decimal scaling, address parsing | -| | `http::{fetch, Fetch, FetchError, FetchOptions}` - allowlisted outbound HTTP over wasi:http on the standard `http` crate's `Request` / `Response` types | -| | `tracing` + `bind_host_via_wit_bindgen!` - guest tracing facade and the per-module adapter macro | -| | `prelude::*` - alloy primitives in one import | -| `shepherd-sdk` | `cow::{CowApiHost, CowHost}` - the cow-api trait and orderbook host bound | -| | `cow::{order, composable, error}` - CoW Protocol bridging (`gpv2_to_order_data`, `PollOutcome`, `decode_revert_hex`, `RetryAction`, `classify_api_error`) | -| | `bind_cow_host_via_wit_bindgen!` - the CoW layering of the generic adapter macro | -| | `prelude::*` - cowprotocol order / signing / orderbook surface in one import | -| `nexum-sdk-test` | `MockHost` + per-trait `MockChain` / `MockLocalStore` / `MockLogging` + `capture_tracing` for native-Rust strategy tests | -| `shepherd-sdk-test` | CoW `MockHost` + `MockCowApi`, composing the `nexum-sdk-test` mocks | - -Future direction (not in 0.2): a `#[nexum::module]` / `#[shepherd::module]` proc macro that subsumes the `wit_bindgen::generate!` + `WitBindgenHost` adapter boilerplate, a typed `TypedState` / `Signer` / `Cow` API client, alloy `Provider` injection via `HostTransport`, and filling out `nexum-sdk` into the full universal SDK for non-CoW modules. None of those land in 0.2. - -The operator CLI is the `nexum` binary itself (`cargo run -p nexum-cli`); a separate `cargo nexum` subcommand for module authors (new / build / package / publish / check / migrate) is future direction, not in 0.2 scope. Today modules are built with `cargo build --target wasm32-wasip2 --release`. +The guest SDK ships as `nexum-sdk` (the generic module SDK: host-trait seam, chain/config/address helpers, `http::fetch`, tracing facade) with `nexum-sdk-test` for the mock-host surface, and `videre-sdk` (the venue and keeper SDK: the `venue-adapter` export trait and the typed venue client) with `videre-test`. Modules are built with `cargo build --target wasm32-wasip2 --release`. The operator CLI is the `nexum` binary itself. -Multi-language support: module authors can use Rust, C/C++, Go, JavaScript, or Python - all compile to valid components against the same WIT world via `wit-bindgen`. The SDK is a Rust ergonomics layer on top of the WIT contract; non-Rust authors target the WIT directly. +Multi-language support: authors can target the WIT world directly from Rust, C/C++, Go, JavaScript, or Python via `wit-bindgen`. The SDK is a Rust ergonomics layer. --> Full design: [05-sdk-design.md](05-sdk-design.md) | M3 architectural decision: [ADR-0009](adr/0009-host-trait-surface.md) - -`nexum-sdk-test` / `shepherd-sdk-test` above are for **module business logic** - no wasm, no engine crate. Testing the *engine* itself (supervision, dispatch, capability wiring, reconnect) is a different, wasm-backed surface: see [testing-runtime-harness.md](testing-runtime-harness.md). +-> Full design: [05-sdk-design.md](05-sdk-design.md) | Host-trait seam: [ADR-0009](adr/0009-host-trait-surface.md) ## Production Hardening -### Resource Enforcement - | Resource | Mechanism | On breach | |----------|-----------|-----------| -| CPU (deterministic) | Fuel | Trap -> rollback -> restart | -| CPU (wall-clock) | Epoch interruption | Yield to Tokio | +| CPU (deterministic) | Fuel | Trap -> restart | +| CPU (wall-clock) | Epoch interruption + dispatch deadline | Yield / abort dispatch | | Memory | `ResourceLimiter` | `memory.grow` denied | | Storage | Host-side tracking | `local-store::set` returns `fault.invalid-input` | -### RPC Resilience - -Tower layer stack per chain: timeout -> retry (exponential + jitter) -> rate limit -> fallback endpoint. WebSocket subscriptions auto-reconnect with missed-block backfill. - -### Error Model +Each interface declares its own typed error over a shared payload-bearing `fault` vocabulary (`unsupported`, `unavailable`, `denied`, `rate-limited`, `timeout`, `invalid-input`, `internal`). Interfaces with nothing to add report `fault` directly; `chain-error` embeds `fault` and adds an `rpc` case. See [ADR-0011](adr/0011-per-interface-typed-errors.md). -In 0.2 each interface declares its own typed error and they share one payload-bearing `fault` vocabulary for the cross-domain cases. `fault` has seven cases: `unsupported(string)`, `unavailable(string)`, `denied(string)`, `rate-limited(rate-limit)`, `timeout`, `invalid-input(string)`, and `internal(string)`. Interfaces with nothing to add report `fault` directly (identity, local-store, remote-store, messaging, and the module exports); a richer interface embeds `fault` as one case of its own variant and adds the cases only it needs (`chain-error` adds an `rpc` case carrying the node code and decoded revert bytes). Modules match on the typed variant for retry/backoff decisions; the per-protocol error types from 0.1 (`json-rpc-error`, `msg-error`, `store-error`, `api-error`) are gone. See [ADR-0011](adr/0011-per-interface-typed-errors.md) for the model and the [migration guide](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder mapping. - -### Observability - -| Signal | Stack | Endpoint | -|--------|-------|----------| -| Logs | `tracing` -> JSON | stdout | -| Metrics | `metrics` -> Prometheus | `:9100/metrics` (default; see `docs/production.md`) | - -Metrics cover three groups: runtime-level (modules loaded/dead), per-module (events, latency, fuel, restarts, state usage), per-chain RPC (requests, errors, fallbacks, blocks behind). Liveness is signalled by the metrics scrape (`/metrics` returns 200 iff the engine is running and the Prometheus exporter is up) plus the structured `tracing` JSON on stdout. A dedicated `:8080/health` JSON endpoint with a per-module table is a future direction, not in 0.2 scope - operators today scrape `/metrics` and inspect the JSON log stream. +Observability: `tracing` JSON to stdout; a Prometheus exporter on `127.0.0.1:9100/metrics` when `[engine.metrics]` is enabled (disabled by default). -> Full design: [06-production-hardening.md](06-production-hardening.md) ## Platform Generalisation -Nexum is **designed** to be portable to mobile and browser hosts: the WIT contract is the universal interface and any host that implements it can run modules unchanged. The **0.2 reference runtime ships server-only** - a Rust/Tokio/wasmtime binary. The mobile, WebView, and super-app targets remain on the roadmap and live in the docs as architectural direction, not shipping artifacts. - -| Platform | WASM Engine | Local Store | RPC Backend | Status | -|----------|-------------|-------------|-------------|--------| -| **Server** (reference) | wasmtime | redb | alloy provider | **Shipping in 0.2** | -| **Mobile** (Flutter/Dart) | wasmtime C API / wasm3 | SQLite | HTTP client | Planned - see roadmap | -| **WebView** | Browser engine + `jco` | IndexedDB | JS bridge / wallet | Planned - see roadmap | -| **Super app** | All of the above | SQLite | HTTP + wallet | Planned - see roadmap | +The `nexum:host` WIT contract is host-portable: any host implementing it can run modules unchanged. The 0.2 reference runtime is server-only (Rust/Tokio/wasmtime). Mobile, WebView, and super-app targets are architectural direction. -The mobile/wallet host story - including the experimental `query-module` world's production support, the C ABI for non-Rust embedders, and the `nexum-host` embedder facade - is on the 0.3 roadmap, conditional on a named design partner. A minimal Rust embedding path already exists today via the `nexum-runtime` library entrypoint, with the richer facade remaining a 0.3 direction. - --> Full design (and the design rationale for each target): [08-platform-generalisation.md](08-platform-generalisation.md) - -## Grant Milestones - -| # | Milestone | Effort | Key Deliverables | -|---|-----------|--------|------------------| -| 1 | Core Runtime & Event System | 120h | wasmtime Component Model host, WIT interfaces, event sources, redb local store, CLI | -| 2 | TWAP & Ethflow Modules | 100h | TWAP monitor, Ethflow monitor, ComposableCoW contract mods | -| 3 | SDK & Developer Experience | 60h | `shepherd-sdk` + `shepherd-sdk-test` crates (host-trait seam per ADR-0009), example modules, tutorial, docs | -| 4 | Production Hardening | 60h | Resource limits, restart policy, logging, metrics, health checks | -| 5 | Multi-Chain & Deployment | 40h | Multi-chain config, Docker image, deployment docs | +-> Full design and the venue layer: [08-platform-generalisation.md](08-platform-generalisation.md) ## Repository Structure @@ -353,32 +245,30 @@ The mobile/wallet host story - including the experimental `query-module` world's shepherd/ ├── crates/ │ ├── nexum-runtime/ Core WASM host (server) library: event system, local store, bootstrap -│ ├── nexum-cli/ The `nexum` binary: clap CLI entry point over the runtime library -│ ├── nexum-sdk/ Generic guest SDK: host-trait seam, Fault, chain/config/address helpers, wasi:http fetch, tracing facade (ADR-0009) -│ ├── nexum-sdk-test/ Generic mock host (MockChain / MockLocalStore / MockLogging) for strategy tests -│ ├── shepherd-sdk/ CoW-domain SDK: cow-api trait + CoW Protocol helpers on top of nexum-sdk -│ ├── shepherd-sdk-test/ CoW mock host (MockCowApi + composed MockHost) for strategy tests -│ └── shepherd-backtest/ Backtest harness against captured chain fixtures +│ ├── nexum-cli/ The `nexum` binary: clap CLI over the runtime library +│ ├── nexum-sdk/ Generic guest SDK: host-trait seam, chain/config/address helpers, wasi:http fetch +│ ├── nexum-sdk-test/ Generic mock host for module tests +│ ├── videre-sdk/ Venue + keeper SDK: venue-adapter export trait, typed venue client +│ ├── videre-host/ Host-side venue registry + status watch +│ ├── videre-test/ Venue/keeper test surface +│ └── cow-venue/ The CoW venue: order body types + IntentBody codec ├── modules/ │ ├── twap-monitor/ TWAP order monitoring module │ ├── ethflow-watcher/ Ethflow order monitoring module -│ └── examples/ price-alert, balance-tracker, stop-loss, http-probe reference modules +│ └── examples/ reference modules (price-alert, balance-tracker, http-probe, echo-*) ├── wit/ │ ├── nexum-host/ Universal WIT package (chain, identity, local-store, remote-store, messaging, logging) -│ └── shepherd-cow/ CoW Protocol WIT package (cow-api, shepherd) +│ ├── shepherd-cow/ CoW event enum (cow-events) +│ └── videre-venue/ Venue-adapter contract (client + adapter faces) ├── Dockerfile ├── docker-compose.yml └── docs/ - ├── 00-overview.md - ├── 01-runtime-environment.md … 08-platform-generalisation.md - ├── adr/ ADR-0001 … ADR-0009 (canonical architectural decisions) + ├── 00-overview.md … 08-platform-generalisation.md + ├── adr/ Architectural decision records ├── deployment/ Docker + Prometheus operator config ├── diagrams/ Mermaid diagrams + reference captions - ├── operations/ Runbooks, E2E reports, load reports, baselines + ├── operations/ Runbooks ├── production.md Operator handbook - ├── sdk.md Module-author entry point (shipped SDK reference) - ├── tutorial-first-module.md - └── migration/0.1-to-0.2.md + ├── sdk.md Module-author entry point + └── tutorial-first-module.md ``` - -The SDK split is in place: `nexum-sdk` carries the universal surface and `shepherd-sdk` layers the CoW domain on top, with no re-export between them. Shipping a `cargo-nexum` subcommand for module authors remains future direction. diff --git a/docs/01-runtime-environment.md b/docs/01-runtime-environment.md index 572e2898..250298f9 100755 --- a/docs/01-runtime-environment.md +++ b/docs/01-runtime-environment.md @@ -2,54 +2,19 @@ ## Version Target -**wasmtime 45.x** (latest stable as of Feb 2026). +**wasmtime 45.x**, requiring **Rust 1.90.0+**. Guest bindings pin `wit-bindgen` 0.57.x. -- Release cadence: new major on the 20th of each month. -- LTS every 12th version (24 months support). Nearest LTS: v36. -- Requires **Rust 1.90.0+**. -- Repo: https://github.com/bytecodealliance/wasmtime +## Component Model -## Why wasmtime +The engine targets the Component Model, not raw core modules, for: -| Criterion | wasmtime | wasmer | wasm3 | -|-----------|----------|--------|-------| -| Rust-native embedding | First-class | Yes | C FFI | -| Async host functions | Yes | No | No | -| Component Model / WASI | Full | Partial | No | -| Fuel / epoch metering | Both | Fuel only | Injection | -| Production users | Fastly, Fermyon, Cloudflare, Zed | General | Embedded | -| Sandboxing | Proven | Similar | Similar | +- **Structural sandboxing.** A component compiled against a WIT world with no filesystem import cannot access the filesystem: enforced at the type level, not by omission of host functions. +- **Type-safe contract.** The WIT definition is the API spec; host and guest get generated bindings (`wasmtime::component::bindgen!`, `wit_bindgen::generate!`). +- **Resource types.** Opaque handles with lifecycle management via `ResourceTable`. +- **Multi-language guests.** Rust, C/C++, Go, JavaScript, Python all produce valid components against the same WIT world. +- **No WASI required.** The pure `nexum:host` world imports exactly the host APIs; zero WASI imports means zero implicit capabilities. -## Decision: Component Model from Day 1 - -### Rationale - -The Component Model is **production-viable in wasmtime 45** and gives us critical advantages over raw core modules: - -1. **Structural sandboxing.** A component compiled against a WIT world with no filesystem import literally *cannot* access the filesystem - enforced at the type level, not just by omission of host functions. This is stronger than core module sandboxing where imports are stringly-typed. - -2. **Type-safe API contract.** The WIT definition *is* the API spec. Both host and guest get generated bindings (`wasmtime::component::bindgen!` on the host, `wit_bindgen::generate!` on the guest). No manual ABI wrangling, no serialisation disagreements. - -3. **Resource types.** Opaque handles with lifecycle management (constructors, methods, destructors via `ResourceTable`). Ideal for subscription handles, RPC connections, etc. - -4. **Multi-language guests from day 1.** Module authors can use Rust, C/C++, Go, JavaScript (ComponentizeJS), or Python (componentize-py) - all producing valid components against the same WIT world. This dramatically lowers the barrier for community modules. - -5. **No WASI required.** The Component Model and WASI are architecturally separate. We define a pure `nexum:host` world with exactly our host APIs. Zero WASI imports means zero implicit capabilities. - -6. **Acceptable overhead.** The canonical ABI adds marshalling for strings/lists (memory copy across boundary), but for a plugin system with coarse-grained calls this is negligible. `InstancePre` front-loads validation costs. - -### What we give up - -- **Tooling churn.** `wit-bindgen` (v0.57) and `cargo-component` (v0.21) are functional but APIs are not yet stable. Pin versions in the SDK. -- **Native async Component Model** (`stream`, `future`) is still evolving. We use basic async host functions (`func_wrap_async`) which are stable. - -### Risk assessment - -| Aspect | Risk | -|--------|------| -| `bindgen!` macro, custom worlds, resource types | Low - stable, well-documented | -| `wit-bindgen` guest bindings | Medium - API churn between versions | -| Component Model native async (streams/futures) | High - not needed yet, avoid for now | +The engine uses wasmtime's basic async host functions (`func_wrap_async`), not the still-evolving Component Model native async (`stream`, `future`). ## Core Concepts @@ -97,16 +62,16 @@ let pre = linker.instantiate_pre(&component)?; let bindings = EventModule::instantiate_pre(&mut store, &pre)?; ``` -## WIT Worlds: Universal and CoW-Specific +## WIT Worlds -Nexum uses a two-layer WIT architecture. The **universal** package `nexum:host` defines platform-agnostic interfaces and the `event-module` world. The **CoW-specific** package `shepherd:cow` extends it with CoW Protocol interfaces and the `shepherd` world. +The **universal** package `nexum:host` defines platform-agnostic interfaces and the `event-module` world. CoW Protocol support layers on top: `shepherd:cow` carries the `cow-events` enum, and order submission is the `videre:venue` venue-adapter contract (below). -### Universal Package: `nexum:host@0.2.0` +### Universal Package: `nexum:host@0.1.0` The `nexum:host` package is the single source of truth for the universal host-guest contract. It defines a custom world with **no WASI imports**: ```wit -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface types { type chain-id = u64; @@ -301,89 +266,46 @@ world event-module { } ``` -In addition to the six core imports, 0.2 publishes one additive optional capability - `http` (allowlisted outbound HTTP) - which modules declare in their `module.toml` `[capabilities]` section. The declaration is a manifest concern only: the capability is serviced by the standard `wasi:http/outgoing-handler` interface, not a `nexum:host` one. The migration guide carries the details. 0.2 also publishes the experimental **`query-module`** world for request/response modules; the WIT is stable but no host implementation ships in 0.2, so it's a target for `MockHost` testing only. +In addition to the six core imports, 0.2 publishes one additive optional capability - `http` (allowlisted outbound HTTP) - which modules declare in their `module.toml` `[capabilities]` section. The declaration is a manifest concern only: the capability is serviced by the standard `wasi:http/outgoing-handler` interface, not a `nexum:host` one. 0.2 also publishes the experimental **`query-module`** world for request/response modules; the WIT is stable but no host implementation ships in 0.2, so it's a target for `MockHost` testing only. -### CoW-Specific Package: `shepherd:cow@0.2.0` +### CoW Protocol packages -The `shepherd:cow` package extends the universal world with CoW Protocol interfaces. In 0.2 the two 0.1 interfaces (`cow` + `order`) merge into a single `cow-api` interface to eliminate the `cow::cow::request` triple-stutter: +`shepherd:cow@0.1.0` carries a single interface, `cow-events`: the canonical decoded on-chain event enum whose variants pin each CoW Solidity signature and its topic-0 hash. Keeper constants and module manifests are parity-tested against it. ```wit -package shepherd:cow@0.2.0; +package shepherd:cow@0.1.0; -interface cow-api { - use nexum:host/types.{chain-id, fault}; - - /// A non-2xx reply with no typed rejection envelope; `body` is raw text. - record http-failure { status: u16, body: option } - - /// A typed orderbook rejection, parsed host-side from `{errorType, description}`. - record order-rejection { status: u16, error-type: string, description: string, data: option } - - /// A cow-api call failure: a shared host `fault`, a raw HTTP failure, - /// or a typed order rejection. - variant cow-api-error { - fault(fault), - http(http-failure), - rejected(order-rejection), +interface cow-events { + enum cow-event { + conditional-order-created, // ComposableCoW registration + conditional-order-removed, // ComposableCoW v2 removal + order-placement, // CoWSwapOnchainOrders (EthFlow) } - - /// HTTP-style request to the CoW Protocol API. - /// - /// The host routes to the correct CoW API base URL for the given chain. - /// `method`: "GET" | "POST" | "PUT" | "DELETE" - /// `path`: relative API path, e.g. "/api/v1/orders" - /// `body`: optional JSON request body - request: func( - chain-id: chain-id, - method: string, - path: string, - body: option, - ) -> result; - - /// Submit a serialised order to the CoW Protocol. - /// (Replaces the 0.1 `order::submit` interface.) - submit-order: func(chain-id: chain-id, order-data: list) - -> result; -} - -/// CoW Protocol module world. Extends the universal event-module -/// with CoW-specific imports. -world shepherd { - include nexum:host/event-module; - - import cow-api; } ``` +Order submission is not a host interface. It is the `videre:venue@0.1.0` venue-adapter contract: a keeper calls `videre:venue/client` (`quote` / `submit` / `observe` / `status` / `cancel`) naming a venue by string, and each installed adapter component exports the provider face for one venue over scoped transport only. The CoW venue is the `cow-venue` crate. See doc 08 for the venue layer. + ### Key properties - **Constrained WASI** - the WASI p2 surface linked into every store includes `wasi:clocks` and `wasi:random` ambiently; there is no filesystem grant and no inbound network. The only network path is allowlisted outbound HTTP through `wasi:http/outgoing-handler`, available to modules that declare the `http` capability in the manifest's `[capabilities]` section. The `wasi:sockets` bindings are linked as part of the p2 surface but stay inert because the WASI context grants no network. -- **All I/O through our interfaces** - RPC reads, identity/signing, CoW API, local-store, order submission, logging. +- **All I/O through host interfaces** - RPC reads, identity/signing, local-store, messaging, logging; venue submission through `videre:venue/client`. - **Generic JSON-RPC passthrough** - the `chain` interface exposes a single `request` function (plus an additive `request-batch`). The SDK implements alloy's `Transport` trait on top of it, giving modules the full alloy `Provider` API. See doc 07 for details. -- **Identity as a first-class primitive** - the `identity` interface provides key management and signing. The `chain` host implementation depends on `identity` internally: signing RPC methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) are intercepted and delegated to the identity backend. Modules can also import `identity` directly for `personal_sign`-style message signing, EIP-712 typed data signing, and listing accounts. (Raw-bytes signing, gated by an explicit capability, is on the 0.3 roadmap; the current `sign` MUST prepend the EIP-191 prefix.) -- **Per-interface typed errors over a shared `fault` vocabulary** - each interface declares its own error type; the cross-domain cases share one payload-bearing `fault` (`unsupported`, `unavailable`, `denied`, `rate-limited`, `timeout`, `invalid-input`, `internal`). Interfaces with nothing to add return `fault` directly (identity, local-store, remote-store, messaging, the module exports); `chain-error` embeds `fault` and adds an `rpc` case, `cow-api-error` adds `http` and `rejected`. The 0.1 per-protocol error types (`json-rpc-error`, `identity-error`, `msg-error`, `store-error`, `api-error`) are gone. Modules match on the typed variant for retry/backoff decisions. See ADR-0011. -- **`list` for raw bytes** - local-store values, order payloads, signatures, accounts, etc. The SDK provides typed wrappers. -- **Resource types** can be added later (e.g. subscription handles, cursor-based log iteration). -- **Two worlds in 0.2's reference runtime** - `nexum:host/event-module` for platform-agnostic modules; `shepherd:cow/shepherd` for CoW Protocol modules that need the `cow-api` import. The experimental `nexum:host/query-module` world is published but not yet hosted. +- **Identity as a first-class primitive** - the `identity` interface provides key management and signing. The `chain` host implementation depends on `identity`: signing RPC methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) are intercepted and delegated to the identity backend. Modules can also import `identity` directly for `personal_sign` message signing, EIP-712 typed data signing, and listing accounts. `sign` prepends the EIP-191 prefix; a raw-bytes signing primitive is a 0.3 direction. +- **Per-interface typed errors over a shared `fault` vocabulary** - each interface declares its own error type; the cross-domain cases share one payload-bearing `fault` (`unsupported`, `unavailable`, `denied`, `rate-limited`, `timeout`, `invalid-input`, `internal`). Interfaces with nothing to add return `fault` directly (identity, local-store, remote-store, messaging, the module exports); `chain-error` embeds `fault` and adds an `rpc` case. Modules match on the typed variant for retry/backoff decisions. See ADR-0011. +- **`list` for raw bytes** - local-store values, signatures, accounts, order bodies. The SDK provides typed wrappers. +- **Worlds** - `nexum:host/event-module` for automation modules; `videre:venue/venue-adapter` for venue adapter components. The experimental `nexum:host/query-module` world is published but not yet hosted. ## Host-Side Embedding -The host uses `wasmtime::component::bindgen!` to generate Rust traits from the WIT. For universal interfaces, the generated traits live under `nexum::host::`. For CoW-specific interfaces, they live under `shepherd::cow::`. +The host uses `wasmtime::component::bindgen!` to generate Rust traits from the WIT; the generated traits for universal interfaces live under `nexum::host::`. ```rust -// Universal event-module world wasmtime::component::bindgen!({ path: "wit/nexum-host", world: "event-module", async: true, }); - -// CoW-specific shepherd world (extends event-module) -wasmtime::component::bindgen!({ - path: "wit/shepherd-cow", - world: "shepherd", - async: true, -}); ``` ### Identity Host Trait @@ -498,101 +420,13 @@ impl nexum::host::local_store::Host for NexumHostState { } // ... } - -impl shepherd::cow::cow_api::Host for NexumHostState { - // CoW-specific host implementation - // ... -} ``` -See doc 07 for the full `chain` and `cow-api` host implementations, method allowlisting, and the `HostTransport` that bridges this to alloy's `Provider` API on the guest side. +See doc 07 for the full `chain` host implementation, method allowlisting, and the `HostTransport` that bridges it to alloy's `Provider` API on the guest side. ## Guest-Side (Module Author) Experience -> The two subsections below describe the **0.3+ macro-driven authoring model** (`#[nexum::module]` / `#[shepherd::module]`, alloy `RootProvider` injection, `TypedState`). It is future direction, not in 0.2 scope. In 0.2, modules ship today using the host-trait seam from [ADR-0009](adr/0009-host-trait-surface.md): a `strategy.rs` (pure logic against `&impl Host`) plus a `lib.rs` `WitBindgenHost` adapter that bridges to `wit-bindgen::generate!`. See [`sdk.md`](sdk.md) and the example modules under `modules/examples/` for the shipped pattern. - -### Universal modules (future direction; `nexum-sdk`) - -In the future direction, module authors targeting the universal `event-module` world would add the `nexum-sdk` crate and use the `#[nexum::module]` proc macro. Modules can access identity for signing operations - either indirectly through `chain` (signing RPC methods are handled transparently) or directly via the `identity` interface for raw signing: - -```rust -use nexum_sdk::prelude::*; - -#[nexum::module] -struct BlockLogger; - -impl BlockLogger { - fn init(config: Config) -> Result<()> { - info!("Block logger starting"); - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let block_num = provider.get_block_number().await?; - info!("New block: {block_num}"); - - TypedState::set("last_block", &block_num)?; - Ok(()) - } -} -``` - -### CoW Protocol modules (future direction; `shepherd-sdk` macro form) - -In the future direction, module authors targeting the CoW-specific `shepherd` world would add the `shepherd-sdk` crate and use the `#[shepherd::module]` proc macro. The macro provides **named event handlers** (`on_block`, `on_chain_logs`, `on_tick`, `on_message`) - it generates the `on_event` match dispatch, WIT export wrapper, and optional provider injection. Handlers can be `async fn` for natural `.await`: - -```rust -use shepherd_sdk::prelude::*; - -sol! { - function getTradeableOrderWithSignature( - address owner, bytes32 ctx, bytes32 orderHash - ) external view returns (bytes memory order, bytes memory signature); -} - -#[shepherd::module] -struct TwapMonitor; - -impl TwapMonitor { - fn init(config: Config) -> Result<()> { - info!("TWAP monitor starting"); - Ok(()) - } - - // Named handler - macro generates on_event match dispatch. - // provider is injected from block.chain_id. - // async fn - macro wraps in block_on (single-poll, zero overhead). - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - // Full alloy Provider API - natural .await - let block_num = provider.get_block_number().await?; - let balance = provider.get_balance(owner).latest().await?; - - // Typed contract calls with sol! + EthCall builder - let tx = TransactionRequest::default() - .to(contract) - .input(getTradeableOrderWithSignatureCall { - owner, ctx, orderHash: order_hash, - }.abi_encode().into()); - let result = provider.call(tx).latest().await?; - let decoded = getTradeableOrderWithSignatureCall::abi_decode_returns(&result)?; - - // CoW API via typed client - let cow = Cow::new(block.chain_id); - cow.submit_order(&order)?; - - // State persistence - TypedState::set("last_block", &block_num)?; - Ok(()) - } - - // Only define handlers for events you subscribe to. - // No on_chain_logs, on_tick, or on_message → those events are silently ignored. -} -``` - -Build with `cargo component build --release` (or `cargo build --target wasm32-wasip2` + `wasm-tools component new`). - -See doc 05 for the full macro design (named handlers, provider injection, escape hatch) and doc 07 for the `HostTransport` implementation and `provider()` constructor. +Modules ship using the host-trait seam from [ADR-0009](adr/0009-host-trait-surface.md): a `logic.rs` (`keeper.rs` in a keeper) of pure logic against `&impl Host`, plus a `lib.rs` `WitBindgenHost` adapter that bridges to `wit-bindgen::generate!`. Build with `cargo build --target wasm32-wasip2 --release`. See [`sdk.md`](sdk.md), doc 05, and the example modules under `modules/examples/`. ## Multi-Language Guest Support @@ -605,7 +439,7 @@ See doc 05 for the full macro design (named handlers, provider injection, escape | **Python** | componentize-py (CPython) | Maturing | | **C#** | `wit-bindgen-csharp` | Emerging | -All produce valid components against the same WIT worlds (`nexum:host/event-module` for universal, `shepherd:cow/shepherd` for CoW). +All produce valid components against the `nexum:host/event-module` world. ## Execution Metering @@ -624,17 +458,11 @@ Both are needed: fuel for correctness, epochs for liveness. ## Resource Limits -Implement `ResourceLimiter` to cap per-module: - -- **Memory growth** - target <10 MB default. -- **Table growth** - max entries. -- **Instance count** - max concurrent. - -Enforced synchronously on every `memory.grow` / `table.grow`. +A `ResourceLimiter` caps linear-memory growth per module store, enforced synchronously on every `memory.grow`. The cap is `[limits].memory_bytes` from `engine.toml` (default 64 MiB). Fuel, the per-dispatch wall-clock deadline, and the local-store byte quota are the other resolved caps (`fuel_per_event` 1B, `event_deadline_secs` 120, `state_bytes` 50 MiB); all live in `nexum/crates/nexum-runtime/src/engine_config.rs` and apply uniformly, per-module overrides being a 0.3 direction. ## Async Integration -All RPC and CoW API I/O is async (alloy / reqwest on the host). wasmtime bridges this: +All RPC and outbound I/O is async (alloy / reqwest on the host). wasmtime bridges this: - `Config::async_support(true)`. - Host functions registered with `func_wrap_async` (or via `async: true` in `bindgen!`). @@ -659,7 +487,7 @@ All RPC and CoW API I/O is async (alloy / reqwest on the host). wasmtime bridges |------------------|--------------------| | Runtime process | `Engine` (one, shared) | | Universal API contract | WIT world (`nexum:host/event-module`) | -| CoW API contract | WIT world (`shepherd:cow/shepherd`) | +| Venue adapter contract | WIT world (`videre:venue/venue-adapter`) | | Compiled module | `Component` (cached, thread-safe) | | Pre-validated module | `InstancePre` (linker + component) | | Running instance | `Store` + `Instance` | @@ -669,5 +497,5 @@ All RPC and CoW API I/O is async (alloy / reqwest on the host). wasmtime bridges | Per-call budget | Fuel | | Wall-clock fairness | Epoch interruption | | Memory/table caps | `ResourceLimiter` | -| Async RPC / CoW I/O | `func_wrap_async` + Tokio | +| Async RPC / outbound I/O | `func_wrap_async` + Tokio | | Persistent state | redb (per-module database file, via `local-store` interface host fns) | diff --git a/docs/02-modules-events-packaging.md b/docs/02-modules-events-packaging.md index cbbf122f..23ad7621 100755 --- a/docs/02-modules-events-packaging.md +++ b/docs/02-modules-events-packaging.md @@ -2,11 +2,11 @@ ## Module Package: the Nexum Module Bundle -A module is distributed as a **bundle** - a WASM component plus a manifest that declares its identity, event subscriptions, chain requirements, and resource limits. The manifest is the bridge between packaging, the event system, and the runtime lifecycle. +A module is distributed as a **bundle** - a WASM component plus a manifest that declares its identity, event subscriptions, chain requirements, and capability grants. The manifest is the bridge between packaging, the event system, and the runtime lifecycle. ### Manifest (`module.toml`) -Every module ships with a manifest. The file is named `module.toml` in 0.2 (was `module.toml` in earlier drafts; per [ADR-0001](adr/0001-engine-toml-separate-from-nexum-toml.md) the operator/module split is now explicit). +Every module ships with a manifest. The file is named `module.toml` in 0.2 (was `nexum.toml` in earlier drafts; per [ADR-0001](adr/0001-engine-toml-separate-from-nexum-toml.md) the operator/module split is now explicit). ```toml [module] @@ -54,13 +54,13 @@ enable_alerts = true # boolean stays boolean Key design points: -- **`component` is a content hash**, not a filename. The runtime resolves it via the content store (see below). (Was `wasm = ...` in 0.1 - see the migration guide.) +- **`component` is a content hash**, not a filename. The runtime resolves it via the content store (see below). - **`[[subscription]]` blocks are declarative.** The module doesn't set up its own subscriptions imperatively - the runtime reads the manifest and wires up event sources before calling `init`. The 0.1 spelling was `[[subscribe]]` with `type = ...`; 0.2 uses `[[subscription]]` with `kind = ...` because `type` is a reserved word in several binding languages. -- **`[capabilities]`** is new in 0.2 and now drives what the runtime links into the module's import space. See the migration guide for the full schema (including `[capabilities.http]` allowlists). A module that declares `http` imports the standard `wasi:http/outgoing-handler` interface - the SDK's `http::fetch` helper wraps it - and the host checks every outgoing request against the `[capabilities.http].allow` list; see `modules/examples/http-probe` for a complete example. +- **`[capabilities]`** is new in 0.2 and now drives what the runtime links into the module's import space. A module that declares `http` imports the standard `wasi:http/outgoing-handler` interface - the SDK's `http::fetch` helper wraps it - and the host checks every outgoing request against the `[capabilities.http].allow` list; see `nexum/modules/examples/http-probe` for a complete example. - **Chain ids are declared per-subscription**, not in a top-level `[chains]` table - each `[[subscription]]` names its own `chain_id`. If `engine.toml` has no `[chains.]` entry for a chain a subscription names, the engine bails at boot, before any events dispatch (fast, clear error). - **`config`** is opaque to the runtime. 0.2 keeps 0.1's stringly-typed shape (`list>`); the host flattens TOML scalars (numbers, booleans) to their string form on the way through. A typed `config-value` variant is on the 0.3 roadmap, bundled with the manifest-parser work. -> **Future direction (not in 0.2):** per-module resource caps via `[module.resources]` (`max_memory_bytes`, `max_fuel_per_event`, `max_state_bytes`), per-module restart policy via `[module.restart]`, and `optional`-import trap stubs that return `fault.unsupported` on call. The 0.2 engine enforces resource limits using global defaults (`DEFAULT_FUEL_PER_EVENT = 1B`, `DEFAULT_MEMORY_LIMIT = 64 MiB` from `crates/nexum-runtime/src/runtime/limits.rs`) and uses a global restart policy. Per-module overrides are on the 0.3 roadmap. +> Resource caps are engine-global in 0.2, set in `engine.toml` `[limits]` (`fuel_per_event`, default 1B; `memory_bytes`, default 64 MiB; `state_bytes`, default 50 MiB; `event_deadline_secs`, default 120), resolved in `nexum/crates/nexum-runtime/src/engine_config.rs`. Per-module `[module.resources]` overrides, per-module restart policy, and `optional`-import trap stubs are 0.3 directions. ### Bundle Format @@ -165,9 +165,9 @@ stateDiagram-v2 | State | Description | |-------|-------------| | **Resolve** | Content store resolves `component` hash to local path. Fail -> `Dead`. | -| **Load** | `Component::from_file`, create `InstancePre`. Validates that the component satisfies the target WIT world (`nexum:host/event-module` or `shepherd:cow/shepherd`). Installs trap stubs for capabilities the manifest declares `optional` but the host does not provide. Fail -> `Dead`. | -| **Init** | Create `Store`, instantiate, call `init(config)` inside an implicit write transaction (same semantics as `on_event` - commit on success, rollback on failure). Module sets up internal state. Fail -> `Restart` (might be transient). | -| **Run** | Runtime dispatches events to `on_event`. Each call gets a fuel budget. Module processes events and may call host imports (chain, local-store, identity, cow-api, etc.). | +| **Load** | `Component::from_file`, create `InstancePre`. Validates that the component satisfies the `nexum:host/event-module` world. Fail -> `Dead`. | +| **Init** | Create `Store`, instantiate, call `init(config)`. Each local-store write commits its own transaction; there is no per-call atomic rollback. Module sets up internal state. Fail -> `Restart` (might be transient). | +| **Run** | Runtime dispatches events to `on_event`. Each call gets a fuel budget. Module processes events and may call host imports (chain, local-store, identity, messaging, logging). | | **Restart** | After a trap or error. Backoff: 1s -> 2s -> 4s -> ... -> 5min cap. A fresh `Store` is created (clean memory), but **local-store data persists** (it's in redb, external to the WASM instance). | | **Dead** | After N consecutive failures (poison pill detection) or explicit operator shutdown. No further event dispatch. Requires manual intervention. | @@ -275,12 +275,12 @@ The runtime serialises event data via the canonical ABI (handled automatically b ## Updated WIT Worlds -The initial WIT in `01-runtime-environment.md` is extended to support the lifecycle and config. The architecture uses two packages: `nexum:host` for universal interfaces and `shepherd:cow` for CoW Protocol extensions. +The initial WIT in `01-runtime-environment.md` is extended to support the lifecycle and config. The universal package is `nexum:host`; CoW Protocol support layers on via `shepherd:cow` (the `cow-events` enum) and the `videre:venue` venue-adapter contract. -### Universal Package: `nexum:host@0.2.0` +### Universal Package: `nexum:host@0.1.0` ```wit -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface types { type chain-id = u64; @@ -409,61 +409,26 @@ world event-module { } ``` -### CoW-Specific Package: `shepherd:cow@0.2.0` +### CoW Protocol packages -```wit -package shepherd:cow@0.2.0; - -interface cow-api { - use nexum:host/types.{chain-id, fault}; - - /// A raw non-2xx reply, or a typed orderbook rejection parsed host-side. - record http-failure { status: u16, body: option } - record order-rejection { status: u16, error-type: string, description: string, data: option } - - /// A shared host `fault`, a raw HTTP failure, or a typed order rejection. - variant cow-api-error { fault(fault), http(http-failure), rejected(order-rejection) } - - /// HTTP-style request to the CoW Protocol API. - request: func( - chain-id: chain-id, - method: string, - path: string, - body: option, - ) -> result; - - /// Submit a serialised order. (Merged in from the 0.1 `order` interface.) - submit-order: func(chain-id: chain-id, order-data: list) - -> result; -} - -/// CoW Protocol module world - extends event-module with cow-api. -world shepherd { - include nexum:host/event-module; - - import cow-api; -} -``` +`shepherd:cow@0.1.0` carries the `cow-events` enum (canonical CoW on-chain event signatures and topic-0 hashes). Order submission is the `videre:venue@0.1.0` venue-adapter contract: a keeper drives venues through `videre:venue/client`, and each installed adapter component (the CoW venue is the `cow-venue` crate) exports the provider face for one venue. See doc 08. ## Putting It All Together Operator deploys a module: ``` -1. Operator adds entry to runtime config: +1. Operator adds entry to engine.toml: [[modules]] - manifest = "/var/nexum/twap-monitor/module.toml" + path = "/var/nexum/twap-monitor/twap_monitor.wasm" -2. Runtime reads manifest: - - Resolves component content hash → fetches from Swarm/local/OCI - - Verifies integrity (sha256 match) +2. Runtime reads the sibling module.toml and verifies sha256(module.wasm) + against the manifest component hash. 3. Runtime compiles Component, creates InstancePre: - - Validates component satisfies target world - (nexum:host/event-module or shepherd:cow/shepherd) - - Installs trap stubs for any [capabilities].optional imports the host doesn't provide - - Enforces resource limits from manifest + - Validates component satisfies the nexum:host/event-module world + - Cross-checks WIT imports against [capabilities] required + optional (link-time) 4. Runtime calls init(config): - Module receives [config] section as typed key-value pairs @@ -478,7 +443,8 @@ Operator deploys a module: Block 19_000_001 on Arbitrum → Router → twap-monitor's dispatch queue → Tokio task calls on_event(Event::Block(…)) - → Module calls chain::request (via alloy Provider), local-store get, cow-api submit-order + → Module calls chain::request (via alloy Provider), local-store get, + videre:venue/client submit → Returns Ok(()) - runtime logs success 7. On crash: diff --git a/docs/03-module-discovery.md b/docs/03-module-discovery.md index 349e601c..2e7cd042 100755 --- a/docs/03-module-discovery.md +++ b/docs/03-module-discovery.md @@ -1,279 +1,42 @@ # Module Discovery -Doc 02 defines how modules are packaged (bundle = `module.toml` + `module.wasm`) and how content is fetched by hash (pluggable content store). This document defines how the runtime **discovers which modules to load** - the layer above content resolution. +Doc 02 defines how modules are packaged (bundle = `module.toml` + `module.wasm`) and how content is fetched by hash. This document defines how the runtime **discovers which modules to load**. -Three discovery sources, from simplest to most decentralised: +## 0.2: static (local path) -```mermaid -flowchart TB - subgraph runtime["Nexum Runtime"] - subgraph discovery["Module Discovery"] - static["Static\n(local)"] - ens["ENS\n(name)"] - registry["Registry\n(contract)"] - end - subgraph content["Content Store (doc 02)\nSwarm / IPFS / OCI / local / HTTPS"] - end - static --> content - ens --> content - registry --> content - end -``` - -## 1. Static (local path) - -Operator points the runtime at a local manifest. No on-chain interaction. +The 0.2 engine loads modules from local filesystem paths listed in `engine.toml`. Each `[[modules]]` entry names the compiled component and, optionally, its manifest (defaulting to a sibling `module.toml`): ```toml [[modules]] -source = "static" +path = "/var/nexum/twap-monitor/twap_monitor.wasm" manifest = "/var/nexum/twap-monitor/module.toml" ``` -Use case: local development, air-gapped deployments, CI testing. - -## 2. ENS Name Resolution - -A module author publishes their bundle to Swarm (or IPFS) and associates it with an ENS name. The runtime resolves the name to a content reference, fetches the bundle, and loads it. +This is the whole of discovery in 0.2. Content-addressed resolution (Swarm / IPFS / OCI) and `[[content.sources]]` are not wired: `EngineConfig::modules` resolves a `(component.wasm, module.toml)` pair on disk, nothing more (see `nexum/crates/nexum-runtime/src/engine_config.rs`). -### How it works +## 0.3 direction: ENS and on-chain registry -ENS already has native support for content-addressed storage: - -- **`contenthash`** (ENSIP-7 / EIP-1577): binary field that encodes a protocol code + content hash. Swarm is protocol `0xe4`, IPFS is `0xe3`. This is the primary pointer to the bundle. -- **Text records** (ENSIP-5 / EIP-634): arbitrary key-value UTF-8 strings. Applications use reverse-domain keys to avoid collisions. - -A module author sets up their ENS name: - -``` -twap-monitor.shepherd.eth -├── contenthash → 0xe40101fa011b20{32-byte-keccak256} -│ (Swarm reference to the bundle) -├── text: shepherd.version → "0.2.0" -├── text: shepherd.chains → "42161,1" -└── text: shepherd.name → "twap-monitor" -``` +The remainder of this document is design contract for a future minor release, not shipped behaviour. It is retained because the WIT and manifest shapes are stable enough to build against. -The `contenthash` points to the full bundle on Swarm (a directory containing `module.toml` + `module.wasm`). Text records provide lightweight metadata the runtime can read without fetching the bundle - useful for filtering or display. +### ENS name resolution -### Runtime resolution flow +A module author publishes a bundle to Swarm (or IPFS) and points an ENS name at it. The runtime would resolve the name to a content reference, fetch the bundle, and load it. -```mermaid -sequenceDiagram - participant R as Nexum Runtime - participant ENS as ENS Registry - participant Resolver as Resolver Contract - participant Swarm as Content Store (Swarm) - - R->>ENS: 1. Resolve ENS name - ENS-->>R: Resolver contract address - R->>Resolver: 2. resolver.contenthash(namehash) - Resolver-->>R: Encoded content reference - R->>R: 3. Decode: protocol 0xe4 (Swarm) + keccak256 hash - R->>Swarm: 4. Fetch bundle from Swarm - Swarm-->>R: Bundle (module.toml + module.wasm) - R->>R: 5. Verify bundle integrity (sha256 of module.wasm matches manifest) - R->>R: 6. Load module via standard lifecycle (doc 02) -``` - -### Runtime config - -```toml -[[modules]] -source = "ens" -name = "twap-monitor.shepherd.eth" -chain_id = 1 # which chain to resolve ENS on -poll_interval = "5m" # check for updates - -[[modules]] -source = "ens" -name = "ethflow.shepherd.eth" -chain_id = 1 -poll_interval = "5m" -``` +- **`contenthash`** (ENSIP-7 / EIP-1577): binary field encoding a protocol code plus content hash (Swarm `0xe4`, IPFS `0xe3`). The primary pointer to the bundle. +- **Text records** (ENSIP-5 / EIP-634): lightweight metadata (version, chains, name) readable without fetching the bundle. -### Updates - -When the module author publishes a new version, they: -1. Upload the new bundle to Swarm → get new content hash -2. Update the ENS `contenthash` record - -The runtime detects the change on its next poll (or via event - see below), fetches the new bundle, and hot-reloads the module. - -## 3. On-Chain Registry (Contract Events) - -For fully autonomous discovery - the runtime watches a contract for registration events and auto-loads modules without operator intervention. - -### Option A: Dedicated registry contract - -A simple contract where module authors register their ENS name: - -```solidity -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity ^0.8.0; - -interface INexumRegistry { - event ModuleRegistered( - string indexed ensNameHash, - string ensName, - address indexed registrant - ); - event ModuleRemoved( - string indexed ensNameHash, - string ensName - ); - - function register(string calldata ensName) external; - function remove(string calldata ensName) external; -} -``` - -The runtime subscribes to `ModuleRegistered` events, resolves the ENS name from the event, and enters the ENS resolution flow above. - -### Option B: No ad-hoc registry - contracts self-declare via ENS - -This is the more decentralised approach. Instead of a central registry: - -1. **Any contract** can associate itself with a Nexum module by setting a text record on its own ENS name. -2. The runtime watches for `TextChanged` events on the ENS Public Resolver filtered to the `shepherd.module` key. - -For example, ComposableCoW (`composablecow.cow.eth`) sets: - -``` -composablecow.cow.eth -├── text: shepherd.module → "twap-monitor.shepherd.eth" -``` +Resolution flow: resolve ENS name -> resolver `contenthash(namehash)` -> decode protocol + hash -> fetch bundle from the content store -> verify `sha256(module.wasm)` against the manifest -> load via the standard lifecycle (doc 02). On a `contenthash` change the runtime re-fetches and hot-reloads. -This says: "the Nexum module for this contract lives at `twap-monitor.shepherd.eth`". +### On-chain registry -The runtime can either: -- **Poll** known ENS names for `shepherd.module` text records. -- **Watch** `TextChanged` events on the ENS resolver, filtered to the `shepherd.module` key: +For autonomous discovery the runtime would watch a contract for registration events and enter the ENS flow. Three shapes are viable: -``` -event TextChanged( - bytes32 indexed node, - string indexed indexedKey, - string key, // "shepherd.module" - string value // "twap-monitor.shepherd.eth" -); -``` - -### Option C: Wildcard subdomain registry (ENSIP-10) - -A parent name like `modules.shepherd.eth` uses wildcard resolution (ENSIP-10). A resolver contract serves subdomains dynamically: - -``` -twap.modules.shepherd.eth → contenthash of TWAP bundle -ethflow.modules.shepherd.eth → contenthash of Ethflow bundle -*.modules.shepherd.eth → resolved by registry contract -``` - -The wildcard resolver is itself the registry - anyone can register a subdomain. The runtime subscribes to events from the resolver contract to discover new modules. - -This gives us human-readable, permissionless module discovery under a shared namespace. - -### Runtime config for registry discovery - -```toml -[[modules]] -source = "registry" -contract = "0x1234…" # registry contract address -chain_id = 1 -# All modules registered here are auto-loaded - -[[modules]] -source = "ens-watch" -resolver = "0x231b…" # ENS Public Resolver -chain_id = 1 -text_key = "shepherd.module" -# Watch for any ENS name that sets this text record -``` - -## Layered Trust Model - -Discovery is permissionless, but **execution requires operator consent**. The runtime config controls what gets auto-loaded: - -```toml -[discovery] -# "allowlist" - only load modules from these sources -# "auto" - load anything discovered (use with caution) -mode = "allowlist" - -# If mode = "allowlist", only these ENS names / registries are trusted -allowed_ens_names = [ - "twap-monitor.shepherd.eth", - "ethflow.shepherd.eth", -] -allowed_registries = [ - "0x1234…" -] - -# Resource caps applied to ALL discovered modules (override manifest if lower) -[discovery.resource_limits] -max_memory_bytes = 10_485_760 -max_fuel_per_event = 100_000 -``` - -In `auto` mode, the runtime loads any module it discovers (useful for a public "run all CoW automation" node). In `allowlist` mode, discovered modules are staged for operator review. - -## ENS Name Conventions - -Suggested naming under a shared parent (e.g. `shepherd.eth` or a subdomain of the protocol): - -``` -.shepherd.eth - community / independent modules -..eth - protocol-owned modules - -Examples: - twap-monitor.shepherd.eth - ethflow-watcher.shepherd.eth - rebalancer.shepherd.eth - twap.cow.eth -``` - -## How the Pieces Fit Together - -```mermaid -sequenceDiagram - participant Author as Module Author - participant Swarm as Swarm - participant ENS as ENS - participant Runtime as Nexum Runtime - - Note over Author: 1. Write module (Rust/Go/JS/...) - Note over Author: 2. Compile to WASM component - Note over Author: 3. Create module.toml manifest - Author->>Swarm: 4. Upload bundle - Swarm-->>Author: Content hash (bzz:abc123...) - Author->>ENS: 5. Set contenthash on twap-monitor.shepherd.eth - - Note over Runtime: 6. Config: source="ens", name="twap-monitor.shepherd.eth" - Runtime->>ENS: 7. Resolve ENS → contenthash - ENS-->>Runtime: Content reference - Runtime->>Swarm: 8. Fetch bundle - Swarm-->>Runtime: Bundle - Runtime->>Runtime: 9. Verify integrity (hash match) - Runtime->>Runtime: 10. Load module (compile, init, run) - - Note over Author, Runtime: On update - Author->>Swarm: 11. Upload new bundle - Swarm-->>Author: New content hash - Author->>ENS: 12. Update contenthash - Runtime->>ENS: 13. Detect change (poll/event) - ENS-->>Runtime: New content reference - Runtime->>Swarm: 14. Fetch new bundle - Swarm-->>Runtime: New bundle - Runtime->>Runtime: 15. Hot-reload module -``` +- **Dedicated registry contract** emitting `ModuleRegistered` / `ModuleRemoved`. +- **ENS self-declaration**: a contract sets a `shepherd.module` text record on its own ENS name pointing at the module; the runtime watches `TextChanged` filtered to that key. +- **Wildcard subdomain registry** (ENSIP-10): `*.modules.shepherd.eth` resolved by a registry contract that anyone can register a subdomain against. -## Summary +### Layered trust -| Discovery Method | Decentralisation | Operator Effort | Use Case | -|-----------------|------------------|-----------------|----------| -| Static (local path) | None | Manual | Dev, CI, air-gapped | -| ENS (named) | High | Configure names | Production, known modules | -| Registry (contract) | Full | Point at contract | Public nodes, auto-discovery | -| ENS self-declare | Full | Watch resolver | Protocol-native automation | +Discovery is permissionless; execution requires operator consent. A `[discovery]` config would gate what auto-loads (`mode = "allowlist"` with `allowed_ens_names` / `allowed_registries`, versus `mode = "auto"` for public nodes) and apply resource caps to discovered modules. -All methods converge on the same flow: resolve a content reference → fetch via content store → verify → load via module lifecycle. +All methods converge on the same flow: resolve a content reference -> fetch via content store -> verify hash -> load. diff --git a/docs/04-state-store.md b/docs/04-state-store.md index 59de103a..0cca3712 100755 --- a/docs/04-state-store.md +++ b/docs/04-state-store.md @@ -1,48 +1,21 @@ # Local Store Architecture -## Overview - -Every Nexum module has access to a persistent key-value store that survives restarts, crashes, and module updates. The store is backed by **redb** (v3.1, pure Rust, embedded, ACID, MVCC) and exposed to modules through the `local-store` WIT interface. - -The local store is the only durable memory a module has - WASM linear memory is wiped on every restart. Modules must be written to reconstruct their working state from the store on `init`. +Every Nexum module has a persistent key-value store that survives restarts, crashes, and module updates, backed by **redb** (v3.1, pure Rust, embedded, ACID, MVCC) and exposed through the `local-store` WIT interface. It is the only durable memory a module has: WASM linear memory is wiped on every restart, so modules reconstruct working state from the store on `init`. ## redb Fundamentals | Property | Detail | |----------|--------| | Engine | Copy-on-write B-tree | -| Concurrency | MVCC - concurrent readers, single writer, no blocking | -| Durability | Crash-safe by default (fsync on commit) | -| Transactions | Full ACID - read txns and write txns | -| Key types | `&str`, `&[u8]`, integers, tuples, `Option`, fixed arrays | -| Value types | All key types + `Vec`, `f32`/`f64`, `()` | -| Size | No hard limit; v3 file format starts at ~50 KiB | +| Concurrency | MVCC: concurrent readers, single writer | +| Durability | Crash-safe (fsync on commit) | +| Transactions | Full ACID | ## Isolation Model -Each module gets its own **redb database file**. Modules cannot read or write each other's state - enforced by filesystem-level separation. - -```rust -// Runtime side - one database per module -fn open_module_db(module_id: &str) -> Result { - let path = format!("/var/nexum/state/{module_id}.redb"); - Database::create(&path) -} - -// Single table within each module's database -const LOCAL_STORE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("state"); -``` - -Module identity = `name` from `module.toml`. If two module instances share a name, they share state (intentional - enables hot-reload with state continuity). Different modules have different names and fully isolated database files. - -``` -/var/nexum/state/ -├── twap-monitor.redb → { "last_block": [...], "posted_parts": [...], ... } -├── ethflow-watcher.redb → { "pending_orders": [...], ... } -└── price-alert.redb → { "thresholds": [...], ... } -``` +A single redb file lives under `EngineConfig.engine.state_dir`. Every key is namespaced host-side by a fixed 32-byte prefix `keccak256(module_name)` prepended before the raw key, so modules sharing a key string see disjoint data and cannot forge a key into another module's range. keccak256 matches ENS node derivation (see [ADR-0003](adr/0003-local-store-namespacing.md)). The module never observes the prefix. -This per-file design ensures concurrent modules never contend on write locks (see Concurrency section below). +Module identity is `name` from `module.toml`. Two instances sharing a name share a namespace (intentional: hot-reload with state continuity). ## WIT Interface @@ -50,247 +23,51 @@ This per-file design ensures concurrent modules never contend on write locks (se interface local-store { use nexum:host/types.{fault}; - /// Get a value by key. Returns none if key doesn't exist. get: func(key: string) -> result>, fault>; - - /// Set a key-value pair. Overwrites existing value. - /// Returns fault.invalid-input or fault.internal on failure. - /// Quota exhaustion surfaces as fault.invalid-input (or a future - /// dedicated case) - see the migration guide. set: func(key: string, value: list) -> result<_, fault>; - - /// Delete a key. No-op if key doesn't exist. delete: func(key: string) -> result<_, fault>; - - /// List keys matching a prefix. Returns keys only (not values). list-keys: func(prefix: string) -> result, fault>; + contains: func(key: string) -> result; + len: func(key: string) -> result, fault>; + count: func(prefix: string) -> result; } ``` -In 0.1 `local-store` errors were bare `string` values. 0.2 replaces them with the shared `fault` vocabulary (see [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both)) so modules can match on the `fault` case rather than parsing error strings. The interface is the failure domain, so it reports `fault` directly with no subsystem tag. +`contains`, `len`, and `count` answer existence, value length, and prefix cardinality without transferring the value or materialising the key list. Errors are the shared `fault` vocabulary; the interface is its own failure domain, so it reports `fault` directly. Keys are UTF-8 strings; values are opaque bytes. -Keys are UTF-8 strings. Values are opaque bytes - the SDK provides typed wrappers (see doc 05). - -`list-keys` enables prefix-based namespacing within a module's state: +`list-keys` and `count` enable prefix-based namespacing within a module: ``` orders/active/0x1234 → [serialised order] orders/active/0x5678 → [serialised order] -orders/completed/… → [serialised order] list_keys("orders/active/") → ["orders/active/0x1234", "orders/active/0x5678"] ``` ## Transaction Semantics -Both `init` and `on_event` execute within an **implicit write transaction**: - -```mermaid -flowchart TD - A["Event arrives (or init called)"] --> B["Runtime opens redb WriteTransaction"] - B --> C["Calls module init(config) or on_event(event)"] - C --> D["module calls local-store::set('key', value) -- buffered in txn"] - C --> E["module calls local-store::get('key') -- reads from txn (sees own writes)"] - C --> F["module calls local-store::delete('key') -- buffered in txn"] - D --> G["Call returns Ok(())"] - E --> G - F --> G - G --> H["Runtime commits WriteTransaction"] - H --> I["State changes are durable"] -``` - -**On failure** (trap, fuel exhaustion, explicit `Err`): - -```mermaid -flowchart TD - A["Call traps / returns Err"] --> B["Runtime aborts WriteTransaction"] - B --> C["No state changes persisted -- atomically rolled back"] -``` - -This gives us **all-or-nothing semantics per call**: either all state mutations from a single `init` or `on_event` callback are applied, or none are. This is critical for correctness - a module that crashes halfway through processing a block doesn't leave behind partial state. Equally, a failed `init` during restart doesn't corrupt state from the previous version. - -### Read-your-own-writes - -Within a single `on_event` call, a module sees its own uncommitted writes: - -```rust -local_store::set("counter", &42u64.to_le_bytes())?; -let val = local_store::get("counter")?; -// val == Some([42, 0, 0, 0, 0, 0, 0, 0]) ✓ -``` - -This works because all operations within one event go through the same `WriteTransaction`. - -### Concurrency: One Database Per Module - -redb allows only **one `WriteTransaction` at a time** per `Database` - a second `begin_write()` blocks until the first commits or aborts. Since modules dispatch events concurrently (doc 02), a single shared redb file would serialise all write transactions across modules, negating concurrency. - -**Design decision:** each module gets its own redb `Database` file: - -``` -/var/nexum/state/ -├── twap-monitor.redb -├── ethflow-watcher.redb -└── price-alert.redb -``` - -This gives true write isolation - module A's transaction never blocks module B. The cost is more file handles (one per module), which is negligible for the expected module count. - -Within a single module, events are already sequential (doc 02 dispatch semantics), so there is never contention on a module's own database. +Each host call is its own redb transaction: a `get` / `contains` / `len` / `count` / `list-keys` opens a read transaction, and a `set` / `delete` opens a write transaction and commits (fsync-durable) before returning. There is no transaction spanning a whole `on_event`, so a module that traps midway through processing an event keeps whatever writes already committed; per-event atomicity is the module's responsibility (checkpoint a "last processed" key last, and make `init` idempotent). A `set` rejected for quota aborts its own write untouched. ## Size Enforcement -The manifest declares `max_state_bytes`. The runtime tracks total bytes stored per module and rejects `local-store::set` calls that would exceed the limit: - -```rust -// Host-side enforcement (simplified) -impl local_store::Host for NexumHostState { - async fn set(&mut self, key: String, value: Vec) -> Result> { - let new_size = self.state_bytes_used - - self.current_value_size(&key) - + key.len() + value.len(); - - if new_size > self.module_config.max_state_bytes { - return Ok(Err(Fault::InvalidInput("state quota exceeded".into()))); - } - - self.write_txn.insert(&*key, value.as_slice())?; - self.state_bytes_used = new_size; - Ok(Ok(())) - } -} -``` - -The tracking is approximate (doesn't account for B-tree overhead) but sufficient for enforcing a meaningful cap. +A module's namespace is capped at the engine-global `[limits].state_bytes` quota (default 50 MiB). `set` charges the on-disk footprint (prefix + key + value + a fixed per-entry overhead), summed across the namespace's keys, and rejects an over-quota write with `fault.invalid-input`, leaving the store untouched. The footprint is tracked by an incremental per-namespace counter, seeded once by a prefix-range scan. ## State Lifecycle -### Init / Cold Start - -On first load, the module's table is empty. The module's `init` function should handle this: - -```rust -fn init(config: Config) -> Result<(), Fault> { - if local_store::get("initialized")?.is_none() { - // First run - set up initial state - local_store::set("initialized", &[1])?; - local_store::set("last_block", &0u64.to_le_bytes())?; - } - Ok(()) -} -``` - -### Restart (crash recovery) - -On restart, the module gets a fresh WASM instance but the **same state table**. The last committed transaction's data is intact. Any in-flight transaction from the crashed event was rolled back. - -The module should read its checkpoint from state in `init` and resume: - -```rust -fn init(_config: Config) -> Result<(), Fault> { - let last_block = local_store::get("last_block")? - .map(|b| u64::from_le_bytes(b.try_into().unwrap())) - .unwrap_or(0); - logging::log(Level::Info, &format!("resuming from block {last_block}")); - Ok(()) -} -``` - -### Module Update (new version, same name) - -When a module is updated (new WASM binary, same `name` in manifest), the new version inherits the existing state table. The new version's `init` is responsible for any migration: - -```rust -fn init(config: Config) -> Result<(), Fault> { - let version = local_store::get("schema_version")? - .map(|b| u64::from_le_bytes(b.try_into().unwrap())) - .unwrap_or(0); - - if version < 2 { - // Migrate from v1 → v2 schema - migrate_v1_to_v2()?; - local_store::set("schema_version", &2u64.to_le_bytes())?; - } - Ok(()) -} -``` - -### Module Removal - -When an operator removes a module, its state table can optionally be: -- **Retained** (default) - in case the module is re-added later. -- **Purged** - operator explicitly requests deletion via CLI. - -```bash -nexum state purge --module twap-monitor -``` - -## Backup and Compaction - -redb supports online reads during writes (MVCC), so backup is straightforward: - -```rust -// Runtime holds a read transaction, copies the file -let _guard = db.begin_read()?; -std::fs::copy("state.redb", "state.redb.backup")?; -``` - -Compaction (`db.compact()`) reclaims space from deleted keys. The runtime can run this periodically or on operator command. - -## Host-Side Implementation Sketch - -```rust -const LOCAL_STORE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("state"); - -struct ModuleStateCtx { - db: Database, // per-module database file - max_bytes: usize, - bytes_used: usize, - write_txn: Option, -} - -impl ModuleStateCtx { - /// Called by runtime before dispatching init or on_event - fn begin(&mut self) -> Result<()> { - self.write_txn = Some(self.db.begin_write()?); - Ok(()) - } - - /// Called by runtime after successful return - fn commit(&mut self) -> Result<()> { - if let Some(txn) = self.write_txn.take() { - txn.commit()?; - } - Ok(()) - } - - /// Called by runtime on failure/trap - fn rollback(&mut self) { - // WriteTransaction::drop aborts automatically - self.write_txn.take(); - } - - fn table<'txn>( - &self, - txn: &'txn WriteTransaction, - ) -> Result> { - txn.open_table(LOCAL_STORE_TABLE) - } -} -``` +- **Cold start.** On first load the namespace is empty; `init` seeds any initial keys. +- **Restart.** A crash yields a fresh WASM instance over the same namespace. The last committed write is intact. `init` reads its checkpoint and resumes. +- **Update.** A new version (same `name`) inherits the namespace; its `init` handles any schema migration. +- **Removal.** A removed module's keys are retained by default. ## Summary | Concern | Design | |---------|--------| | Backend | redb v3.1 (pure Rust, ACID, MVCC) | -| Isolation | One database file per module (keyed by `name`) | -| Key type | UTF-8 string | -| Value type | Opaque bytes (`list` in WIT) | -| Namespacing within module | Convention: slash-separated prefixes + `list-keys` | -| Transaction scope | Per `init` / `on_event` call - commit on success, rollback on failure | -| Read-your-own-writes | Yes (same `WriteTransaction`) | -| Size limit | Enforced per-module via manifest `max_state_bytes` | -| Survives restart | Yes - state is external to WASM instance | -| Module update | New version inherits state; `init` handles migration | -| Backup | Online copy under read transaction | +| File layout | Single redb file under `state_dir` | +| Isolation | 32-byte `keccak256(module_name)` key prefix (ADR-0003) | +| Key / value | UTF-8 string / opaque bytes (`list`) | +| Namespacing within module | Slash-separated prefixes + `list-keys` / `count` | +| Transaction scope | Per host call, committed on return | +| Size limit | Per-namespace quota from `[limits].state_bytes` | +| Survives restart | Yes, external to the WASM instance | diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 697d3448..ad5ff4cb 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -1,982 +1,303 @@ -# SDK Design: Layered SDK (`nexum-sdk` + `shepherd-sdk`) - -> **Status: future direction, not in 0.2 scope.** This document is the **0.3+ north-star** vision for the layered SDK. The 0.2 SDK shipped a focused subset, and the macro-driven authoring model below was superseded by the host-trait seam in [ADR-0009](adr/0009-host-trait-surface.md) - that is the design that ships. Treat the macros, two-crate split, `TypedState`, `Signer`, `HostTransport` / `Provider`, and `cargo-nexum` CLI sections below as design intent, not API documentation. For the shipped surface, see [`sdk.md`](sdk.md) and the rustdoc on `crates/shepherd-sdk/`. -> -> The split, for quick reference: -> -> | Feature | M3 status | Where | -> |---|---|---| -> | `shepherd-sdk` crate | ✅ shipped | `crates/shepherd-sdk/` | -> | `shepherd-sdk-test` crate (mock host) | ✅ shipped | `crates/shepherd-sdk-test/` | -> | Host traits (`ChainHost`, `LocalStoreHost`, `LoggingHost`) + supertrait `Host` | ✅ shipped | `crates/nexum-sdk/src/host.rs` (see ADR-0009); the CoW `CowApiHost` lives in `crates/shepherd-sdk/src/cow/` | -> | `strategy.rs` (pure logic) + `lib.rs` (wit-bindgen adapter) recipe | ✅ shipped | every M2/M3 module | -> | `Fault` + `HostFault` trait, `ChainError` (SDK-side mirror of wit) | ✅ shipped | `crates/nexum-sdk/src/host.rs` | -> | `chain` helpers (`eth_call_params`, `parse_eth_call_result`) | ✅ shipped | `crates/nexum-sdk/src/chain/`; the CoW `decode_revert_hex` lives in `crates/shepherd-sdk/src/cow/` | -> | `cow` helpers (`PollOutcome`, `RetryAction`, `classify_api_error`, `gpv2_to_order_data`, `decode_revert`, `IConditionalOrder`) | ✅ shipped | `crates/shepherd-sdk/src/cow/` | -> | `http::fetch` over wasi:http (+ `Fetch` seam, `FetchError`) | ✅ shipped | `crates/nexum-sdk/src/http.rs` | -> | `MockHost` with per-trait mocks (`MockChain`, `MockLocalStore`, `MockLogging`; CoW `MockCowApi`) | ✅ shipped | `crates/nexum-sdk-test/src/lib.rs` + `crates/shepherd-sdk-test/src/lib.rs` | -> | Separate `nexum-sdk` crate | ✅ shipped | `crates/nexum-sdk/` carries the generic surface (host seam, bind macro, chain/config/address, http, tracing); `shepherd-sdk` layers the CoW domain on top with no re-export | -> | `#[nexum::module]` / `#[shepherd::module]` proc macros | ❌ deferred (M5) | modules write `wit_bindgen::generate!` + `WitBindgenHost` adapter by hand | -> | Named event handlers (`on_block` / `on_chain_logs` / `on_tick` / `on_message` injection) | ❌ deferred (M5) | modules pattern-match on `types::Event` in `Guest::on_event` | -> | `async fn` handler support via `block_on` | ❌ deferred (M5) | strategy functions are synchronous | -> | Full alloy `Provider` via `HostTransport` | ❌ deferred (M5) | modules call `host.request(chain_id, method, params)` with JSON strings | -> | `TypedState` (postcard-backed typed local-store) | ❌ deferred (M5) | modules call `host.set(&key, &raw_bytes)` directly | -> | `Signer` (ECDSA + EIP-712 via `identity` host interface) | ❌ deferred (M5) | modules use `Signature::PreSign` / `Signature::Eip1271`; no key custody on the module side | -> | `Cow` typed CoW Protocol API client (quote / get_order / raw_request) | ❌ deferred (M5) | `cow-api` exposes only `submit-order` today | -> | `MockIdentity`, `MockProvider`, `WasmTestHarness` | ❌ deferred (M5) | tests against `&impl Host` + per-trait mocks | -> | `cargo nexum` CLI (new / build / package / publish) | ❌ deferred (M5) | modules use `cargo build --target wasm32-wasip2` directly | -> | `block.timestamp` in ms | ✅ shipped | confirmed in `nexum:host/types` | -> -> **Reader's guide**: treat the sections below as design intent the next two milestones move toward, not API documentation for the code that exists today. For M3 API reference, see [sdk.md](sdk.md) and the rustdoc on `crates/shepherd-sdk/`. The M3 architectural decision is captured in [ADR-0009](adr/0009-host-trait-surface.md). - -## Purpose - -The SDK is split into two layers: - -1. **`nexum-sdk`** -- the universal SDK for any `nexum:host/event-module`. It provides: - - WIT bindings (re-exported, version-pinned) - - A proc macro (`#[nexum::module]`) that eliminates boilerplate (supports `async fn` for natural `.await`) - - A full alloy `Provider` backed by the host's RPC stack (`HostTransport`) - - Typed local-store helpers (serde over raw bytes) - - A typed `Signer` for key management and signing - - Ethereum ABI helpers (alloy-sol-types integration) - - A test harness with a mock host (`MockHost`) - - A logging convenience layer - - The per-interface typed error model over the shared `Fault` vocabulary - -2. **`shepherd-sdk`** -- the CoW Protocol extension. It depends on `nexum-sdk` (modules import both directly; nothing is re-exported) and adds: - - CoW-specific WIT bindings (`shepherd:cow`) - - A typed CoW Protocol API client (`Cow`) - - A proc macro (`#[shepherd::module]`) that targets the `shepherd:cow/shepherd` world - - CoW-specific mock testing utilities - -Module authors should never interact with `wit-bindgen` or the canonical ABI directly. - -## Crate Structure +# SDK Design: The Two-Persona SDK -``` -nexum-sdk/ -├── Cargo.toml -├── src/ -│ ├── lib.rs # re-exports, prelude, provider() constructor (block_on is internal) -│ ├── bindings.rs # generated by wit-bindgen (checked in or build.rs) -│ ├── transport.rs # HostTransport -- alloy Transport impl over chain::request / chain::request-batch -│ ├── local_store.rs # typed local-store helpers -│ ├── signer.rs # Signer -- typed identity helpers (accounts, signing) -│ ├── abi.rs # Ethereum ABI encoding/decoding -│ ├── log.rs # logging convenience -│ ├── error.rs # Fault, HostFault, ChainError -│ └── testing.rs # mock host, test harness -└── macros/ - └── src/ - └── lib.rs # #[nexum::module] proc macro (async fn support) - -shepherd-sdk/ -├── Cargo.toml -├── src/ -│ ├── lib.rs # CoW-specific prelude and API; modules import nexum-sdk directly -│ ├── bindings.rs # generated CoW WIT bindings (shepherd:cow) -│ ├── cow.rs # Cow -- typed CoW Protocol API wrapper -│ └── testing.rs # CoW-specific mock utilities -└── macros/ - └── src/ - └── lib.rs # #[shepherd::module] proc macro (CoW variant) -``` +This document describes the guest-side SDK crates. There are two personas, and both are shipped: the **module author**, served by `nexum-sdk`, and the **venue persona**, served by `videre-sdk` - which covers both sides of a venue: the adapter author who speaks one venue's protocol, and the keeper author who drives venues through the typed client. -The workspace root `wit/nexum-host/` is the **universal WIT definition**. The `wit/shepherd-cow/` directory extends it with CoW Protocol interfaces. The SDKs reference these via path (not a copy) to prevent drift: +For the architectural decision behind the host-trait seam both personas build on, see [ADR-0009](adr/0009-host-trait-surface.md). For the rustdoc-level API reference, see [`sdk.md`](sdk.md) and the rustdoc under `nexum/crates/nexum-sdk/` and `videre/crates/videre-sdk/`. -```toml -# nexum-sdk/Cargo.toml -[package.metadata.component.target] -path = "../wit/nexum-host" -``` +## The two personas -```toml -# shepherd-sdk/Cargo.toml -[package.metadata.component.target] -path = "../wit/shepherd-cow" -``` +1. **Module author.** Writes an automation module against +`nexum:host/event-module`: react to blocks, chain logs, ticks, or messages; read and write local state. Served by `nexum-sdk` plus the `#[nexum_sdk::module]` attribute macro (from `nexum-module-macros`). -Both SDKs pin a specific `wit-bindgen` version so module authors are insulated from upstream churn. +2. **Venue author.** Writes the component that exposes a trading venue +(CoW Protocol, a DEX, a lending market, ...) to modules through the `videre:venue` intent surface, so a module author never sees the venue's wire format. Served by `videre-sdk`: the `VenueAdapter` trait under `#[videre_sdk::venue]`, the `IntentBody` derive, and the `videre-test` conformance kit. -The crates above are the guest-side SDK. The host side ships separately as the `nexum-runtime` library plus the `nexum` binary (`crates/nexum-cli`). A Rust host embedding the runtime directly should start from `crates/nexum-runtime/examples/embed.rs` rather than the SDK. +A keeper - a module that drives venues - sits between the two: it is a module by world, but it authors with `#[videre_sdk::keeper]` and calls venues through the typed `VenueClient`. The domain itself (CoW, a DEX) lives in the venue adapter, never in the host: see [doc 08](08-platform-generalisation.md#layer-3-domain-extensions-venue-adapters). -## The `#[nexum::module]` and `#[shepherd::module]` Macros +Each persona has its own proc-macro crate (`nexum-module-macros`, `videre-macros`), reached through the SDK re-exports. Both share the same host-trait philosophy: guest code is written against small Rust traits that mirror the WIT interfaces one-for-one, so module logic can be unit-tested against an in-memory mock without a `wasm32-wasip2` toolchain or a running wasmtime instance. -### Universal: `#[nexum::module]` - -Without the macro, a module author writes (against the typed 0.2 config): - -```rust -wit_bindgen::generate!({ world: "event-module", path: "..." }); - -struct MyModule; - -impl Guest for MyModule { - fn init(config: Config) -> Result<(), Fault> { ... } - fn on_event(event: Event) -> Result<(), Fault> { - match event { - Event::Block(block) => { ... } - Event::ChainLogs(logs) => { ... } - Event::Tick(tick) => { ... } - Event::Message(msg) => { ... } - } - } -} +## Crate layout -export!(MyModule); ``` +nexum-sdk/ # universal module SDK (host-neutral, domain-free) +└── src/ + ├── lib.rs # crate docs, `pub use nexum_module_macros::module` + ├── prelude.rs # alloy primitive re-exports (Address, B256, Bytes, U256, keccak256) + ├── host.rs # ChainHost / LocalStoreHost / LoggingHost + supertrait Host; Fault, ChainError, RpcError + ├── wit_bindgen_macro.rs # bind_host_via_wit_bindgen! - generates WitBindgenHost + converters + ├── keeper.rs # WatchSet, Gates, Journal, Poller, Retrier + ├── chain/ # eth_call_params, parse_eth_call_result, chainlink AggregatorV3 reader + ├── events.rs # native alloy Log assembly from the wire ChainLog record + ├── config.rs # (key, value) config-table lookups, decimal scaling + ├── address.rs # EVM address parsing with typed errors + ├── http.rs # Fetch trait seam, WasiFetch, FetchError (wasi:http) + └── tracing.rs # guest tracing facade + panic hook over a LogSink seam + +nexum-module-macros/ # #[module] attribute macro (proc-macro) + +videre-sdk/ # venue SDK: both venue sides +└── src/ + ├── lib.rs # crate docs, macro re-exports (venue, keeper, IntentBody) + ├── adapter.rs # VenueAdapter trait (init + the five intent functions) + ├── body.rs # IntentBody trait + BodyError (versioned borsh codec) + ├── client.rs # Venue, VenueId, VenueClient, VenueTransport, HostVenues; poll_once completes async handlers on the sync guest boundary + ├── keeper.rs # Keeper::run - the generic run assembler; Outcome, RunReport + ├── transport.rs # HostChain, HostMessaging, http, BoundedFetch + ├── faults.rs # VenueFault + conversions across wire fault / SDK fault / VenueError + ├── event.rs # intent-status event decoding for on_intent_status handlers + ├── value_flow.rs # value-flow asset helpers (erc20 amounts) + └── bindings.rs # the shared import-only bindgen the macros remap onto -With the macro, module authors implement **named event handlers** instead. The macro generates the `on_event` match dispatch, the `Guest` trait impl, WIT bindings, and `export!`: - -```rust -use nexum_sdk::prelude::*; - -#[nexum::module] -struct TwapMonitor; - -impl TwapMonitor { - fn init(config: Config) -> Result<()> { - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let num = provider.get_block_number().await?; - // ... - Ok(()) - } +videre-macros/ # #[venue], #[keeper], derive(IntentBody) (proc-macro) - async fn on_chain_logs(logs: Vec, provider: &RootProvider) -> Result<()> { - for log in &logs { - // ... - } - Ok(()) - } +videre-test/ # venue conformance kit +└── src/ # CodecVectors, HeaderGoldens, MockTransport, reference venue - // on_tick / on_message not defined -> those events are silently ignored -} +cow-venue/ # the CoW venue, as feature slices +composable-cow/ # ComposableCoW keeper machinery (body, poll seam, run) ``` -The `#[nexum::module]` macro generates code against the `nexum:host/event-module` world. +`nexum-sdk` is host-neutral and domain-free: any module targeting the runtime pulls helpers and canonical primitive types from it regardless of which world it exports. `videre-sdk` layers the venue platform's guest surface on top. Domain crates (`cow-venue`, `composable-cow`) depend on the SDKs; nothing is re-exported between layers. -### CoW Protocol: `#[shepherd::module]` +Companion mock crates: `nexum-sdk-test` (in-memory `MockHost` over `ChainHost` / `LocalStoreHost` / `LoggingHost`) for modules and keepers, and `videre-test` for adapters. See [Testing](#testing-nexum-sdk-test-and-videre-test) below. -For CoW Protocol modules, the `#[shepherd::module]` macro targets the `shepherd:cow/shepherd` world, which extends `event-module` with the merged `cow-api` import: +## Module-author persona: `nexum-sdk` -```rust -use shepherd_sdk::prelude::*; +### The host-trait seam -#[shepherd::module] -struct CowTwapMonitor; - -impl CowTwapMonitor { - fn init(config: Config) -> Result<()> { - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let cow = Cow::new(block.chain_id); - let quote = cow.get_quote(&OrderQuoteRequest { /* ... */ })?; - // ... - Ok(()) - } -} -``` - -### What the macro generates - -For the universal `#[nexum::module]`: +`nexum-sdk` never calls `wit_bindgen`-generated functions directly. Instead `nexum_sdk::host` exposes small traits that mirror the WIT interfaces: ```rust -wit_bindgen::generate!({ world: "event-module", path: "..." }); - -impl Guest for TwapMonitor { - fn init(config: Config) -> Result<(), Fault> { - TwapMonitor::init(config.into()).map_err(Fault::from) - } - - fn on_event(event: types::Event) -> Result<(), Fault> { - nexum_sdk::block_on(async { - match event { - Event::Block(block) => { - let provider = nexum_sdk::provider(block.chain_id); - TwapMonitor::on_block(block, &provider).await - } - Event::ChainLogs(logs) => { - let provider = nexum_sdk::provider(logs[0].chain_id); - TwapMonitor::on_chain_logs(logs, &provider).await - } - Event::Tick(_) => Ok(()), // no handler defined - Event::Message(_) => Ok(()), // no handler defined - } - }).map_err(Fault::from) - } +pub trait ChainHost { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result; } - -export!(TwapMonitor); -``` - -For the CoW `#[shepherd::module]`, the generated code additionally imports `shepherd:cow/cow-api` alongside the `nexum:host` base. - -### Named event handlers - -| Handler | Payload | Optional injectable context | -|---|---|---| -| `on_block(block)` | `Block` | `provider: &RootProvider` (from `block.chain_id`) | -| `on_chain_logs(logs)` | `Vec` | `provider: &RootProvider` (from the `chain-logs` batch chain id) | -| `on_tick(tick)` | `Tick` (`tick.fired_at`) | None (no chain context) | -| `on_message(message)` | `Message` | None | - -The macro inspects each handler's signature: - -- **If the second parameter is `&RootProvider`**: the macro creates `nexum_sdk::provider(chain_id)` (also for CoW modules) and passes it in. The chain_id is derived from the event payload (`block.chain_id`, `logs[0].chain_id`). -- **If no second parameter**: the macro passes only the payload. -- **Both sync and async handlers work.** Async handlers are wrapped in `block_on`; sync handlers are called directly. -- **Unimplemented handlers** become `Ok(())` -- the module only handles event types it cares about. - -### Escape hatch: `on_event` - -For modules that need custom dispatch logic, defining `on_event` directly takes precedence over named handlers: - -```rust -#[nexum::module] -struct CustomModule; - -impl CustomModule { - fn init(config: Config) -> Result<()> { Ok(()) } - - // Full control -- named handlers are ignored if on_event exists - async fn on_event(event: Event) -> Result<()> { - match event { - Event::Block(block) if block.chain_id == 42161 => { /* Arbitrum only */ } - Event::Block(_) => { /* other chains */ } - _ => {} - } - Ok(()) - } +pub trait LocalStoreHost { + fn get(&self, key: &str) -> Result>, Fault>; + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault>; + fn delete(&self, key: &str) -> Result<(), Fault>; + fn list_keys(&self, prefix: &str) -> Result, Fault>; } -``` - -Resolution order: -1. `on_event` defined -> use it directly (wrap in `block_on` if async) -2. Any of `on_block` / `on_chain_logs` / `on_tick` / `on_message` defined -> generate the match dispatch -3. Neither -> compile error - -> Full async design rationale: [07-rpc-namespace-design.md](07-rpc-namespace-design.md#eliminating-block_on-async-module-functions) - -## Prelude - -### Universal: `nexum_sdk::prelude` - -```rust -// nexum_sdk::prelude -pub use crate::bindings::nexum::host::types::*; -pub use crate::bindings::nexum::host::chain; -pub use crate::bindings::nexum::host::identity; -pub use crate::bindings::nexum::host::local_store; -pub use crate::bindings::nexum::host::remote_store; -pub use crate::bindings::nexum::host::messaging; -pub use crate::bindings::nexum::host::logging; -pub use crate::log::{trace, debug, info, warn, error}; -pub use crate::local_store::TypedState; -pub use crate::signer::Signer; -pub use crate::transport::HostTransport; -pub use crate::provider; -pub use crate::error::{Result, Fault, HostFault, ChainError, RpcError}; - -// Re-export alloy essentials so modules don't need direct alloy dependencies -pub use alloy_primitives::{Address, B256, U256, Bytes}; -pub use alloy_sol_types::sol; -pub use alloy_rpc_types::*; -pub use alloy_provider::Provider; -``` - -One `use nexum_sdk::prelude::*;` gives module authors everything they need -- including the alloy `Provider` trait, primitive types, `sol!` macro, and `Signer` for signing. - -`block_on` is no longer a public re-export in 0.2 -- it's hidden behind the `#[nexum::module]` macro. See the [migration guide §7](migration/0.1-to-0.2.md#7-sdk-changes-author) for the full SDK rename table. - -### CoW Protocol: `shepherd_sdk::prelude` - -```rust -// shepherd_sdk::prelude -- CoW-specific items only; no nexum-sdk re-export -pub use crate::bindings::shepherd::cow::cow_api; -pub use crate::cow::Cow; -``` - -CoW module authors write `use nexum_sdk::prelude::*;` alongside `use shepherd_sdk::prelude::*;` -- the CoW prelude adds only the merged CoW `cow-api` interface and the typed `Cow` client. - -## Typed Local-Store Helpers - -Raw local-store is `string -> list`. The SDK adds a typed layer using serde: - -```rust -use nexum_sdk::prelude::*; -use serde::{Serialize, Deserialize}; - -#[derive(Serialize, Deserialize)] -struct TwapProgress { - last_block: u64, - posted_parts: Vec<[u8; 32]>, +pub trait LoggingHost { + fn log(&self, level: Level, message: &str); } - -// Read typed value -let progress: Option = TypedState::get("progress")?; - -// Write typed value -TypedState::set("progress", &TwapProgress { - last_block: 19_000_001, - posted_parts: vec![part_hash], -})?; - -// Delete -TypedState::delete("progress")?; - -// List keys by prefix -let keys: Vec = TypedState::list_keys("orders/")?; +pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} +impl Host for T {} ``` -Implementation: - -```rust -pub struct TypedState; - -impl TypedState { - pub fn get(key: &str) -> Result> { - match local_store::get(key)? { - Some(bytes) => Ok(Some(postcard::from_bytes(&bytes)?)), - None => Ok(None), - } - } - - pub fn set(key: &str, value: &T) -> Result<()> { - let bytes = postcard::to_allocvec(value)?; - local_store::set(key, &bytes)?; - Ok(()) - } +Module code takes `&impl Host` (or a narrower `` bound when it only needs part of the surface) so tests inject `nexum_sdk_test::MockHost` while the compiled module injects the wit-bindgen-backed adapter. See [ADR-0009](adr/0009-host-trait-surface.md) for the full rationale and [ADR-0011](adr/0011-per-interface-typed-errors.md) for the typed error model. - pub fn delete(key: &str) -> Result<()> { - local_store::delete(key)?; - Ok(()) - } +### The wit-bindgen adapter: `bind_host_via_wit_bindgen!` - pub fn list_keys(prefix: &str) -> Result> { - Ok(local_store::list_keys(prefix)?) - } -} -``` - -Serialisation uses **postcard** (compact, no-std, deterministic) rather than JSON to minimise local-store storage overhead. +Every module keeps its own `wit_bindgen::generate!` call (the macro emits types into the calling crate; re-exporting wit-bindgen output from a library crate would duplicate symbols and break the component-export contract). What the SDK removes is the ~80 lines of mechanical glue that used to sit next to it: the `nexum_sdk::bind_host_via_wit_bindgen!()` declarative macro emits a `WitBindgenHost` struct, the trait impls over the generated import shims, the `Fault` / `ChainError` converters in both directions, a `Level` converter, a `From for nexum_sdk::events::Log` impl, and an `install_tracing()` helper that routes `tracing::info!(...)` through the bound host logging call. The adapter is capability-selected: the zero-argument form emits the full set for blanket-world modules, and the `caps: [chain, logging]` form (what `#[nexum_sdk::module]` generates from the manifest) emits only the pieces whose imports the module's world carries. -## Signer +### The `#[nexum_sdk::module]` macro -The `identity` WIT interface provides cryptographic identity -- key management and signing (ECDSA secp256k1 by default, extensible). The SDK wraps this with a typed `Signer`: +`nexum-module-macros` ships one attribute macro, re-exported as `nexum_sdk::module`. Apply it to an inherent `impl` block whose methods are named event handlers - `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message` - and the macro reads the crate's `module.toml`, synthesizes the per-module world from its `[capabilities]`, and generates the `wit_bindgen::generate!` call for that world, the capability-selected `bind_host_via_wit_bindgen!` invocation, a `Guest` implementation whose `on_event` dispatches to whichever handlers are present (absent handlers become a no-op for that event), and `export!`: ```rust -use nexum_sdk::prelude::*; - -// Get available signing accounts -let accounts = Signer::accounts()?; -for account in &accounts { - info!("available signer: 0x{}", hex::encode(account)); -} - -// Sign raw bytes with a specific account -let signature = Signer::sign(&accounts[0], &data_to_sign)?; -// signature is 65 bytes: r (32) || s (32) || v (1) +// nexum/modules/examples/http-probe/src/lib.rs (shipped) +mod logic; -// Sign EIP-712 typed data -let typed_data_json = r#"{"types":...,"primaryType":"Order","domain":...,"message":...}"#; -let signature = Signer::sign_typed_data(&accounts[0], typed_data_json)?; -``` - -Implementation: +use nexum::host::types; -```rust -/// Typed client for the identity WIT interface. -/// -/// Provides cryptographic signing operations backed by the host engine's -/// key management. The host manages private keys -- modules never see them. -pub struct Signer; - -impl Signer { - /// Get available signing accounts (20-byte Ethereum addresses). - pub fn accounts() -> Result>> { - identity::accounts().map_err(Fault::from) - } +struct HttpProbe; - /// Get available signing accounts as alloy `Address` types. - pub fn addresses() -> Result> { - let accounts = Self::accounts()?; - accounts - .into_iter() - .map(|a| { - Address::try_from(a.as_slice()) - .map_err(|_| Fault::InvalidInput("invalid address length".into())) - }) - .collect() - } - - /// Sign raw bytes with the specified account. - /// Returns a 65-byte ECDSA secp256k1 signature (r || s || v). - pub fn sign(account: &[u8], data: &[u8]) -> Result> { - identity::sign(account, data).map_err(Fault::from) +#[nexum_sdk::module] +impl HttpProbe { + fn init(config: Vec<(String, String)>) -> Result<(), Fault> { + install_tracing(); + let cfg = logic::parse_config(&config)?; + // ... + Ok(()) } - /// Sign EIP-712 typed data with the specified account. - /// `typed_data` is a JSON string conforming to the EIP-712 specification. - /// Returns a 65-byte ECDSA secp256k1 signature (r || s || v). - pub fn sign_typed_data(account: &[u8], typed_data: &str) -> Result> { - identity::sign_typed_data(account, typed_data).map_err(Fault::from) + fn on_block(block: types::Block) -> Result<(), Fault> { + logic::on_block(&nexum_sdk::http::WasiFetch, /* ... */ block.number) + .map_err(Into::into) } } ``` -Note: modules can also use `identity` indirectly through `chain`. When a module calls `chain::request` with a signing method (e.g. `eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`), the host's `chain` implementation delegates to the `identity` backend internally. `Signer` is for modules that need direct, raw signing operations -- e.g. EIP-712 over an off-chain order payload. - -Modules can match on `Fault::Denied` to distinguish "user rejected" from a transport failure -- see the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder mapping table. - -## Ethereum ABI Helpers & alloy Provider - -Modules frequently need to read chain state and encode/decode Ethereum calldata. The SDK provides a full alloy `Provider` (via `HostTransport` over `chain::request` / `chain::request-batch`) and integrates `alloy-sol-types` and `alloy-primitives` (compiled to WASM): - -```rust -use nexum_sdk::prelude::*; - -// Define the contract interface -sol! { - function getTradeableOrderWithSignature( - address owner, - bytes32 ctx, - bytes32 orderHash - ) external view returns ( - bytes memory order, - bytes memory signature - ); -} - -// Named handler -- provider is injected by the macro -async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - // Full alloy Provider API -- natural .await - let block_num = provider.get_block_number().await?; - let balance = provider.get_balance(owner_addr).latest().await?; - - // Typed contract calls with sol! + EthCall builder - let tx = TransactionRequest::default() - .to(contract_addr) - .input(getTradeableOrderWithSignatureCall { - owner: owner_addr, - ctx: ctx_bytes, - orderHash: order_hash, - }.abi_encode().into()); - - let result = provider.call(tx).latest().await?; - let decoded = getTradeableOrderWithSignatureCall::abi_decode_returns(&result)?; - let order_bytes = decoded.order; - Ok(()) -} -``` - -The SDK re-exports: -- `alloy_primitives::{Address, B256, U256, Bytes}` -- core Ethereum types. -- `alloy_sol_types::sol!` -- compile-time ABI codec generation. -- `alloy_provider::Provider` -- the full alloy Provider trait. -- `alloy_rpc_types::*` -- `TransactionRequest`, `Filter`, `Block`, etc. - -These are already WASM-compatible (no-std support, no system dependencies). The `HostTransport` routes all RPC calls through the `chain::request` (and batched `chain::request-batch`) host functions -- see doc 07 for the full design. - -## CoW Protocol API: `Cow` +Two things worth being precise about: -The `cow-api` WIT interface (in `shepherd:cow`) exposes a REST passthrough to the CoW Protocol API plus a typed `submit-order` function (the two were separate interfaces, `cow` and `order`, in 0.1). The `shepherd-sdk` wraps this with a typed `Cow` client: +- **The world is derived from the manifest.** The macro generates +against a per-module world whose imports are exactly the `[capabilities].required`/`optional` declarations: a chain + local-store module simply has no `identity` bindings to call. Its imports equal its declarations by construction, and the runtime's capability check is a backstop rather than a consumer of toolchain dead-import elision. +- **Handlers are synchronous.** `init` and the named handlers are +plain `fn`, called directly with no `block_on` wrapper. Modules call `host.request(chain_id, method, params_json)` (or the `chain::eth_call_params` / `parse_eth_call_result` helpers) directly against `ChainHost`, per [doc 07](07-rpc-namespace-design.md). (Keeper handlers are the exception: see below.) -```rust -use shepherd_sdk::prelude::*; - -let cow = Cow::new(42161); +The `Guest`/`export!` shape the macro emits follows the `logic.rs` (pure logic, tested against `&impl Host`) / `lib.rs` (handlers plus the macro attribute) split from ADR-0009. The keeper primitives in `nexum_sdk::keeper` - `WatchSet`, `Gates`, `Journal`, `Poller`, `Retrier` - give conditional-commitment modules a shared set of `LocalStoreHost` conventions instead of hand-rolled key schemes; `videre_sdk::keeper` assembles them into the generic run. -// Submit an order via the merged cow-api interface -let uid = cow.submit_order(&OrderCreation { - sell_token: sell_addr, - buy_token: buy_addr, - sell_amount: U256::from(1_000_000), - buy_amount: U256::from(950_000), - kind: OrderKind::Sell, - valid_to: (block.timestamp / 1000) + 300, // block.timestamp is ms in 0.2 - ..Default::default() -})?; +## Venue persona: `videre-sdk` -// Get a quote -let quote = cow.get_quote(&OrderQuoteRequest { ... })?; +### The venue contract -// Get an order by UID -let order = cow.get_order(&uid)?; - -// Raw request for endpoints not yet wrapped -let resp = cow.raw_request("GET", "/api/v1/auction", None)?; -``` +An adapter targets the `videre:venue/venue-adapter` world: it exports the `adapter` interface (`body-versions`, `derive-header`, `quote`, `submit`, `status`, `cancel`) plus `init`, and imports scoped transport only - `chain`, `messaging`, and allowlisted `wasi:http`. No local-store, remote-store, identity, or logging import: an adapter structurally cannot touch host key material or persistent state. Keepers reach venues through the host-implemented `videre:venue/client` interface, which mirrors `adapter` with a venue selector per call. The types (`intent-header`, `quotation`, `submit-outcome`, `receipt`, `intent-status`, `venue-error`) live in `videre:types`, over the `videre:value-flow` asset vocabulary. -The `Cow` client handles JSON serialisation and routes requests through the host's `cow-api::request` (REST passthrough) and `cow-api::submit-order` (order submission) functions. +### Bodies: the `IntentBody` derive -## Logging Convenience - -Wrappers over the `logging` WIT interface (provided in `nexum-sdk`; CoW modules import them from `nexum-sdk` directly): +A venue's intent body is opaque on the wire; typing is a guest-side agreement between keeper and adapter, spelled as an outer per-venue version enum under `#[derive(videre_sdk::IntentBody)]`: ```rust -// nexum_sdk::log -pub fn trace(msg: &str) { logging::log(Level::Trace, msg); } -pub fn debug(msg: &str) { logging::log(Level::Debug, msg); } -pub fn info(msg: &str) { logging::log(Level::Info, msg); } -pub fn warn(msg: &str) { logging::log(Level::Warn, msg); } -pub fn error(msg: &str) { logging::log(Level::Error, msg); } - -/// Format + log in one call -#[macro_export] -macro_rules! info { - ($($arg:tt)*) => { - $crate::log::info(&format!($($arg)*)) - }; +#[derive(videre_sdk::IntentBody)] +enum EchoBody { + V1(u64), } ``` -Usage: +The wire form is the borsh enum layout: a one-byte version tag (the variant's declaration index) then the borsh payload - so the tag order is the schema: append new versions, never reorder. Decoding an unknown tag fails typedly as `BodyError::UnknownVersion` rather than as a stringly decode error. The versions an adapter decodes are declared in its manifest `[venue] body_versions` and asserted at install against its `body-versions` export; a keeper declares the single `[venue] body_version` it encodes and install refuses it unless every installed adapter decodes that version. -```rust -nexum_sdk::info!("processing block {} on chain {}", block.number, block.chain_id); -``` +### `#[videre_sdk::venue]` -## Error Handling +The single blessed venue authoring path. Apply it to the adapter's `impl VenueAdapter for MyVenue` block: the macro reads the crate's `module.toml`, asserts its `[module] kind` is `venue-adapter`, synthesizes a per-component world exporting the `videre:venue/adapter` face and importing exactly the manifest's declared scoped transport, then emits the `wit_bindgen::generate!` call, the untouched trait impl, and the export glue. An undeclared capability's bindings do not exist (using one is a compile error), and a capability outside the venue-permitted set (`chain`, `messaging`, `http`) is rejected at expansion. The generated world remaps the shared interfaces onto `videre_sdk::bindings`, so the impl speaks `videre_sdk` types directly and shares type identity with the conformance kit and the client core. -In 0.2 each interface declares its own typed error, and they share one payload-bearing `Fault` vocabulary for the cross-domain cases. The SDK exposes `Fault`, the `HostFault` trait (recovers an embedded fault plus a stable snake_case label), and the richer `ChainError`. A `From for Fault` fold lets a strategy aggregating store and chain calls `?`-propagate into one `Fault`. +### The typed client and `#[videre_sdk::keeper]` -```rust -pub enum Fault { - Unsupported(String), - Unavailable(String), - Denied(String), - RateLimited(RateLimit), // { retry_after_ms: Option } - Timeout, - InvalidInput(String), - Internal(String), -} - -/// Recovers the shared fault from a richer, per-interface error, plus a -/// stable snake_case label for logs and metrics. -pub trait HostFault { - fn fault(&self) -> Option<&Fault>; - fn label(&self) -> &'static str; -} - -/// The chain interface embeds `Fault` and adds a structured JSON-RPC case. -pub enum ChainError { - Fault(Fault), - Rpc(RpcError), // { code: i32, message: String, data: Option> } -} - -pub type Result = core::result::Result; -``` - -Interfaces with nothing to add report `Fault` directly (identity, local-store, remote-store, messaging, and the module exports). The module exports return `Result<(), Fault>`; module-defined failures are plain `Fault` cases, and the supervisor supplies the module name and derives its log kind from the fault label. Module authors use `?` naturally, and match on the case (or on `ChainError::Rpc` for a revert) for retry/backoff: +The wire carries opaque bodies and a stringly venue selector; typing is recovered in `videre_sdk::client`. A keeper names a venue once, as a `Venue` marker carrying its `VenueId` and body schema: ```rust -// Universal module -async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let num = provider.get_block_number().await?; // ChainError -> Fault via From - let decoded = MyCall::abi_decode_returns(&data) - .map_err(|e| Fault::InvalidInput(e.to_string()))?; // module-defined - TypedState::set("last", &decoded)?; // store Fault - let sig = Signer::sign(&account, &data)?; // identity Fault - Ok(()) -} - -// Inspecting the case for retry decisions -match host.request(chain_id, "eth_blockNumber", "[]") { - Ok(n) => Ok(n), - Err(ChainError::Fault(Fault::Unavailable(_) | Fault::Timeout)) => retry(), - Err(ChainError::Fault(Fault::RateLimited(rl))) => backoff(rl.retry_after_ms), - Err(ChainError::Rpc(rpc)) => decode_revert(rpc.data), // structured revert - Err(e) => Err(e.into()), -} - -// CoW module -async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let num = provider.get_block_number().await?; // chain error - TypedState::set("last", &num)?; // store Fault - Cow::new(block.chain_id).submit_order(&order)?; // cow-api-error - Ok(()) +struct CowVenue; +impl Venue for CowVenue { + const ID: VenueId = VenueId::from_static("cow"); + type Body = CowIntentBody; } ``` -See [ADR-0011](adr/0011-per-interface-typed-errors.md) for the model and the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder-side mapping of backend signals (HTTP codes, transport errors, wallet rejections) to `fault` cases. +`VenueClient` then drives the venue with typed bodies - `quote` returns a `Quoted` typestate whose `submit` sends exactly the priced bytes - encoding through `IntentBody` before the byte-level, native-AFIT `VenueTransport` seam. `HostVenues` binds the seam to the module's own `videre:venue/client` import; tests implement `VenueTransport` in memory. -## Testing Framework +`#[videre_sdk::keeper]` is the keeper mirror of `#[module]`: apply it to an `impl` block whose associated functions are the event handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_intent_status`). It requires the `client` capability (the `videre:venue/client` import is what makes a keeper a keeper), wires that import onto the SDK's shared shims, and lets handlers be `async` so they can await the typed client directly; `videre_sdk::client::poll_once` completes the futures on the synchronous guest boundary. A `From` impl onto the wire fault is emitted, so `?` applies to client calls inside handlers. -### Universal: `nexum-sdk` Mock Host +`videre_sdk::keeper::Keeper::run` assembles the world-neutral `nexum_sdk::keeper` stores - `WatchSet` to `Gates` to `Poller::poll` to `Retrier` to `Journal` - over the `VenueTransport` seam, so a conditional-commitment keeper writes one `poll` producing the shared `Outcome` outcome and inherits the whole gate/journal/retry pass. -The `nexum-sdk` provides a mock host so modules can be tested without a live blockchain or runtime. +### Testing: `nexum-sdk-test` and `videre-test` -```rust -use nexum_sdk::testing::{MockHost, MockChain}; - -#[test] -fn test_monitor_processes_block() { - let mut host = MockHost::new(); - - // Set up mock chain state - host.chain(42161) - .block_number(19_000_001) - .mock_call( - "0xfdaFc9d...", // contract address - &get_active_orders_calldata(), // expected calldata - &mock_orders_response(), // return value - ); - - // Pre-populate local-store (simulating previous run) - host.local_store() - .set("last_block", &19_000_000u64.to_le_bytes()); - - // Dispatch a block event - let result = host.dispatch(Event::Block(Block { - chain_id: 42161, - number: 19_000_001, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, // ms since epoch - })); - - assert!(result.is_ok()); - - // Verify local-store was updated - let last = host.local_store().get::("last_block").unwrap(); - assert_eq!(last, Some(19_000_001)); -} -``` - -### Identity Mocking - -The `MockHost` supports identity mocking for modules that use signing: +Keeper logic tests exactly as module logic does: against the host traits with `nexum_sdk_test::MockHost`, plus an in-memory `VenueTransport` for the client seam. Tests run as plain native Rust - no `wasm32-wasip2` target, no wasmtime instance, no network. ```rust -use nexum_sdk::testing::{MockHost, MockIdentity}; - -#[test] -fn test_module_signs_data() { - let mut host = MockHost::new(); - - // Configure mock identity with test accounts - host.identity() - .add_account(hex::decode("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045").unwrap()) - .on_sign(|account, data| { - // Return a mock 65-byte signature - Ok(vec![0u8; 65]) - }) - .on_sign_typed_data(|account, typed_data| { - // Return a mock 65-byte signature for EIP-712 - Ok(vec![0u8; 65]) - }); - - let result = host.dispatch(Event::Block(Block { - chain_id: 1, - number: 19_000_001, - hash: vec![0; 32], - timestamp: 1700000000, - })); - - assert!(result.is_ok()); - - // Verify signing was called - assert_eq!(host.identity().sign_calls().len(), 1); -} -``` - -### CoW Protocol: `shepherd-sdk` Mock Extensions - -The `shepherd-sdk` extends `MockHost` with CoW-specific assertions: - -```rust -use shepherd_sdk::testing::{MockHost, MockCow}; - -#[test] -fn test_twap_monitor_submits_order() { - let mut host = MockHost::new(); - - host.chain(42161).block_number(19_000_001); +use nexum_sdk::host::*; +use nexum_sdk_test::MockHost; - let result = host.dispatch(Event::Block(Block { - chain_id: 42161, - number: 19_000_001, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - })); +let host = MockHost::new(); +host.chain.respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); - assert!(result.is_ok()); - - // Verify an order was submitted (CoW-specific) - assert_eq!(host.submitted_orders().len(), 1); -} +assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); +assert_eq!(host.chain.calls().len(), 1); ``` -### MockHost Internals +Adapters are held to the `videre-test` conformance kit instead: -```rust -// nexum-sdk: universal mock host -pub struct MockHost { - local_store: HashMap>, - chains: HashMap, - identity: MockIdentity, - logs: Vec<(Level, String)>, -} +- **`CodecVectors`** - the venue's `IntentBody` wire bytes as a JSON +file (bytes as lowercase hex). A Rust adapter checks its derived enum with `assert_conforms`; a non-Rust author reads the same file and proves byte-exactness without linking Rust. +- **`HeaderGoldens`** - published bodies paired with the header a +conforming `derive-header` projects from them. +- **`MockTransport`** - the three transports an adapter is granted +(chain, messaging, outbound HTTP) as programmable in-memory mocks behind the SDK's own seams. -pub struct MockChain { - block_number: u64, - /// Maps (method, params_json) -> result_json for RPC mocking - rpc_mocks: HashMap<(String, String), String>, - logs: Vec, -} +(The runtime crate separately ships a feature-gated component-level harness - `nexum-runtime`'s `test_utils::TestRuntime` - that loads a compiled `.wasm` plus manifest under real wasmtime; that is runtime-internal tooling, not part of either SDK contract.) -pub struct MockIdentity { - accounts: Vec>, - sign_handler: Option Result>>>, - sign_typed_data_handler: Option Result>>>, - sign_calls: Vec<(Vec, Vec)>, - sign_typed_data_calls: Vec<(Vec, String)>, -} +## Walkthrough: authoring a venue on videre -// shepherd-sdk: extends with CoW-specific fields -pub struct CowMockHost { - inner: MockHost, // universal mock - submitted_orders: Vec<(u64, Vec)>, - cow_requests: Vec<(u64, String, String, Option)>, -} -``` +The shipped reference pair is `videre/modules/examples/echo-venue` (adapter) and `videre/modules/examples/echo-keeper` (driver); the production instance of the same shape is `shepherd/crates/cow-venue` driven by `shepherd/modules/twap-monitor`. -The mock host implements the same trait interface as the real host. Tests run as native Rust (not compiled to WASM) -- the mock substitutes for the WIT imports. +1. **Declare the manifest.** A venue adapter is a component with a +`module.toml` whose kind names it: -For alloy `Provider`-based tests, the `nexum-sdk` also provides `MockProvider` (backed by alloy's `Asserter`-based mock transport) -- see doc 07 for details. + ```toml + [module] + name = "echo-venue" # the venue id the registry installs it under + version = "0.1.0" + kind = "venue-adapter" + component = "sha256:..." -### Integration Testing + [capabilities] + required = ["chain"] # scoped transport only: chain, messaging, http -For tests that compile to WASM and run in a real wasmtime instance: + [capabilities.http] + allow = [] -```rust -use nexum_sdk::testing::WasmTestHarness; - -#[test] -fn test_module_as_component() { - let harness = WasmTestHarness::new("target/wasm32-wasip2/release/twap_monitor.wasm"); - - harness.mock_chain(42161).block_number(100); - harness.call_init(vec![("api_url".into(), "mock".into())]).unwrap(); - - let result = harness.call_on_event(Event::Block(Block { - chain_id: 42161, - number: 100, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - })); - assert!(result.is_ok()); -} -``` + [venue] + body_versions = [1] # must equal the body-versions export; install asserts it + ``` -This tests the full component boundary (canonical ABI marshalling, host function binding). +2. **Implement `VenueAdapter`.** One impl block under the macro; the +five intent functions plus `init` and `body_versions`: -## Project Scaffolding + ```rust + use videre_sdk::{IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, VenueError}; -### `cargo-nexum` CLI + struct EchoVenue; -> **Future direction, not in 0.2 scope.** The `cargo-nexum` cargo subcommand described in this section does not ship in 0.2. Module authors today build with `cargo build --target wasm32-wasip2 --release` (the M5 reference repo includes a `justfile` with the canonical recipes). A `cargo-nexum` (or successor) scaffolding/packaging CLI is on the 0.3 roadmap. -> -> **Two separate tools (design intent):** `cargo-nexum` would be a cargo subcommand for **module authors** (new, build, package, publish). The `nexum` binary is the **operator runtime** (run, module list/restart, local-store purge). Embedders bypass the binary entirely and drive the runtime through the `nexum-runtime` library. + #[videre_sdk::venue] + impl VenueAdapter for EchoVenue { + fn init(_config: Config) -> Result<(), Fault> { Ok(()) } + fn body_versions() -> Vec { vec![1] } + fn derive_header(body: Vec) -> Result { /* pure, no I/O */ } + fn quote(body: Vec) -> Result { /* ... */ } + fn submit(body: Vec) -> Result { /* ... */ } + fn status(receipt: Vec) -> Result { /* ... */ } + fn cancel(receipt: Vec) -> Result<(), VenueError> { /* ... */ } + } + ``` -```bash -cargo nexum new my-module -``` +`derive_header` is the policy seam: it projects the guard-facing `IntentHeader` (`gives`, `wants`, `settlement`, `authorisation`) from a body, pure and I/O-free, and the host's egress guard runs on it before every submit. -Generates: +3. **Publish the fixtures.** Ship the venue's codec vectors and header +goldens, and hold the adapter to them with `videre-test` in the crate's tests. Non-Rust keeper authors read the same files. -``` -my-module/ -├── Cargo.toml -├── module.toml # manifest template -└── src/ - └── lib.rs # minimal module skeleton -``` - -#### Universal module (targeting `nexum:host/event-module`) - -`Cargo.toml`: -```toml -[package] -name = "my-module" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nexum-sdk = "0.2" - -[package.metadata.component] -package = "my:module" -``` - -`src/lib.rs`: -```rust -use nexum_sdk::prelude::*; - -#[nexum::module] -struct MyModule; - -impl MyModule { - fn init(config: Config) -> Result<()> { - info!("module initialised"); - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let block_num = provider.get_block_number().await?; - info!("block {} on chain {}", block_num, block.chain_id); - Ok(()) - } - - async fn on_chain_logs(logs: Vec, provider: &RootProvider) -> Result<()> { - info!("received {} logs", logs.len()); - Ok(()) - } - - fn on_tick(tick: Tick) -> Result<()> { - info!("tick fired at {} ms UTC", tick.fired_at); - Ok(()) - } -} -``` - -#### CoW Protocol module (targeting `shepherd:cow/shepherd`) +4. **Build and install.** Build the cdylib for `wasm32-wasip2` +(`cargo build --target wasm32-wasip2 --release -p echo-venue`) and install it via the engine config: -`Cargo.toml`: -```toml -[package] -name = "my-cow-module" -version = "0.1.0" -edition = "2024" + ```toml + [[adapters]] + path = "target/wasm32-wasip2/release/echo_venue.wasm" + manifest = "videre/modules/examples/echo-venue/module.toml" + http_allow = [] # the operator's outbound-HTTP grant + ``` -[lib] -crate-type = ["cdylib"] +Install boots the component, checks the `body-versions` handshake against the manifest, and registers the venue under its manifest name in the `VenueRegistry` ([doc 08](08-platform-generalisation.md)). -[dependencies] -shepherd-sdk = "0.2" +5. **Drive it from a keeper.** A keeper declares the `client` +capability plus its `[venue] body_version`, names the venue as a `Venue` marker, and speaks types end to end: -[package.metadata.component] -package = "my:module" -``` - -`src/lib.rs`: -```rust -use shepherd_sdk::prelude::*; - -#[shepherd::module] -struct MyCowModule; - -impl MyCowModule { - fn init(config: Config) -> Result<()> { - info!("module initialised"); - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let block_num = provider.get_block_number().await?; - info!("block {} on chain {}", block_num, block.chain_id); - Ok(()) - } - - async fn on_chain_logs(logs: Vec, provider: &RootProvider) -> Result<()> { - info!("received {} logs", logs.len()); - Ok(()) - } + ```rust + #[videre_sdk::keeper] + impl EchoKeeper { + async fn on_block(block: types::Block) -> Result<(), Fault> { + let venue = VenueClient::::new(); + let quoted = venue.quote(&EchoBody::V1(block.number)).await?; + if let SubmitOutcome::Accepted(receipt) = quoted.submit().await? { + let _status = venue.status(&receipt).await?; + } + Ok(()) + } + } + ``` - fn on_tick(tick: Tick) -> Result<()> { - info!("tick fired at {} ms UTC", tick.fired_at); - Ok(()) - } -} -``` +An accepted submit is watched implicitly: the registry polls the adapter's `status` and fans transitions back as `intent-status` events, which the keeper subscribes to in its manifest and handles in `on_intent_status`. -`module.toml` (same for both): -```toml -[module] -name = "my-module" -version = "0.1.0" -description = "" -authors = [] -component = "sha256:TODO" +## The CoW venue -[module.resources] -max_memory_bytes = 10_485_760 -max_fuel_per_event = 100_000 -max_state_bytes = 52_428_800 +CoW ships as the production instance of the persona, in two crates so the venue stays orderbook-only: -[module.restart] -max_consecutive_failures = 10 +- **`cow-venue`** - feature slices. `body` (default, `no_std`): the +order intent body types and codec, light enough for any keeper or adapter to carry. `client`: the typed `CowClient` bound to the CoW venue, the deterministic `intent_id` journal key, and the table-driven retry classification generated from the shipped `data/classification.toml`. `assembly`: the chain-edge order projections and orderbook submission bodies. `adapter`: the venue adapter component itself (`CowAdapter` under `#[videre_sdk::venue]`, manifest at `shepherd/crates/cow-venue/module.toml`). +- **`composable-cow`** - the ComposableCoW keeper machinery, kept out +of the venue: the conditional-order `ComposableBody`, the structured poll seam (`Verdict`, with the deployed 1.x reverting wire quarantined behind `LegacyRevertAdapter`, per [ADR-0013](adr/0013-composable-cow-structured-poll.md)), and the `run` slice composing the poll loop over the typed `CowClient`. -[chains] -required = [] -optional = [] +The shipped CoW keepers - `shepherd/modules/twap-monitor`, `shepherd/modules/ethflow-watcher` - are ordinary `#[videre_sdk::keeper]` modules on this surface. -[capabilities] -required = ["chain", "local-store", "logging"] -optional = [] +## Non-Rust module and adapter authors -[config] -``` - -### Build +For **non-Rust** authors (JavaScript, Python, Go, C++), neither SDK is relevant - they generate bindings directly from the WIT package for their target world with their language's `wit-bindgen`, and prove body conformance against the venue's published `videre-test` fixture files. The WIT is the universal contract; both Rust SDKs are an ergonomics layer on top of it, not a requirement. -```bash -cargo component build --release -# -> target/wasm32-wasip2/release/my_module.wasm -``` - -### Package - -```bash -cargo nexum package -# Computes sha256, updates module.toml, creates bundle directory -``` - -### Publish to Swarm - -```bash -cargo nexum publish --swarm http://localhost:1633 --batch-id -# Uploads bundle to Swarm, prints content reference -``` +## Where to go next -## SDK / Runtime Version Compatibility - -The WIT definition is versioned (`nexum:host@0.2.0`). The SDK pins this version. When the WIT evolves: - -- **Patch** (0.2.x): backwards-compatible additions (new host functions, new manifest fields, new SDK helpers). Old modules continue to work. -- **Minor** (0.x.0): may add new required exports. Old modules need recompilation. -- **Major** (x.0.0): breaking changes. Runtime supports multiple world versions during transition. - -The `bindgen!` macro on the host side uses wasmtime's **semver-aware resolution** -- a host implementing `@0.2.1` satisfies a guest compiled against `@0.2.0`. - -0.2 is the coordinated breaking-change window relative to 0.1. The 0.2.0 contracts (WIT package name, interface names, the per-interface typed errors over the shared `fault` vocabulary, the `module.toml` schema, the `#[nexum::module]` macro surface) are stable starting at 0.2.0 -- see the [migration guide §10](migration/0.1-to-0.2.md#10-deprecation-policy-going-forward-both) for the full deprecation policy. - -## Summary - -| SDK Layer | Provides | -|-----------|----------| -| `#[nexum::module]` | Eliminates WIT boilerplate; named event handlers (`on_block`, `on_chain_logs`, `on_tick`, `on_message`); `async fn` + provider injection (universal) | -| `#[shepherd::module]` | Same as above, targeting CoW Protocol's `shepherd:cow/shepherd` world | -| `provider(chain_id)` | Full alloy `Provider` backed by host RPC via `HostTransport` (including 0.2's `chain::request-batch` for real wire-level batching) | -| `Signer` | Typed identity client for accounts, signing, and EIP-712 (nexum-sdk) | -| `Cow` | Typed CoW Protocol API client backed by host `cow-api` interface (shepherd-sdk only) | -| `nexum_sdk::prelude::*` | Universal types, interfaces, alloy re-exports in one import | -| `shepherd_sdk::prelude::*` | CoW-specific types and interfaces; the universal surface comes from `nexum_sdk::prelude::*` | -| `TypedState` | Serde-based typed local-store over raw bytes | -| `sol!` | Compile-time Ethereum ABI codec (alloy-sol-types) | -| `log::{info!, ...}` | Formatted logging macros | -| `Fault` / `HostFault` / `ChainError` / `Result` | Per-interface typed errors over the shared `fault` vocabulary, with `?` support and case-based matching | -| `nexum_sdk::testing::MockHost` | Native-Rust unit tests with universal mock host (includes identity mocking) | -| `shepherd_sdk::testing::MockHost` | Extends universal mock with CoW-specific assertions | -| `testing::MockProvider` | alloy `Provider` mock for RPC-level testing | -| `testing::WasmTestHarness` | Integration tests against real wasmtime | -| `cargo nexum` | new / build / package / publish / check / migrate CLI | +- [`sdk.md`](sdk.md) - the day-to-day API reference and rustdoc entry +point. +- [ADR-0009](adr/0009-host-trait-surface.md) - the host-trait seam +decision this document builds on. +- [ADR-0011](adr/0011-per-interface-typed-errors.md) - the typed +error model the host traits return. +- [doc 07](07-rpc-namespace-design.md) - the `chain` RPC passthrough +design and why module authors call `host.request` directly. +- [doc 08](08-platform-generalisation.md) - the layered WIT and why +venue adapters are the domain-extension mechanism. diff --git a/docs/06-production-hardening.md b/docs/06-production-hardening.md index e1944384..d4299596 100755 --- a/docs/06-production-hardening.md +++ b/docs/06-production-hardening.md @@ -1,473 +1,59 @@ -# Production Hardening & Observability +# Production hardening -## Resource Enforcement +The design facts behind the runtime's resource enforcement, restart policy, RPC resilience, and error model. Deploy procedures live in [`docs/production.md`](./production.md); the metric surface and alert rules in [`docs/production.md`](./production.md) §6-7; containers in [`docs/deployment/docker.md`](./deployment/docker.md). -Four resource dimensions are capped per module. **In 0.2, caps come from compile-time global defaults** (`DEFAULT_FUEL_PER_EVENT = 1_000_000_000`, `DEFAULT_MEMORY_LIMIT = 64 MiB` in `crates/nexum-runtime/src/runtime/limits.rs`); per-module overrides via the manifest's `[module.resources]` section are a future direction (0.3). The mechanism and shape below describe what the 0.2 engine actually enforces - using global values where this doc previously read `module_config.max_*`. +## Resource enforcement -### CPU: Fuel +Four dimensions are capped per module. Caps come from `[limits]` in `engine.toml`, resolving to built-in defaults (`nexum/crates/nexum-runtime/src/engine_config.rs`). They apply uniformly to every module; per-module overrides land in 0.3. -Each `on_event` call is budgeted `DEFAULT_FUEL_PER_EVENT` fuel units. Exhaustion traps the call (state rolled back -- see doc 04). The budget prevents infinite loops and excessive computation. +### Fuel -```rust -store.set_fuel(DEFAULT_FUEL_PER_EVENT)?; -``` +Each `on_event` call is granted `fuel_per_event` fuel (default 1_000_000_000, ~1s of compute). Exhaustion traps the call and rolls back its state (doc 04). Fuel is deterministic: the same WASM consumes the same fuel regardless of host speed. It meters only guest instructions. -Fuel is deterministic -- the same WASM code consumes the same fuel regardless of host machine speed. +### Wall-clock deadline -### CPU: Epoch (wall-clock) - -A background Tokio task increments the engine epoch on a fixed interval (e.g. 100ms). Each module store has a deadline: if a callback exceeds N epochs, it yields back to the Tokio runtime. This prevents one module from starving others even if fuel is generous. - -```rust -// Runtime startup -let engine = engine.clone(); -tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_millis(100)); - loop { - interval.tick().await; - engine.increment_epoch(); - } -}); - -// Per-module store setup -store.epoch_deadline_async_yield_and_update(10); // yield after 10 epochs (~1s) -``` +Fuel does not meter time spent in host calls (chain RPC, HTTP, redb). A per-dispatch wall-clock deadline (`event_deadline_secs`, default 120, floor 1) is the backstop: the supervisor runs each dispatch under a `tokio::time::timeout`, and a dispatch that outlives it, guest plus every awaited host call, is cancelled and the module marked dead. Fuel and the deadline are complementary, not redundant. ### Memory -The engine builds a `wasmtime::StoreLimitsBuilder` with `DEFAULT_MEMORY_LIMIT` and attaches it to each module's store at dispatch time (see `crates/nexum-runtime/src/supervisor.rs`). `memory.grow` is denied past the cap. Future direction: a per-module `ResourceLimiter` driven by `module_config.max_memory_bytes` from the manifest; not in 0.2 scope. +The supervisor attaches a `wasmtime::StoreLimitsBuilder` built from `memory_bytes` (default 64 MiB) to each module store; `memory.grow` past the cap is denied. ### Storage -Local-store quota is enforced on the `local-store::set` host path. The 0.2 engine treats the cap as a host-side constant; a manifest-driven `max_state_bytes` is future direction. Rejected with a clear `fault` (see doc 04), not a trap -- the module can handle it gracefully. - -### Summary +The local-store byte quota (`state_bytes`, default 50 MiB) is enforced on the `local-store::set` host path. Overrun is rejected with a `fault.invalid-input`, not a trap, so the module can handle it. | Resource | Mechanism | Failure mode | |----------|-----------|-------------| -| CPU (deterministic) | Fuel | Trap -> rollback -> restart | -| CPU (wall-clock) | Epoch interruption | Yield -> resume or trap | -| Memory | `ResourceLimiter` | `memory.grow` returns -1 | +| CPU (guest instructions) | Fuel | Trap, rollback, restart | +| Wall-clock | Per-dispatch tokio timeout | Cancel, module marked dead | +| Memory | `StoreLimits` | `memory.grow` denied | | Storage | Host-side byte tracking | `local-store::set` returns `fault.invalid-input` | -## Crash Handling & Restart Policy - -### Restart with Exponential Backoff - -When a module's `on_event` (or `init`) traps or returns `Err`: - -``` -Attempt 1: restart after 1s -Attempt 2: restart after 2s -Attempt 3: restart after 4s -Attempt 4: restart after 8s -... -Attempt N: restart after min(2^(N-1), 300)s <- capped at 5 minutes -``` - -A successful `on_event` resets the backoff counter to zero. - -```rust -struct RestartPolicy { - consecutive_failures: u32, - max_backoff: Duration, // 5 minutes - base: Duration, // 1 second -} - -impl RestartPolicy { - fn next_backoff(&self) -> Duration { - let backoff = self.base * 2u32.saturating_pow(self.consecutive_failures - 1); - backoff.min(self.max_backoff) - } - - fn record_success(&mut self) { - self.consecutive_failures = 0; - } - - fn record_failure(&mut self) { - self.consecutive_failures += 1; - } -} -``` - -### Poison Pill Detection - -A module that crashes on every event is a poison pill. After `max_consecutive_failures` (default: 10), the module transitions to `Dead` state: - -```mermaid -flowchart TD - A["Consecutive failures >= 10"] --> B["Module state -> Dead"] - B --> C["All event dispatch stops"] - B --> D["Alert emitted (metric + log)"] - B --> E["Requires manual intervention"] - E --> F["nexum module restart twap-monitor"] - E --> G["nexum module reload twap-monitor\n(re-fetch + recompile)"] -``` - -The threshold is configurable per-module in the manifest under `[module.restart]` (separate from resource caps): - -```toml -[module.restart] -max_consecutive_failures = 10 -``` - -### Restart Scope - -A restart creates a fresh `Store` (clean WASM memory) but reuses the `InstancePre` (no recompilation). The module's `init` is called again. Local-store data persists (doc 04). - -## RPC Resilience - -All RPC I/O flows through alloy providers configured by the runtime operator. The `chain::request` host function (see doc 07) forwards to the provider, which is wrapped with resilience layers using alloy's tower-based middleware. The additive `chain::request-batch` (0.2) routes alloy's `RequestPacket::Batch` to actually batch on the wire. - -### Provider Stack +## Restart policy -```mermaid -flowchart TD - A["Module calls chain::request\n(via alloy Provider in SDK)"] --> B["Host chain::request impl\n-> alloy Provider"] - B --> C["Timeout\n(10s default)"] - C --> D["Retry\n(3 attempts, exponential\nbackoff + jitter)"] - D --> E["Rate Limit\n(per-endpoint)"] - E --> F["Fallback\n(primary -> secondary)"] - F --> G["RPC Endpoint"] +When a module's `init` or `on_event` traps or returns `Err`, the supervisor restarts it after an exponential backoff: 1s, 2s, 4s, 8s, doubling to a 300s (5 min) cap (`runtime/restart_policy.rs`). A restart creates a fresh `Store` (clean WASM memory) but reuses the compiled `InstancePre`; `init` runs again and local-store data persists (doc 04). A successful dispatch resets the counter. - subgraph Tower Layer Stack - C - D - E - F - end -``` +A module that keeps trapping is a poison pill. After `max_failures` traps within a sliding `window_secs` (`[limits.poison]`, default 5 / 600s in `runtime/poison_policy.rs`), the module is quarantined: dispatch to it stops, the `shepherd_module_poisoned` gauge goes to `1`, and a WARN is logged. Quarantine clears only on an engine restart. Venue adapters follow the same policy under the `shepherd_adapter_poisoned` gauge. -### Operator Configuration +## RPC resilience -```toml -[[chains]] -chain_id = 42161 -name = "arbitrum" +RPC I/O flows through one alloy provider per chain, opened from `engine.toml` at boot (`nexum/crates/nexum-runtime/src/host/provider_pool.rs`). Each chain has a single `rpc_url`; there is no secondary-endpoint failover. The `chain::request` host function forwards the typed method to the provider. -[[chains.endpoints]] -url = "wss://arb-mainnet.g.alchemy.com/v2/KEY" -priority = 1 +Two layers harden it: -[[chains.endpoints]] -url = "https://arb1.arbitrum.io/rpc" -priority = 2 # fallback +- A transport `RetryBackoffLayer` (10 retries, 300ms base backoff, 100 CU/s pacing) heals transient node blips below the poller, so a momentary hiccup does not force a stream re-open. +- A per-request timeout (`request_timeout_secs`, default 30) bounds each `chain::request`; it does not apply to the long-lived subscription and log-poller streams. -[chains.rpc_policy] -timeout_ms = 10_000 -max_retries = 3 -rate_limit_per_second = 50 -``` +Block following uses `eth_subscribe(newHeads)` on a WebSocket URL and polls `eth_getBlockByNumber` on HTTP; logs poll `eth_getLogs` on either transport. On a dropped WebSocket the event loop reconnects with backoff, then backfills the gap between the last dispatched block and head so modules do not silently miss events. A `resume` chain-log subscription persists its cursor under `last_dispatched_block:{chain_id}` and resumes from it across restarts. -### Subscription Reconnection +## Error model -WebSocket subscriptions (`eth_subscribe`) drop when the connection is lost. The event source manager detects this and reconnects: +Host interfaces surface a common `fault` variant (`wit/nexum-host/types.wit`): `unsupported`, `unavailable`, `denied`, `rate-limited` (carrying `retry-after-ms` guidance), `timeout`, `invalid-input`, `internal`. A fault is a typed, recoverable return the guest can handle; a trap (fuel, memory, panic) is not, and drives the restart path above. -```mermaid -flowchart TD - A["Subscription drops"] --> B["Reconnect with backoff\n(1s, 2s, 4s, ... 30s cap)"] - B --> C["On reconnect: check for missed blocks"] - C --> D["Query eth_blockNumber"] - C --> E["Compare with last dispatched block"] - C --> F["Backfill missed blocks\nvia eth_getBlockByNumber"] - F --> G["Resume live subscription"] -``` +## Structured logging -This ensures modules don't silently miss events during RPC outages. - -## Structured Logging - -### Stack: `tracing` + `tracing-subscriber` - -All runtime logging uses the `tracing` crate with structured fields, output as JSON in production: - -```rust -use tracing::{info, warn, error, instrument, Span}; - -#[instrument(skip(store), fields(module = %module_id, chain_id))] -async fn dispatch_event(module_id: &str, event: &Event, store: &mut Store) { - info!(event_type = %event.type_name(), "dispatching event"); - // ... -} -``` - -### Log Contexts - -Every log line includes: - -| Field | Source | -|-------|--------| -| `module` | Module name from manifest | -| `chain_id` | Chain the event originated from | -| `event_type` | `block` / `logs` / `tick` / `message` | -| `block_number` | For block/log events | -| `level` | trace / debug / info / warn / error | -| `timestamp` | ISO 8601 | -| `span_id` | Tracing span (correlates related logs) | - -### Module Guest Logs - -When a module calls `logging::log(level, message)`, the host writes a `tracing` event tagged with the module's context: - -```rust -impl logging::Host for NexumHostState { - fn log(&mut self, level: Level, message: String) { - let span = tracing::info_span!("module", module = %self.module_id); - let _enter = span.enter(); - match level { - Level::Trace => tracing::trace!("{message}"), - Level::Debug => tracing::debug!("{message}"), - Level::Info => tracing::info!("{message}"), - Level::Warn => tracing::warn!("{message}"), - Level::Error => tracing::error!("{message}"), - } - } -} -``` - -### Output Formats - -| Environment | Format | Config | -|-------------|--------|--------| -| Development | Pretty, coloured | `RUST_LOG=nexum=debug` | -| Production | JSON, one line per event | `--log-format json` | - -```json -{ - "timestamp": "2026-02-18T12:00:00.123Z", - "level": "INFO", - "module": "twap-monitor", - "chain_id": 42161, - "event_type": "block", - "block_number": 19000001, - "message": "posted TWAP part 3/10", - "span_id": "abc123" -} -``` +All runtime logging uses `tracing` with structured fields, emitted as JSON in production (or the pretty format under `--pretty-logs`). Every per-module event carries the module name and, on chain events, the `chain_id` and `block_number`. When a guest calls `logging::log`, the host writes a `tracing` event tagged with the module's context. Log operations (retention, aggregation) are in [`docs/production.md`](./production.md) §5. ## Metrics -### Stack: `metrics` crate + Prometheus exporter - -The `metrics` crate provides a facade (like `log` for logging). We use `metrics-exporter-prometheus` to expose a `/metrics` HTTP endpoint. - -> Note: The OpenTelemetry Prometheus exporter crate is deprecated. The `metrics` + `metrics-exporter-prometheus` combo remains the simplest, most stable path for Prometheus scraping. - -### Metric Definitions - -#### Runtime-level - -| Metric | Type | Labels | Description | -|--------|------|--------|-------------| -| `nexum_modules_loaded` | Gauge | -- | Number of modules currently in Run state | -| `nexum_modules_dead` | Gauge | -- | Number of modules in Dead state | -| `nexum_uptime_seconds` | Counter | -- | Runtime uptime | -| `nexum_content_fetch_total` | Counter | `scheme` | Content store fetches by scheme | -| `nexum_content_fetch_errors` | Counter | `scheme` | Content store fetch failures | - -#### Per-module - -| Metric | Type | Labels | Description | -|--------|------|--------|-------------| -| `nexum_events_dispatched_total` | Counter | `module`, `event_type` | Events dispatched | -| `nexum_events_processed_total` | Counter | `module`, `event_type` | Events successfully processed | -| `nexum_events_failed_total` | Counter | `module`, `event_type` | Events that trapped or returned Err | -| `nexum_event_duration_seconds` | Histogram | `module`, `event_type` | Wall-clock time per on_event call | -| `nexum_fuel_consumed` | Histogram | `module` | Fuel consumed per on_event call | -| `nexum_restarts_total` | Counter | `module` | Total restart count | -| `nexum_consecutive_failures` | Gauge | `module` | Current consecutive failure count | -| `nexum_state_bytes_used` | Gauge | `module` | Current local-store usage in bytes | -| `nexum_memory_bytes_used` | Gauge | `module` | Current WASM linear memory size | - -#### Per-chain RPC - -| Metric | Type | Labels | Description | -|--------|------|--------|-------------| -| `nexum_rpc_requests_total` | Counter | `chain_id`, `method` | RPC calls made | -| `nexum_rpc_errors_total` | Counter | `chain_id`, `method`, `endpoint` | RPC errors | -| `nexum_rpc_duration_seconds` | Histogram | `chain_id`, `method` | RPC call latency | -| `nexum_rpc_fallbacks_total` | Counter | `chain_id` | Times a fallback endpoint was used | -| `nexum_subscription_reconnects_total` | Counter | `chain_id` | Subscription reconnection count | -| `nexum_blocks_behind` | Gauge | `chain_id` | Blocks behind head (0 = caught up) | - -#### Identity - -| Metric | Type | Labels | Description | -|--------|------|--------|-------------| -| `nexum_identity_sign_total` | Counter | `module`, `account` | Signing operations performed | -| `nexum_identity_errors_total` | Counter | `module`, `error_code` | Identity operation failures | - -### Exposition - -```toml -[metrics] -enabled = true -listen = "0.0.0.0:9090" -path = "/metrics" -``` - -```bash -curl http://localhost:9090/metrics -# HELP nexum_events_dispatched_total Events dispatched to modules -# TYPE nexum_events_dispatched_total counter -nexum_events_dispatched_total{module="twap-monitor",event_type="block"} 150234 -``` - -## Health Checks - -> **Future direction, not in 0.2 scope.** A dedicated `:8080/health` JSON endpoint is described below as design intent for 0.3. The 0.2 engine does **not** bind a separate health port; liveness is signalled by the metrics scrape (`:9100/metrics` returns 200 iff the engine is running and the Prometheus exporter is up) and by the structured `tracing` JSON on stdout (per-module state transitions and quarantine events). Docker / compose configurations use a TCP/bash health probe against the metrics port (see [`docs/deployment/docker.md`](deployment/docker.md)). - -### HTTP Health Endpoint (future direction) - -``` -GET /health -> 200 OK | 503 Service Unavailable -``` - -Intended response shape: - -```json -{ - "status": "healthy", - "uptime_seconds": 86400, - "modules": { - "twap-monitor": { "state": "running", "last_event_age_seconds": 2 }, - "ethflow-watcher": { "state": "running", "last_event_age_seconds": 5 } - }, - "chains": { - "42161": { "connected": true, "head_block": 19000500, "blocks_behind": 0 }, - "1": { "connected": true, "head_block": 21500000, "blocks_behind": 0 } - } -} -``` - -When this endpoint lands, health would be `unhealthy` if: -- Any required chain's RPC is disconnected. -- Any module is in `Dead` state. -- Last event age exceeds a configurable staleness threshold (suggests subscription dropped and backfill failed). - -```toml -[health] -listen = "0.0.0.0:8080" -stale_event_threshold_seconds = 60 -``` - -### Docker / Kubernetes (0.2 today) - -Today the metrics endpoint and the supervisor `tracing` stream cover the liveness signal. A TCP probe against the metrics port works as a liveness check (see `docker-compose.yml` and `docs/deployment/docker.md` for the shipped configuration). Once the dedicated `:8080/health` endpoint lands, the recommended probe shape would be: - -```yaml -livenessProbe: - httpGet: - path: /health - port: 8080 - periodSeconds: 10 -readinessProbe: - httpGet: - path: /health - port: 8080 - initialDelaySeconds: 5 -``` - -## Alerting Conditions - -The runtime itself doesn't send alerts -- it exposes metrics and health for external systems (Prometheus + Alertmanager, Grafana, PagerDuty, etc). Recommended alert rules: - -| Condition | Severity | Prometheus Expression | -|-----------|----------|-----------------------| -| Module entered Dead state | Critical | `nexum_modules_dead > 0` | -| High event failure rate | Warning | `rate(nexum_events_failed_total[5m]) / rate(nexum_events_dispatched_total[5m]) > 0.1` | -| Event processing latency spike | Warning | `histogram_quantile(0.99, nexum_event_duration_seconds) > 5` | -| RPC endpoint down | Critical | `nexum_rpc_errors_total` sustained increase with no successes | -| Chain falling behind | Warning | `nexum_blocks_behind > 10` | -| State store near quota | Warning | `nexum_state_bytes_used / nexum_state_bytes_limit > 0.9` | -| Subscription reconnect storm | Warning | `rate(nexum_subscription_reconnects_total[5m]) > 1` | -| Identity signing failures | Warning | `rate(nexum_identity_errors_total[5m]) > 0.5` | - -## Runtime Configuration Summary - -```toml -# -- Logging -- -[logging] -format = "json" # "json" | "pretty" -level = "info" # default filter level -module_level = "debug" # filter for module guest logs - -# -- Metrics -- -[metrics] -enabled = true -listen = "0.0.0.0:9090" -path = "/metrics" - -# -- Health (future direction; not bound in 0.2) -- -# [health] -# listen = "0.0.0.0:8080" -# stale_event_threshold_seconds = 60 - -# -- Epoch ticker -- -[runtime] -epoch_interval_ms = 100 -epoch_deadline = 10 # epochs before yield (~1s) - -# -- Resource defaults -- -# In 0.2 these come from compile-time constants in -# crates/nexum-runtime/src/runtime/limits.rs (DEFAULT_FUEL_PER_EVENT = 1B, -# DEFAULT_MEMORY_LIMIT = 64 MiB). Manifest-driven per-module overrides are -# a future direction (0.3). - -# -- Global restart defaults -- -# In 0.2 the restart policy is the global exponential backoff (1s -> 2s -> -# ... cap 5 min) defined in runtime/restart_policy.rs and the poison -# threshold (POISON_MAX_FAILURES = 5 within 600 s) in runtime/poison_policy.rs. -# Per-module overrides via [module.restart] are a future direction. -``` - -## Deployment: Docker - -The shipped Dockerfile lives at the repo root; see [`docs/deployment/docker.md`](deployment/docker.md) for the canonical operator-facing configuration. The shape is a multi-stage build with `tini` PID1, a non-root `shepherd` user, the five reference modules baked in, and the metrics port exposed: - -```dockerfile -FROM rust:1.96-slim-bookworm AS builder -# ... cargo build --release -p nexum-cli + module wasm builds ... - -FROM debian:bookworm-slim -COPY --from=builder /build/target/release/nexum /usr/local/bin/ -EXPOSE 9100 # metrics -ENTRYPOINT ["tini", "--", "nexum"] -``` - -```bash -docker run -d \ - -v /etc/shepherd:/etc/shepherd \ - -v /var/shepherd:/var/shepherd \ - -p 9100:9100 \ - shepherd:latest -``` - -Volumes: -- `/etc/shepherd/` -- engine config, module manifests. -- `/var/shepherd/` -- local-store (`state.redb`), content cache, logs. - -## Operational Runbook (CLI) - -```bash -# List loaded modules and their state -nexum module list - -# Restart a dead or failed module -nexum module restart twap-monitor - -# Reload a module (re-fetch wasm, recompile) -nexum module reload twap-monitor - -# Purge a module's local-store -nexum state purge --module twap-monitor - -# Compact the state database -nexum state compact - -# Check runtime health -nexum health - -# Dump metrics -nexum metrics -``` +The runtime records through the `metrics` crate facade. The `shepherd` binary installs a `metrics-exporter-prometheus` exporter (the Prometheus add-on) that binds `/metrics` on `[engine.metrics] bind_addr` when `enabled = true`; the bare `nexum` binary installs the recorder but binds no listener. The runtime emits no alerts itself. The metric surface and recommended alert rules are in [`docs/production.md`](./production.md) §6-7. diff --git a/docs/07-rpc-namespace-design.md b/docs/07-rpc-namespace-design.md index f7e8f542..79e4ef33 100755 --- a/docs/07-rpc-namespace-design.md +++ b/docs/07-rpc-namespace-design.md @@ -1,1247 +1,78 @@ -# RPC Namespace Design: Generic JSON-RPC Passthrough +# RPC Namespace Design: the `chain` interface -> **Status: Partially shipped.** The reference runtime ships the single -> `chain::request(chain_id, method, params)` WIT entry point, and the -> seam behind it is typed to a closed read-only method set: a method -> outside that surface is refused with a `chain-error` carrying a `denied` fault before it -> reaches the provider. The rest of the "Method Allowlisting" section -> below remains design intent: per-module `[module.chain] -> extra_allowed_methods` (now bounded by the typed surface rather than -> free-form) and identity-delegated signing methods are not wired into -> `chain::request` in the shipped binary. +Modules reach chain state through one host function, `chain.request`, plus a batch form `chain.request-batch`. A single generic JSON-RPC entry point means no WIT change per method: the guest SDK layers an alloy `Provider` on top, so every read method on the permitted surface works without host-side per-method plumbing. -> **Naming note (0.2):** This document describes the `chain` interface in the -> `nexum:host` WIT package. In the 0.1 design history it was called `chain` -> (short for "consensus"); 0.2 renamed it to `chain` because `chain.request(...)` -> reads itself at the call site. The function signatures below are the 0.2 shape, -> returning `chain-error` rather than the 0.1-era `json-rpc-error`. -> -> **SDK-shape note (0.2):** The macro-driven authoring model below (`#[nexum::module]` / `#[shepherd::module]` with named event handlers and `&RootProvider` injection) and the separate `nexum-sdk` crate are **future direction, not in 0.2 scope** - see [ADR-0009](adr/0009-host-trait-surface.md) for the shipped host-trait seam that replaces the macro design. 0.2 modules call `host.request(chain_id, method, params_json)` directly against `ChainHost`. The WIT contract for `chain` is unchanged; only the guest-side ergonomics differ. +## The WIT interface -## Problem Statement - -The 0.1 design started with a `blockchain` interface that defined individual functions for each Ethereum RPC method: +`nexum:host/chain` (`wit/nexum-host/chain.wit`): ```wit -interface blockchain { - eth-call: func(chain-id: chain-id, to: list, data: list) -> result, string>; - eth-get-logs: func(filter: log-filter) -> result, string>; - eth-block-number: func(chain-id: chain-id) -> result; -} -``` - -This creates several problems: - -1. **Boilerplate multiplication.** Every new `eth_` method requires changes in three places: WIT definition, host trait implementation, and SDK wrapper. The Ethereum JSON-RPC namespace has 30+ methods; most modules will need more than the three currently exposed. - -2. **Alloy incompatibility.** Module authors using Rust cannot use alloy's `Provider` API - which provides 80+ typed convenience methods - because the transport layer is locked behind per-method WIT functions. They're forced to manually ABI-encode calldata, call `blockchain::eth_call`, and ABI-decode the result for every interaction. - -3. **Namespace rigidity.** Adding a `cow_` namespace for CoW Protocol API calls would duplicate the same per-method pattern. Future namespaces (debug_, trace_, etc.) compound this further. - -The goal: **one WIT function to rule the entire `eth_` namespace**, with a guest-side SDK that gives module authors the full alloy `Provider` API - no manual ABI wrangling, no WIT changes when new methods are needed. - -## Design: Generic JSON-RPC Passthrough - -### Core Insight - -alloy's `Transport` trait is a Tower `Service`. If we expose a single JSON-RPC dispatch function in WIT, the SDK can implement `Transport` on top of it. This gives guest modules the entire alloy `Provider` API for free - every current and future `eth_` method works automatically. - -From the guest's perspective, host function calls are synchronous (they block until the host returns). The returned future resolves in a single poll. This means alloy's async `Provider` methods work with a trivial executor - no real async machinery needed. - -### Architecture - -```mermaid -flowchart TD - A["Module author code - provider.get_block_number() - provider.call(tx).latest() - provider.get_logs(&filter)"] -->|full alloy Provider API| B - - B["HostTransport (SDK) - implements alloy Transport trait"] -->|"chain::request(chain_id, "eth_blockNumber", "[]")"| C - - C["WIT boundary - single generic function"] --> D - - D["Host chain::request impl - forwards to alloy provider"] -->|"provider.raw_request_dyn(method, params)"| E - - E["Alloy provider stack - timeout -> retry -> rate-limit -> fallback -> RPC"] -``` - -## Updated WIT Interface - -Replace the `blockchain` interface with `chain`: - -```wit -package nexum:host@0.2.0; - interface chain { use types.{chain-id, fault}; - /// A structured JSON-RPC error carrying the node code and revert bytes. record rpc-error { code: s32, message: string, data: option> } - - /// Either a shared host `fault` or a structured JSON-RPC error. variant chain-error { fault(fault), rpc(rpc-error) } - /// Execute a JSON-RPC request against the specified chain. - /// - /// The host forwards the request to the configured alloy provider for the - /// given chain, applying timeout/retry/rate-limit/fallback middleware - /// transparently. The method string should include the namespace prefix - /// (e.g. "eth_call", "eth_getBlockByNumber"). - /// - /// `params` and the success return value are JSON-encoded strings matching - /// the JSON-RPC specification. The host handles id/jsonrpc framing; the - /// guest only provides method + params and receives the `result` field. + record rpc-request { method: string, params: string } + variant rpc-result { ok(string), err(chain-error) } + request: func(chain-id: chain-id, method: string, params: string) -> result; - - /// 0.2 additive: batched JSON-RPC. alloy's HostTransport routes - /// RequestPacket::Batch through this, so provider.multicall(...) actually - /// batches on the wire (it silently fanned-out single requests in 0.1). - request-batch: func(chain-id: chain-id, calls: list>) - -> result>, chain-error>; + request-batch: func(chain-id: chain-id, requests: list) + -> result, chain-error>; } ``` -Errors are reported via `chain-error`: either a shared `fault` (see doc 00, ADR-0011, and the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both)) or a structured `rpc` case carrying the node code and decoded revert bytes - the 0.1 `json-rpc-error` shape is gone. Modules match on the `fault` case (`unavailable`, `rate-limited`, `timeout`, `denied`, `invalid-input`, ...) for retry/backoff, and on the `rpc` case to decode a revert without parsing numeric JSON-RPC codes by hand. - -The `types` interface now exposes the shared `fault` / `rate-limit`. The `local-store`, `remote-store`, `messaging`, and `logging` interfaces are unchanged in shape (the first three report `fault` directly). - -The `identity` interface provides cryptographic identity - key management and signing: - -```wit -interface identity { - use types.{fault}; - - /// Get available signing accounts (20-byte Ethereum addresses). - accounts: func() -> result>, fault>; +`method` carries the namespace prefix (`eth_call`). `params` and the success value are JSON strings; the host frames the id/jsonrpc envelope. A failure is a `chain-error`: either a shared host `fault` (`unavailable`, `rate-limited`, `timeout`, `denied`, `invalid-input`, ...) that a module matches for retry and backoff, or a structured `rpc` case carrying the node code and the host-decoded revert bytes so a revert reads without parsing numeric JSON-RPC codes by hand. `request-batch` runs several calls against one chain in a single round trip where the transport supports it, falling back to sequential `request` otherwise; the result list matches `requests` in length and order, each entry independently `ok` or `err`. - /// Sign raw bytes with the specified account. - /// Returns a 65-byte ECDSA secp256k1 signature (r ‖ s ‖ v). - sign: func(account: list, data: list) -> result, fault>; +## Permitted method surface - /// Sign EIP-712 typed data with the specified account. - sign-typed-data: func(account: list, typed-data: string) -> result, fault>; -} -``` +The reference server host forwards only a closed read-only set, the `ChainMethod` enum in `nexum-world`. Host dispatch and the guest-side allowlist re-export the same type, so the two cannot drift. A method outside the set, which is every signing or mutating method, is refused with a `denied` fault before it reaches the provider. -The universal `event-module` world (in `nexum:host`) contains the platform-agnostic interfaces - six imports in 0.2: +The surface is `eth_blockNumber`, `eth_call`, `eth_chainId`, `eth_estimateGas`, `eth_feeHistory`, `eth_gasPrice`, `eth_maxPriorityFeePerGas`, `eth_getBalance`, `eth_getBlockByHash`, `eth_getBlockByNumber`, `eth_getBlockReceipts`, `eth_getCode`, `eth_getLogs`, `eth_getProof`, `eth_getStorageAt`, `eth_getTransactionByHash`, `eth_getTransactionCount`, `eth_getTransactionReceipt`, and `net_version`. -```wit -world event-module { - import chain; // replaces `import blockchain;` from the early 0.1 sketch - import identity; // cryptographic identity (key management, signing) - import local-store; - import remote-store; - import messaging; - import logging; +Enforcement is host-side string-to-`ChainMethod` resolution, not a compile-time guarantee. The Component Model already sandboxes I/O, so a chain-capable module can only call `chain.request`, and the closed surface adds method-level defence in depth on top. - export init: func(config: types.config) -> result<_, fault>; - export on-event: func(event: types.event) -> result<_, fault>; -} -``` +## Signing -The CoW-specific `shepherd` world (in `shepherd:cow`) extends it with the merged `cow-api` interface: +`chain.request` neither signs nor delegates signing; signing methods simply fall outside the read surface. Signing is the separate `nexum:host/identity` interface: ```wit -world shepherd { - include nexum:host/event-module; - import cow-api; -} -``` - -### What This Replaces - -| Before (per-method) | After (generic) | -|---|---| -| `blockchain::eth-call(chain-id, to, data)` | `chain::request(chain-id, "eth_call", params_json)` | -| `blockchain::eth-get-logs(filter)` | `chain::request(chain-id, "eth_getLogs", params_json)` | -| `blockchain::eth-block-number(chain-id)` | `chain::request(chain-id, "eth_blockNumber", "[]")` | -| *n/a - not exposed* | `chain::request(chain-id, "eth_getBalance", params_json)` | -| *n/a - not exposed* | `chain::request(chain-id, "eth_getCode", params_json)` | -| *n/a - not exposed* | `chain::request(chain-id, "eth_getStorageAt", params_json)` | -| *n/a - not exposed* | Any `eth_*` method - no WIT change needed | - -### Why JSON Strings (Not `list`) - -- The Ethereum JSON-RPC spec is JSON. alloy serialises params to JSON internally. Using `string` means zero intermediate format - the guest produces JSON, the host forwards JSON to alloy's `raw_request_dyn` which accepts `&RawValue` (a JSON string). -- Debuggability: JSON is human-readable in logs and traces. -- The canonical ABI cost of copying a JSON string across the component boundary is negligible relative to the network RTT of an actual RPC call. -- Binary encoding (CBOR, postcard) would require custom (de)serialisation on both sides, defeating the purpose of minimising boilerplate. - -## Host Implementation - -The host implementation is minimal - one function handles the entire `eth_` namespace: - -```rust -use serde_json::value::RawValue; - -impl nexum::host::chain::Host for NexumHostState { - async fn request( - &mut self, - chain_id: u64, - method: String, - params: String, - ) -> wasmtime::Result> { - // 1. Check if this is a signing method that requires identity delegation - if self.is_signing_method(&method) { - return self.dispatch_signing(chain_id, &method, ¶ms).await; - } - - // 2. Method allowlisting for read-only methods - if !self.is_read_method_allowed(&method) { - return Ok(Err(ChainError::Fault(Fault::Denied(format!( - "method not allowed: {method}" - ))))); - } - - // 3. Resolve the provider for this chain - let provider = self.provider_for(chain_id).map_err(|_| { - ChainError::Fault(Fault::Unsupported(format!("unknown chain: {chain_id}"))) - })?; - - // 4. Parse params as raw JSON and forward to alloy - let raw_params: Box = RawValue::from_string(params) - .map_err(|e| wasmtime::Error::msg(format!("invalid JSON params: {e}")))?; - - // A structured JSON-RPC error folds to ChainError::Rpc (node code + - // decoded revert bytes); a transport failure folds to a Fault. - match provider.raw_request_dyn(method.into(), &raw_params).await { - Ok(result) => Ok(Ok(result.get().to_string())), - Err(e) => Ok(Err(e.into())), - } - } -} -``` - -That's it. The alloy provider already has the timeout/retry/rate-limit/fallback tower stack configured per chain (see doc 01). Every read-only `eth_*` method automatically inherits that middleware. - -### Method Allowlisting - -> **Status: Future direction (0.3+ target).** The shipped 0.2 host -> implementation of `chain::request` forwards any method string to -> the alloy provider; it does **not** consult a read-only allowlist -> and it does **not** intercept signing methods to delegate to the -> identity backend. The categorisation below is the planned 0.3 -> enforcement model. Until that tracking issue lands the gating -> code, operators must treat any chain-capable module as having -> access to the full RPC surface their configured provider exposes. - -The host maintains two categories of methods: **read-only methods** (always allowed through the RPC passthrough) and **signing methods** (delegated to the `identity` backend). - -#### Read-Only Methods (RPC Passthrough) - -```rust -impl NexumHostState { - fn is_read_method_allowed(&self, method: &str) -> bool { - // Default allowlist: read-only eth_ methods - matches!(method, - "eth_blockNumber" - | "eth_call" - | "eth_chainId" - | "eth_estimateGas" - | "eth_feeHistory" - | "eth_gasPrice" - | "eth_maxPriorityFeePerGas" - | "eth_getBalance" - | "eth_getBlockByHash" - | "eth_getBlockByNumber" - | "eth_getBlockReceipts" - | "eth_getCode" - | "eth_getLogs" - | "eth_getProof" - | "eth_getStorageAt" - | "eth_getTransactionByHash" - | "eth_getTransactionCount" - | "eth_getTransactionReceipt" - // net_ methods - | "net_version" - ) - } -} -``` - -This could be made configurable per-module via `module.toml`: - -```toml -[module.chain] -# Additional methods beyond the default read-only set. -# Use with caution - write methods can have side-effects. -extra_allowed_methods = ["eth_createAccessList"] -``` - -The allowlist is runtime-enforced (string matching), not compile-time. This is an acceptable trade-off: the Component Model already provides structural sandboxing (modules can only call `chain::request`, not arbitrary network I/O), and the allowlist adds defence-in-depth for method-level granularity. - -#### Signing Methods (Identity Delegation) - -When a module calls `chain::request` with a signing method, the host does **not** forward the request to the RPC provider. Instead, it delegates to the `identity` backend for signing, then broadcasts the signed result via RPC. - -```rust -impl NexumHostState { - fn is_signing_method(&self, method: &str) -> bool { - matches!(method, - "eth_sendTransaction" - | "eth_accounts" - | "eth_signTypedData_v4" - | "personal_sign" - ) - } -} -``` - -These methods are deliberately **not** in the read-only allowlist. They follow a completely different code path through the identity backend. - -### Identity Delegation Flow - -When a module calls a signing method through `chain::request`, the host intercepts it and delegates to the `Identity` trait: - -```mermaid -sequenceDiagram - participant M as Module (guest) - participant C as CsnHost - participant I as Identity backend - participant R as RPC provider - - M->>C: chain::request(1, "eth_sendTransaction", params) - C->>C: is_signing_method("eth_sendTransaction") → true - C->>C: Parse transaction from params - C->>I: sign(account, tx_hash) - I-->>C: 65-byte signature (r ‖ s ‖ v) - C->>C: Assemble signed transaction (RLP-encode with signature) - C->>R: eth_sendRawTransaction(signed_tx) - R-->>C: tx_hash - C-->>M: Ok(tx_hash) -``` - -The key insight: modules never call `eth_sendRawTransaction` directly (it's not in the read-only allowlist). Instead, `eth_sendTransaction` is intercepted by the host, which uses the `identity` backend to sign, then broadcasts the signed transaction itself. - -This pattern applies to all signing methods: - -| Method | Identity Delegation | -|---|---| -| `eth_accounts` | Returns accounts from `Identity::accounts()` | -| `eth_sendTransaction` | Signs the transaction via `Identity::sign()`, broadcasts via `eth_sendRawTransaction` | -| `eth_signTypedData_v4` | Signs EIP-712 typed data via `Identity::sign_typed_data()` | -| `personal_sign` | Signs the message via `Identity::sign()` (with EIP-191 prefix) | - -### Identity Trait and ChainHost - -The host's `chain` implementation is generic over an `Identity` trait. This allows different identity backends (hardware wallet, KMS, in-memory test keys, etc.): - -```rust -/// Trait for identity backends that provide signing capabilities. -/// -/// The host's chain implementation delegates signing methods to this trait. -/// Implementations can back onto hardware wallets, cloud KMS, in-memory -/// test keys, or any other signing infrastructure. -pub trait Identity: Send + Sync { - /// Get available signing accounts (20-byte Ethereum addresses). - fn accounts(&self) -> Result>, IdentityBackendError>; - - /// Sign raw bytes with the specified account. - /// Returns a 65-byte ECDSA secp256k1 signature (r ‖ s ‖ v). - fn sign(&self, account: &[u8], data: &[u8]) -> Result, IdentityBackendError>; - - /// Sign EIP-712 typed data with the specified account. - fn sign_typed_data(&self, account: &[u8], typed_data: &str) -> Result, IdentityBackendError>; -} - -/// The host state is generic over the identity backend. -pub struct ChainHost { - providers: HashMap, - identity: I, -} - -impl nexum::host::chain::Host for ChainHost { - async fn request( - &mut self, - chain_id: u64, - method: String, - params: String, - ) -> wasmtime::Result> { - if self.is_signing_method(&method) { - return self.dispatch_signing(chain_id, &method, ¶ms).await; - } - - if !self.is_read_method_allowed(&method) { - return Ok(Err(ChainError::Fault(Fault::Denied(format!( - "method not allowed: {method}" - ))))); - } - - let provider = self.provider_for(chain_id)?; - let raw_params: Box = RawValue::from_string(params) - .map_err(|e| wasmtime::Error::msg(format!("invalid JSON params: {e}")))?; - - match provider.raw_request_dyn(method.into(), &raw_params).await { - Ok(result) => Ok(Ok(result.get().to_string())), - Err(e) => Ok(Err(e.into())), - } - } -} - -impl ChainHost { - /// Dispatch signing methods to the identity backend. - async fn dispatch_signing( - &self, - chain_id: u64, - method: &str, - params: &str, - ) -> wasmtime::Result> { - match method { - "eth_accounts" => { - let accounts = self.identity.accounts() - .map_err(|e| ChainError::Fault(Fault::Internal(e.to_string())))?; - let hex_accounts: Vec = accounts - .iter() - .map(|a| format!("0x{}", hex::encode(a))) - .collect(); - Ok(Ok(serde_json::to_string(&hex_accounts)?)) - } - - "eth_sendTransaction" => { - let provider = self.provider_for(chain_id)?; - // Parse the transaction params - let tx_params: Vec = serde_json::from_str(params)?; - let tx = &tx_params[0]; - - let from = parse_address(tx.get("from"))?; - - // Fill missing fields (nonce, gas, etc.) via the provider - let filled_tx = self.fill_transaction(provider, tx).await?; - - // Hash the transaction and sign it - let tx_hash = filled_tx.signing_hash(); - let signature = self.identity.sign(&from, tx_hash.as_ref()) - .map_err(|e| ChainError::Fault(Fault::Internal(e.to_string())))?; - - // Assemble signed transaction and broadcast - let signed_tx = filled_tx.with_signature(&signature); - let raw_tx = signed_tx.rlp_encode(); - - let raw_params = serde_json::to_string(&[format!("0x{}", hex::encode(&raw_tx))])?; - let raw_params_box: Box = RawValue::from_string(raw_params)?; - match provider.raw_request_dyn("eth_sendRawTransaction".into(), &raw_params_box).await { - Ok(result) => Ok(Ok(result.get().to_string())), - Err(e) => Ok(Err(e.into())), - } - } - - "eth_signTypedData_v4" => { - let params_arr: Vec = serde_json::from_str(params)?; - let account = parse_address(¶ms_arr[0])?; - let typed_data = params_arr[1].to_string(); - - let signature = self.identity.sign_typed_data(&account, &typed_data) - .map_err(|e| ChainError::Fault(Fault::Internal(e.to_string())))?; - Ok(Ok(format!("\"0x{}\"", hex::encode(&signature)))) - } - - "personal_sign" => { - let params_arr: Vec = serde_json::from_str(params)?; - let data = parse_hex_bytes(¶ms_arr[0])?; - let account = parse_address(¶ms_arr[1])?; - - // EIP-191 prefix - let prefixed = format!("\x19Ethereum Signed Message:\n{}", data.len()); - let mut msg = prefixed.into_bytes(); - msg.extend_from_slice(&data); - let hash = keccak256(&msg); - - let signature = self.identity.sign(&account, &hash) - .map_err(|e| ChainError::Fault(Fault::Internal(e.to_string())))?; - Ok(Ok(format!("\"0x{}\"", hex::encode(&signature)))) - } - - _ => Ok(Err(ChainError::Fault(Fault::InvalidInput(format!( - "unknown signing method: {method}" - ))))), - } - } -} -``` - -The `ChainHost` also implements `nexum::host::identity::Host` directly, delegating to the same `Identity` trait so modules can use the identity WIT interface for raw signing. The interface is the failure domain, so it reports a plain `fault`: - -```rust -impl nexum::host::identity::Host for ChainHost { - fn accounts(&mut self) -> wasmtime::Result>, Fault>> { - // From picks unavailable/denied/internal. - Ok(self.identity.accounts().map_err(Fault::from)) - } - - fn sign( - &mut self, - account: Vec, - data: Vec, - ) -> wasmtime::Result, Fault>> { - Ok(self.identity.sign(&account, &data).map_err(Fault::from)) - } - - fn sign_typed_data( - &mut self, - account: Vec, - typed_data: String, - ) -> wasmtime::Result, Fault>> { - Ok(self.identity.sign_typed_data(&account, &typed_data).map_err(Fault::from)) - } -} -``` - -## Guest SDK: `HostTransport` - -The key SDK addition is a `HostTransport` struct that implements alloy's `Transport` trait by routing through the WIT `chain::request` host function. - -### Transport Implementation - -```rust -use alloy_json_rpc::{ - ErrorPayload, RequestPacket, Response, ResponsePacket, ResponsePayload, - SerializedRequest, -}; -use alloy_transport::{BoxTransport, Transport, TransportError, TransportFut}; -use tower::Service; -use std::task::{Context, Poll}; - -/// An alloy-compatible transport that routes JSON-RPC requests through the -/// Nexum host engine. Synchronous from the guest's perspective - the host -/// function blocks until the RPC response is available. -#[derive(Debug, Clone)] -pub struct HostTransport { - chain_id: u64, -} - -impl HostTransport { - pub fn new(chain_id: u64) -> Self { - Self { chain_id } - } -} - -impl Service for HostTransport { - type Response = ResponsePacket; - type Error = TransportError; - type Future = TransportFut<'static>; - - fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { - // Always ready - host function calls are synchronous from the guest. - Poll::Ready(Ok(())) - } - - fn call(&mut self, req: RequestPacket) -> Self::Future { - let chain_id = self.chain_id; - Box::pin(async move { - match req { - RequestPacket::Single(req) => { - let resp = dispatch_single(chain_id, &req)?; - Ok(ResponsePacket::Single(resp)) - } - RequestPacket::Batch(reqs) => { - // 0.2: route batches through chain::request-batch so the - // host actually pipelines them on the wire. - let calls: Vec<(String, String)> = reqs.iter() - .map(|r| (r.method().to_string(), - r.params().map(|p| p.get()).unwrap_or("[]").to_string())) - .collect(); - let results = chain::request_batch(chain_id, &calls) - .map_err(|e| TransportError::from_host(e))?; - let resps: Vec<_> = reqs.iter().zip(results.into_iter()) - .map(|(req, result)| build_response(req, result)) - .collect(); - Ok(ResponsePacket::Batch(resps)) - } - } - }) - } -} - -impl Transport for HostTransport { - fn boxed(self) -> BoxTransport - where - Self: Sized + Clone + Send + Sync + 'static, - { - BoxTransport::new(self) - } -} - -/// Dispatch a single JSON-RPC request through the host function. -fn dispatch_single( - chain_id: u64, - req: &SerializedRequest, -) -> Result>, TransportError> { - let method = req.method(); - let params_json = req.params().map(|p| p.get()).unwrap_or("[]"); - - // This calls the WIT-imported host function. Synchronous from the guest's - // perspective - the host executes the RPC call asynchronously and returns - // the result when ready. - match chain::request(chain_id, method, params_json) { - Ok(result_json) => { - let payload: Box = RawValue::from_string(result_json) - .map_err(|e| TransportError::deser_err(e, "host response"))?; - Ok(Response { - id: req.id().clone(), - payload: ResponsePayload::Success(payload), - }) - } - Err(e) => { - // Map the chain-error onto an alloy error payload. A structured - // `rpc` case carries the node code and decoded revert bytes; a - // shared `fault` becomes a generic -32000 with the fault message. - let payload = match e { - ChainError::Rpc(rpc) => ErrorPayload { - code: rpc.code as i64, - message: rpc.message, - data: rpc.data.map(|d| { - RawValue::from_string(format!("\"0x{}\"", hex::encode(d))).unwrap() - }), - }, - ChainError::Fault(fault) => ErrorPayload { - code: -32000, - message: fault.to_string(), - data: None, - }, - }; - Ok(Response { - id: req.id().clone(), - payload: ResponsePayload::Failure(payload), - }) - } - } -} -``` - -### Why This Works Without Real Async - -The `call()` method returns a `Box::pin(async move { ... })` - but the body is entirely synchronous. The `chain::request` host function blocks from the guest's perspective (the host runs the actual RPC call asynchronously via wasmtime's `func_wrap_async`, but the guest sees a normal function call that returns a value). The future resolves in a single poll. - -This means alloy's `Provider` methods - which `await` the transport internally - complete immediately when driven by any executor. The SDK provides a minimal single-threaded executor: - -```rust -/// Drive a future to completion. Since the HostTransport resolves -/// synchronously, this is a single-poll operation - no actual async -/// scheduling occurs. -pub fn block_on(future: F) -> F::Output { - futures_executor::block_on(future) -} -``` - -`futures-executor` is no-std-compatible and adds no meaningful overhead. - -### Provider Constructor - -```rust -use alloy_provider::RootProvider; -use alloy_rpc_client::RpcClient; - -/// Create an alloy `Provider` backed by the Nexum host engine. -/// -/// The returned provider supports the full alloy `Provider` API - all `eth_*` -/// methods, builder patterns, typed responses - routing every request through -/// the host's RPC stack (timeout, retry, rate-limit, failover). -/// -/// ```rust -/// let provider = nexum_sdk::provider(42161); -/// let block = provider.get_block_number().await?; -/// ``` -pub fn provider(chain_id: u64) -> RootProvider { - let transport = HostTransport::new(chain_id); - let client = RpcClient::new(transport, false); // false = not local - RootProvider::new(client) -} -``` - -## Eliminating `block_on`: Async Module Functions - -### The Problem - -alloy's `Provider` is async. Without help, module authors would need `block_on()` around every RPC call: - -```rust -let block_num = block_on(provider.get_block_number())?; // noisy -let balance = block_on(provider.get_balance(addr).latest())?; // everywhere -``` - -This is verbose and obscures the actual logic. But we can't reimplement every `Provider` method as a synchronous wrapper - that defeats the purpose of the generic passthrough. - -### The Solution: Named Event Handlers + `async fn` - -The proc macro (see doc 05) already generates the WIT export boilerplate. We extend it in two ways. For universal modules, the `#[nexum::module]` macro is used; for CoW modules, the `#[shepherd::module]` macro (which extends the universal one with CoW-specific imports): - -1. **Named event handlers** - instead of writing the `match event { ... }` dispatch manually, module authors implement `on_block`, `on_chain_logs`, `on_tick`, and/or `on_message`. The macro generates the `on_event` match. -2. **`async fn` support** - handlers can be async. The macro wraps the generated `on_event` in `block_on()`, so `.await` works naturally. -3. **Provider injection** - if a handler accepts `&RootProvider` as a second parameter, the macro creates the provider from the event's chain_id and passes it in. - -**What the module author writes (universal module):** - -```rust -#[nexum::module] -struct MyModule; - -impl MyModule { - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let block_num = provider.get_block_number().await?; // natural .await - let balance = provider.get_balance(addr).latest().await?; // no block_on - Ok(()) - } - - async fn on_chain_logs(logs: Vec, provider: &RootProvider) -> Result<()> { - for log in &logs { - // ... - } - Ok(()) - } - - // on_tick / on_message not defined -> those events are silently ignored -} -``` - -**What the module author writes (CoW module):** - -```rust -#[shepherd::module] -struct MyModule; - -impl MyModule { - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let cow = Cow::new(block.chain_id); - let block_num = provider.get_block_number().await?; - cow.submit_order(&order)?; - Ok(()) - } -} -``` - -**What the macro generates:** - -```rust -impl Guest for MyModule { - fn on_event(event: types::Event) -> Result<(), Fault> { - nexum_sdk::block_on(async { - match event { - Event::Block(block) => { - let provider = nexum_sdk::provider(block.chain_id); - MyModule::on_block(block, &provider).await - } - Event::ChainLogs(batch) => { - let provider = nexum_sdk::provider(batch.chain_id); - MyModule::on_chain_logs(batch.logs, &provider).await - } - Event::Tick(_) => Ok(()), // no handler defined - Event::Message(_) => Ok(()), // no handler defined - } - }) - } -} -``` - -The generated code calls `block_on` exactly once - at the top-level export boundary. Inside the async block, all `.await` calls resolve immediately (the `HostTransport` is synchronous under the hood). No real async scheduler runs. No tokio. No waker machinery. It's syntactic sugar that costs nothing at runtime. - -### Named Handler Conventions - -| Handler | Payload | Optional injectable context | -|---|---|---| -| `on_block(block)` | `Block` | `provider: &RootProvider` (from `block.chain_id`) | -| `on_chain_logs(logs)` | `Vec` | `provider: &RootProvider` (from `logs[0].chain_id`) | -| `on_tick(tick)` | `Tick` (`tick.fired_at` is ms UTC) | None (no chain context) | -| `on_message(message)` | `Message` | None | - -The macro inspects each handler's signature: -- **Second parameter is `&RootProvider`** -> inject `nexum_sdk::provider(chain_id)` -- **No second parameter** -> pass only the payload -- **Async handlers** -> wrapped in `block_on`; sync handlers called directly -- **Missing handlers** -> `Ok(())` for that variant (no-op) - -**Escape hatch:** defining `on_event` directly takes precedence - the macro uses it as-is (wrapping in `block_on` if async) and ignores named handlers. - -### Why This Works - -1. **WIT exports are synchronous.** The Component Model export signature is `func(event) -> result<_, string>` - no async. The macro bridges this by wrapping the generated dispatch in `block_on`. - -2. **The transport resolves in one poll.** `HostTransport::call()` returns a future whose body is entirely synchronous (it calls the WIT host function, which blocks). When alloy's `Provider` awaits the transport, the future completes immediately. - -3. **`futures_executor::block_on` is trivial.** It creates a waker, polls the future once, gets `Poll::Ready`. No thread parking, no event loop. On WASM single-threaded targets this is a no-op wrapper. - -4. **Composability.** Module authors can use alloy's builder patterns naturally inside any handler: - - ```rust - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - // EthCall builder - .latest() and .await both work - let result = provider.call(tx).latest().await?; - - // Filter builder - standard alloy ergonomics - let logs = provider.get_logs(&filter).await?; - - // Raw request for unlisted methods - let proof: EIP1186AccountProofResponse = provider - .raw_request("eth_getProof".into(), (addr, keys, "latest")) - .await?; - Ok(()) - } - ``` - -5. **Sync handlers still work.** Handlers that don't need RPC can be plain `fn`: - - ```rust - fn on_tick(tick: Tick) -> Result<()> { - info!("tick fired at {} ms UTC", tick.fired_at); - Ok(()) - } - ``` - -### Comparison - -| Approach | Event dispatch boilerplate | RPC call boilerplate | New methods need shimming? | alloy-native? | -|---|---|---|---|---| -| Manual `on_event` + `block_on()` | `match event { ... }` every module | `block_on(...)` every call | No | Yes | -| **Named handlers + async macro** | **None (generated)** | **None (`.await`)** | **No** | **Yes** | - -The named handler + async macro approach eliminates boilerplate at both the event dispatch level and the RPC call level. - -## Module Author Experience - -### Before (Per-Method WIT) - -```rust -use nexum_sdk::prelude::*; -use nexum_sdk::abi::sol; - -sol! { - function balanceOf(address owner) view returns (uint256); -} - -#[nexum::module] -struct MyModule; - -impl MyModule { - fn on_event(event: Event) -> Result<()> { - if let Event::Block(block) = event { - // Manual ABI encode - let calldata = balanceOfCall { owner: addr }.abi_encode(); - - // Raw host call - returns opaque bytes - let result_bytes = blockchain::eth_call( - block.chain_id, - &token_addr.to_vec(), - &calldata, - )?; - - // Manual ABI decode - let balance = balanceOfCall::abi_decode_returns(&result_bytes)?; - - // Want eth_getBalance? Not available. Want eth_getCode? Not available. - // Each new method needs WIT + host + SDK changes. - } - Ok(()) - } -} -``` - -### After (Generic RPC + named handlers + provider injection) - -```rust -use nexum_sdk::prelude::*; - -sol! { - function balanceOf(address owner) view returns (uint256); -} - -#[nexum::module] -struct MyModule; - -impl MyModule { - // Named handler - macro generates the match dispatch + provider injection - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - // Full alloy Provider API - natural .await, provider injected - let block_num = provider.get_block_number().await?; - let eth_balance = provider.get_balance(addr).latest().await?; - let code = provider.get_code_at(contract).latest().await?; - - // Typed contract calls with the EthCall builder - let tx = TransactionRequest::default() - .to(token_addr) - .input(balanceOfCall { owner: addr }.abi_encode().into()); - - let result = provider.call(tx).latest().await?; - let balance = balanceOfCall::abi_decode_returns(&result)?; - - // Log queries with alloy's Filter builder - let filter = Filter::new() - .address(contract) - .event_signature(Transfer::SIGNATURE_HASH) - .from_block(block.number - 100); - let logs = provider.get_logs(&filter).await?; - - // Raw request for anything not wrapped by Provider - let proof: EIP1186AccountProofResponse = provider - .raw_request("eth_getProof".into(), (addr, keys, "latest")) - .await?; - - Ok(()) - } - - // Only implement handlers for event types you care about. - // No on_chain_logs, on_tick, or on_message -> those events are no-ops. -} -``` - -Every alloy `Provider` method works. No WIT changes. No host-side per-method code. No `block_on`. No `match event { ... }`. No manual provider construction. - -## The `cow-api` Namespace - -CoW Protocol's API is REST-based, not JSON-RPC. Two options: - -### Option A: Separate REST Interface (Recommended - chosen for 0.2) - -In 0.1 this was two interfaces, `cow` (REST passthrough) and `order` (typed `submit`). 0.2 merges them into a single `cow-api` interface, dropping the `cow::cow::request` triple-stutter: - -```wit -interface cow-api { - use nexum:host/types.{chain-id, fault}; - - /// A non-2xx reply with no typed rejection envelope; `body` is raw text. - record http-failure { status: u16, body: option } - - /// A typed orderbook rejection, parsed host-side from `{errorType, description}`. - record order-rejection { status: u16, error-type: string, description: string, data: option } - - /// A shared host `fault`, a raw HTTP failure, or a typed order rejection. - variant cow-api-error { fault(fault), http(http-failure), rejected(order-rejection) } - - /// HTTP-style request to the CoW Protocol API. - /// - /// The host routes to the correct CoW API base URL for the given chain - /// (e.g. https://api.cow.fi/mainnet for chain 1, /arbitrum for chain - /// 42161). The path is relative to the base URL. - /// - /// method: "GET" | "POST" | "PUT" | "DELETE" - /// path: relative API path, e.g. "/api/v1/orders" - /// body: optional JSON request body - /// - /// Returns the response body as a JSON string. - request: func( - chain-id: chain-id, - method: string, - path: string, - body: option, - ) -> result; - - /// Submit a serialised order. (Merged in from the 0.1 `order::submit`.) - submit-order: func(chain-id: chain-id, order-data: list) - -> result; -} -``` - -```wit -world shepherd { - include nexum:host/event-module; - import cow-api; -} -``` - -The host implementation is similarly minimal: - -```rust -impl shepherd::cow::cow_api::Host for NexumHostState { - async fn request( - &mut self, - chain_id: u64, - method: String, - path: String, - body: Option, - ) -> wasmtime::Result> { - let base_url = self.cow_api_url_for(chain_id)?; - let url = format!("{base_url}{path}"); - - let req = self.http_client.request(method.parse()?, &url); - let req = match body { - Some(b) => req.header("content-type", "application/json").body(b), - None => req, - }; - - let resp = req.send().await - .map_err(|e| CowApiError::Fault(Fault::Unavailable(e.to_string())))?; - let status = resp.status().as_u16(); - - if status >= 400 { - // A non-2xx with no typed rejection envelope surfaces as `http` - // so a caller matches on `status` (e.g. 404) and reads `body` - // only for diagnostics; submit-order parses the orderbook's - // `{errorType, description}` into the `rejected` case instead. - let body = resp.text().await.ok(); - return Ok(Err(CowApiError::Http(HttpFailure { status, body }))); - } - - Ok(Ok(resp.text().await.unwrap_or_default())) - } -} -``` - -### Option B: JSON-RPC Style (Unified) - -Route `cow_*` methods through the same `chain::request` function: - -```rust -// Guest usage (illustrative): -let order_uid: String = block_on(provider.raw_request( - "cow_submitOrder".into(), - serde_json::json!({ "sellToken": "0x...", "buyToken": "0x...", ... }), -))?; -``` - -The host would dispatch by method prefix: - -```rust -async fn request(&mut self, chain_id: u64, method: String, params: String) - -> wasmtime::Result> -{ - if method.starts_with("eth_") || method.starts_with("net_") { - self.dispatch_rpc(chain_id, &method, ¶ms).await - } else if method.starts_with("cow_") { - self.dispatch_cow(chain_id, &method, ¶ms).await - } else { - Ok(Err(ChainError::Fault(Fault::InvalidInput("unknown namespace".into())))) - } +interface identity { + use types.{fault}; + accounts: func() -> result>, fault>; + sign: func(account: list, message: list) -> result, fault>; + sign-typed-data: func(account: list, typed-data: string) -> result, fault>; } ``` -**Option A is recommended and is what 0.2 ships.** The CoW API is REST, not JSON-RPC - forcing it into JSON-RPC semantics adds a translation layer on both sides. A separate `cow-api` interface keeps the contract explicit and makes it clear in the WIT world what capabilities a module has. It also allows independent evolution - the `chain` interface doesn't need to know about CoW, and vice versa. - -### SDK: `Cow` - -```rust -/// Typed client for the CoW Protocol API, backed by the host engine. -pub struct Cow { - chain_id: u64, -} - -impl Cow { - pub fn new(chain_id: u64) -> Self { - Self { chain_id } - } - - /// Submit an order via the typed cow-api::submit-order function. - pub fn submit_order(&self, order: &OrderCreation) -> Result { - let bytes = postcard::to_allocvec(order)?; - let uid = cow_api::submit_order(self.chain_id, &bytes)?; - Ok(uid.parse()?) - } - - /// Get an order by UID. - pub fn get_order(&self, uid: &OrderUid) -> Result { - let resp = cow_api::request(self.chain_id, "GET", &format!("/api/v1/orders/{uid}"), None)?; - Ok(serde_json::from_str(&resp)?) - } +`accounts` returns the 20-byte addresses the host will sign for (an empty list means no signing capability); `sign` applies `personal_sign` semantics (the EIP-191 prefix) and returns a 65-byte signature; `sign-typed-data` signs an EIP-712 JSON payload. `nexum-sdk`'s `IdentityHost` trait mirrors the interface one-for-one. - /// Get the current auction. - pub fn get_auction(&self) -> Result { - let resp = cow_api::request(self.chain_id, "GET", "/api/v1/auction", None)?; - Ok(serde_json::from_str(&resp)?) - } +## Guest SDK: the alloy provider seam - /// Get a quote for a potential order. - pub fn get_quote(&self, params: &OrderQuoteRequest) -> Result { - let body = serde_json::to_string(params)?; - let resp = cow_api::request(self.chain_id, "POST", "/api/v1/quote", Some(&body))?; - Ok(serde_json::from_str(&resp)?) - } +`nexum_sdk::chain` fronts `chain.request` with an alloy `Provider`, so module code calls typed provider methods instead of hand-building JSON-RPC: - /// Raw request for endpoints not yet wrapped. - pub fn raw_request(&self, method: &str, path: &str, body: Option<&str>) -> Result { - Ok(cow_api::request(self.chain_id, method, path, body)?) - } -} -``` - -Usage in a module: +- `HostTransport` implements alloy's `Service` over any `ChainHost`, dispatching single requests through `chain.request` and batches through `chain.request-batch`. +- `ProviderHost::provider(chain)` mints an alloy `RootProvider` over that transport, blanket-implemented for every cloneable `ChainHost`. +- `block_on` drives the returned futures. The transport is a synchronous WIT import, so a future resolves on its first poll; a `Pending` panics, signalling that an alloy layer awaiting a reactor or timer has been introduced. ```rust -async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let cow = Cow::new(block.chain_id); - - // Read chain state via alloy - provider injected by macro - let block_num = provider.get_block_number().await?; - - // Submit order via CoW API - cow.submit_order(&OrderCreation { - sell_token: usdc, - buy_token: weth, - sell_amount: U256::from(1_000_000_000), - kind: OrderKind::Sell, - // block.timestamp is ms-since-epoch in 0.2 - divide for seconds - valid_to: (provider.get_block(block_num.into(), false).await? - .unwrap().header.timestamp / 1000) + 300, - ..Default::default() - })?; +use nexum_sdk::chain::{Chain, ProviderHost, block_on}; - Ok(()) -} -``` - -## Updated SDK Crate Structure - -``` -nexum-sdk/ -├── Cargo.toml -├── src/ -│ ├── lib.rs # re-exports, prelude, provider() constructor -│ ├── bindings.rs # generated WIT bindings -│ ├── transport.rs # HostTransport (alloy Transport impl, batches via chain::request-batch) -│ ├── local_store.rs # TypedState helpers (serde over local-store) -│ ├── signer.rs # Signer (typed identity helpers) -│ ├── abi.rs # alloy-sol-types integration -│ ├── log.rs # logging macros -│ ├── error.rs # Fault, HostFault, ChainError -│ └── testing.rs # mock host, test harness -└── macros/ - └── src/ - └── lib.rs # #[nexum::module] proc macro - -shepherd-sdk/ -├── Cargo.toml # depends on nexum-sdk, re-exports it -├── src/ -│ ├── lib.rs # re-exports nexum-sdk + CoW additions -│ └── cow.rs # Cow typed wrapper (submit + REST passthrough) -└── macros/ - └── src/ - └── lib.rs # #[shepherd::module] proc macro (extends nexum::module) +let provider = host.provider(Chain::mainnet()); +let block = block_on(provider.get_block_number())?; ``` -New dependencies (in `nexum-sdk`): - -```toml -[dependencies] -alloy-transport = { version = "1.5", default-features = false } -alloy-json-rpc = { version = "1.5", default-features = false } -alloy-rpc-client = { version = "1.5", default-features = false } -alloy-provider = { version = "1.5", default-features = false } -alloy-rpc-types = { version = "1.5", default-features = false } -alloy-primitives = { version = "1.5", default-features = false } -alloy-sol-types = { version = "1.5", default-features = false } -futures-executor = { version = "0.3", default-features = false } -serde = { version = "1", default-features = false, features = ["derive"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } -tower = { version = "0.5", default-features = false } -``` +A module that only needs raw JSON calls `host.request(chain_id, method, params)` directly, or reaches for the `chain::eth_call_params` and `parse_eth_call_result` helpers. -All alloy crates with `default-features = false` to avoid pulling in reqwest, tokio, or other dependencies that won't compile for `wasm32-wasip2`. The key crates (`alloy-primitives`, `alloy-sol-types`, `alloy-json-rpc`) are already `no_std`-compatible or have WASM-friendly feature flags. +## Handlers are synchronous -## Updated Prelude - -```rust -// nexum_sdk::prelude -pub use crate::bindings::nexum::host::types::*; -pub use crate::bindings::nexum::host::chain; -pub use crate::bindings::nexum::host::identity; -pub use crate::bindings::nexum::host::local_store; -pub use crate::bindings::nexum::host::remote_store; -pub use crate::bindings::nexum::host::messaging; -pub use crate::bindings::nexum::host::logging; -pub use crate::log::{trace, debug, info, warn, error}; -pub use crate::local_store::TypedState; -pub use crate::signer::Signer; -pub use crate::transport::HostTransport; -pub use crate::provider; -pub use crate::error::{Result, Fault, HostFault, ChainError, RpcError}; +`#[nexum_sdk::module]` dispatches events to synchronous named handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_custom`); an absent handler is a no-op for that event. There is no `block_on` wrapper around a handler and no provider injection: a handler that wants the alloy provider builds it with `host.provider(chain)` and drives calls with `block_on` itself. Keeper handlers are the exception, where `#[videre_sdk::keeper]` allows `async fn` completed by `videre_sdk::client::poll_once`; see [doc 05](05-sdk-design.md). -// Re-export alloy essentials so modules don't need direct alloy dependencies -pub use alloy_primitives::{Address, B256, U256, Bytes}; -pub use alloy_sol_types::sol; -pub use alloy_rpc_types::*; -pub use alloy_provider::Provider; -``` +## Order submission -```rust -// shepherd_sdk::prelude (re-exports nexum_sdk::prelude + CoW additions) -pub use nexum_sdk::prelude::*; -pub use crate::bindings::shepherd::cow::cow_api; -pub use crate::cow::Cow; -``` +Submitting an order or intent is not a chain namespace. It is the `videre:venue` venue-adapter contract: a keeper calls `videre:venue/client`, and the installed venue adapter (for CoW, `shepherd/crates/cow-venue`) speaks the orderbook wire. See [doc 08](08-platform-generalisation.md) for the layer model and [doc 05](05-sdk-design.md) for the venue SDK. ## Testing -### MockTransport for Unit Tests - -The SDK testing module provides a mock transport that mirrors alloy's own `Asserter`-based testing pattern: - -```rust -use nexum_sdk::testing::MockProvider; - -#[test] -fn test_reads_balance() { - // block_on is still useful in tests - tests are sync by default. - // (Or use #[tokio::test] - MockProvider works with any executor.) - let mut mock = MockProvider::new(42161); - - // Queue mock responses (FIFO) - mock.push_success(&U256::from(1_000_000)); // for get_balance - mock.push_success(&19_000_001u64); // for get_block_number - - let provider = mock.provider(); - - let balance = block_on(provider.get_balance(addr).latest()).unwrap(); - assert_eq!(balance, U256::from(1_000_000)); - - let block = block_on(provider.get_block_number()).unwrap(); - assert_eq!(block, 19_000_001); -} -``` - -Note: `block_on` is still available and useful in test code where `#[test]` functions are synchronous. In module code, prefer `async fn on_event` with `.await` instead. - -### MockCow for Unit Tests - -```rust -use shepherd_sdk::testing::MockCow; - -#[test] -fn test_submits_order() { - let mut mock_cow = MockCow::new(42161); - mock_cow.on_submit(|order| { - assert_eq!(order.sell_token, usdc); - Ok(OrderUid::from([0x42; 56])) - }); - - let uid = mock_cow.submit_order(&order).unwrap(); - assert_eq!(uid, OrderUid::from([0x42; 56])); -} -``` - -## Trade-Offs - -| Concern | Generic passthrough | Per-method WIT functions | -|---|---|---| -| **WIT changes for new methods** | None | New function + types per method | -| **Host implementation** | ~20 lines total | Per-method impl + dispatch | -| **Guest API** | Full alloy Provider (80+ methods) | Only what WIT exposes | -| **alloy compatibility** | Native - IS an alloy transport | Manual ABI encode/decode | -| **Type safety at WIT boundary** | Runtime (JSON strings) | Compile-time (WIT types) | -| **Method allowlisting** | Runtime string match | Implicit (only exposed methods exist) | -| **Debugging** | JSON in/out visible in traces | Structured WIT types in traces | -| **Multi-language guests** | Must handle JSON serialisation | WIT types auto-generated | - -The primary trade-off is **type safety at the WIT boundary**: JSON strings vs. structured WIT types. This is mitigated by: - -1. **Rust guests** use alloy's type system - serialisation errors surface as alloy `TransportError` with clear messages. -2. **Non-Rust guests** (JS, Python, Go) typically work with JSON natively, so JSON strings are actually *more* natural than WIT record types. -3. **Tracing**: the host can log method + params as structured JSON before forwarding, providing equal or better debuggability. - -The compile-time guarantee that a module can only call methods in the WIT is traded for a runtime allowlist. Given that the Component Model already provides structural sandboxing (the module can only call `chain::request`, not arbitrary network I/O), and the allowlist is enforced at the host boundary before any RPC call is made, this is a sound trade-off. - -## Migration Path - -For modules and embedders moving from 0.1 to 0.2, follow the [Migration Guide](migration/0.1-to-0.2.md). In summary: the early 0.1 `blockchain` sketch was replaced by `csn` later in 0.1 and is now `chain` in 0.2; the SDK's `block_on` is now hidden behind the `#[nexum::module]` macro; and each interface returns its own typed error over the shared `fault` vocabulary rather than a per-protocol error type. - -## Summary - -| Component | What 0.2 ships | -|---|---| -| **WIT** | `chain` interface with `request` + additive `request-batch`. `identity` (accounts, sign, sign-typed-data). Merged `cow-api` in `shepherd:cow`. `event-module` imports 6 interfaces: chain, identity, local-store, remote-store, messaging, logging. Plus the additive `http` capability and the experimental `query-module` world. | -| **Host** | `ChainHost` - one `chain::request` impl that forwards read-only methods to `provider.raw_request_dyn` and delegates signing methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) to the `Identity` backend. Plus `chain::request-batch` that actually pipelines. One `identity::Host` impl delegating to the same backend. One `cow-api::request` + `submit-order` impl forwarding to HTTP client. Chain calls return `chain-error`, cow-api calls return `cow-api-error`, and the rest report `fault` directly. | -| **SDK** | `nexum-sdk`: `HostTransport` (alloy `Transport` impl, batches via `chain::request-batch`), `provider()` constructor, `Signer` (typed identity wrapper), `Fault` / `HostFault` / `ChainError`. `shepherd-sdk`: `Cow` (extends `nexum-sdk`). `block_on` is internal. | -| **`#[nexum::module]` / `#[shepherd::module]` macros** | Named event handlers (`on_block`, `on_chain_logs`, `on_tick`, `on_message`) with generated match dispatch. `async fn` support. Optional `&RootProvider` injection. `#[nexum::module]` for universal modules; `#[shepherd::module]` for CoW modules. | -| **Module author experience** | Full alloy `Provider` API via injected provider. Signing via `Signer` or transparently through `chain::request` signing methods. Full CoW API via `Cow`. No match boilerplate. No `block_on`. No manual ABI wrangling for RPC calls. Match on the `fault` case (or `ChainError::Rpc`) for retry/backoff. | -| **Existing ABI helpers** | Unchanged - `sol!` macro and `alloy-sol-types` still used for contract calldata encoding/decoding. | +`nexum_sdk_test::MockHost` implements the host traits (`ChainHost`, `IdentityHost`, `LocalStoreHost`, ...); module logic tests against `&impl Host` as plain native Rust, with no `wasm32-wasip2` target and no wasmtime instance. Provider-level tests wrap a stub `ChainHost` in `HostTransport` and drive it with `block_on`. diff --git a/docs/08-platform-generalisation.md b/docs/08-platform-generalisation.md index d0391e04..2a42ee17 100755 --- a/docs/08-platform-generalisation.md +++ b/docs/08-platform-generalisation.md @@ -1,65 +1,28 @@ # Platform Generalisation -> **Status (0.2):** Nexum is **designed** to be portable to mobile and browser hosts; the 0.2 **reference runtime is server-only**. The mobile, WebView, and super-app targets in this document describe architectural direction, not shipping artifacts. They remain in the docs because they're load-bearing design - the WIT contract is shaped by the requirement that all four can implement it - but they are **planned** work, conditional on a named design partner for 0.3. See the per-target rows below for current status. +> **Status (0.2):** the WIT contract is host-portable; the reference server runtime (docs 01-07) is its sole implementation. Mobile, WebView, and super-app hosts are design direction only and ship nothing in 0.2. -## Motivation +A module compiles against `nexum:host/event-module`. Any host that implements the required interfaces runs the same module binary; platforms differ only in how they back those interfaces. This document defines the layered world model, the universal interface set, and the venue-adapter extension mechanism. [Doc 00](00-overview.md) owns the six-primitive taxonomy. -The Nexum runtime (docs 01-07) is designed as a server-side Rust binary embedding wasmtime. But the core abstractions - WIT-defined host interfaces, content-addressed module distribution, declarative manifests - are not inherently server-specific. The same module binary, the same packaging, and the same distribution mechanism are intended to serve multiple platform targets: +## Layered WIT Worlds -1. **Server runtime** *(shipping in 0.2)* - the current design (Rust/Tokio/wasmtime). Headless automation: blockchain event monitoring, order submission, background computation. -2. **Mobile app (Flutter/Dart)** *(planned - see roadmap)* - a WASM runtime embedded in a native mobile application via FFI. Modules run on-device, backed by local state (SQLite) and RPC over HTTP. -3. **WebView** *(planned - see roadmap)* - a browser engine (V8/JSC/SpiderMonkey) executing WASM natively, with host functions injected from the native layer via a JavaScript bridge. Enables rich web-based UIs with blockchain-native capabilities. -4. **Decentralised super app** *(planned - see roadmap)* - a shell application (mobile or desktop) that dynamically loads modules discovered via ENS and fetched from Swarm. Some modules are headless (automation); others are interactive (UI). All are sandboxed, all are distributed without a central app store. - -The key insight: **the WIT contract is the universal interface**. Any host that implements the required interfaces can run the same module binary. The differences between platforms are in *how* the host implements those interfaces - not in what the module sees. - -This document defines the layered architecture that enables this generalisation and specifies the universal interface set. The 0.2 server runtime is the first host implementation; the experimental `nexum:host/query-module` WIT world (published but unhosted in 0.2) exists to give mobile/wallet embedders a stable target to implement against before 0.3. - -## Primitive Taxonomy - -Before diving into WIT definitions, the universal runtime is built on six primitive capabilities. These are the fundamental building blocks that any decentralised application needs: - -| Primitive | Interface | Backed by | Purpose | -|-----------|-----------|-----------|---------| -| **Chain** | `chain` | JSON-RPC (eth_*) | Read/write blockchain consensus state | -| **Identity** | `identity` | Keystore / KMS / device keychain / wallet extension | Cryptographic identity - key management and signing | -| **Local Store** | `local-store` | redb / SQLite / IndexedDB | Per-module private persistence on the device | -| **Remote Store** | `remote-store` | Ethereum Swarm | Decentralised content-addressed storage | -| **Messaging** | `messaging` | Waku | Decentralised pub/sub messaging | -| **Logging** | `logging` | tracing / console | Diagnostic output | - -These six primitives are orthogonal: - -- **Chain** is the source of truth - the blockchain consensus state. Modules read chain state and (indirectly) write to it via order submission or transactions. -- **Identity** is cryptographic agency - key management and signing. Modules can enumerate available accounts and request signatures (ECDSA secp256k1 by default, extensible). The `chain` host implementation depends on `identity` internally - signing RPC methods (e.g. `eth_sendTransaction`) delegate to `identity` for the actual signature. -- **Local Store** is the module's private scratchpad - fast, local, scoped to one module on one device. Does not replicate. -- **Remote Store** is shared persistent content - content-addressed, decentralised, survives independent of any device. Any module on any device can read what another module wrote. -- **Messaging** is real-time communication - ephemeral pub/sub messages between modules, devices, or users. Unlike remote store (persistent, content-addressed), messaging is transient and topic-based. -- **Logging** is diagnostics - one-way output for debugging and monitoring. Not a data channel. - -Together they cover the full spectrum: persistent truth (chain), cryptographic agency (identity), local scratch (local-store), shared content (remote-store), real-time coordination (messaging), and diagnostics (logging). - -The 0.2 `event-module` world imports all six. (In 0.1 the WIT inadvertently omitted `identity` from the world definition despite the docs claiming six primitives; 0.2 makes the contract match the taxonomy.) One additional **additive** capability - `http` (allowlisted) - is declared via the manifest's `[capabilities]` section but is not part of the six-primitive core; it is serviced by the standard `wasi:http/outgoing-handler` interface rather than a `nexum:host` one. - -## Architectural Principle: Layered WIT Worlds - -The current `shepherd` world conflates universal blockchain runtime capabilities with CoW Protocol domain-specific interfaces. To enable reuse across platforms and domains, the WIT is split into layers: +Universal runtime capabilities and domain-specific surfaces live in separate layers: ```mermaid graph TD - subgraph L3["Layer 3: Application-Specific Worlds"] - COW["shepherd:cow - cow + order (CoW Protocol automation)"] - DEFI["myapp:defi - vault + strategy (DeFi yield app)"] - GAME["game:engine - physics + assets (on-chain game)"] + subgraph L3["Layer 3: Domain Venues (adapter components)"] + COW["cow-venue - CoW Protocol orderbook"] + DEX["dex-venue - a DEX (hypothetical)"] end subgraph L2["Layer 2: Capability Extensions (optional, composable)"] - UI["ui - user interface bridge (interactive modules)"] + VC["videre:venue/client - typed intent access to installed venues"] + UI["ui - user interface bridge (design only, no WIT)"] end subgraph L1["Layer 1: Universal Runtime Interfaces"] CSN["chain - consensus access (JSON-RPC passthrough)"] - ID["identity - cryptographic identity (key management, signing)"] + ID["identity - cryptographic identity"] LS["local-store - local key-value persistence"] RS["remote-store - decentralised content-addressed storage"] MSG["messaging - decentralised pub/sub messaging"] @@ -67,240 +30,116 @@ graph TD EXP["Exports: init(config) + on-event(event)"] end - L3 -->|"builds on via WIT include"| L2 - L2 -->|"builds on via WIT include"| L1 + L3 -->|"exports videre:venue/adapter, routed by the host through"| L2 + L2 -->|"adds imports to"| L1 ``` -Each layer builds on the one below via WIT `include`. A module compiled against Layer 1 alone runs on any conforming host. A module compiled against Layer 3 (e.g. `shepherd:cow`) requires a host that implements Layers 1 + the CoW extension. +Layer 1 is the world every module compiles against. A Layer 2 capability adds an import to a module's manifest-derived world: a keeper module declares `client` and gains `videre:venue/client`. Layer 3 is not a world: a domain enters as a venue adapter component installed into the host's venue registry, and modules reach it through the Layer 2 client interface. No module compiles against a domain world. ## Layer 1: Universal Interfaces -These six interfaces form the universal runtime contract. Any platform - server, mobile, WebView, desktop - can implement them. +Six interfaces form the universal runtime contract. The signatures below are the shipped `wit/nexum-host` package. -### `chain` - Consensus Access +### `chain` -The module's window into blockchain consensus. A single generic function that forwards JSON-RPC requests to the host's provider infrastructure, plus an additive batched variant. The host decides *how* to reach the chain - the module only specifies *what* to ask. +Forwards JSON-RPC to the host's provider infrastructure, plus a batched variant. The reference server host forwards a read-only method surface and refuses signing or mutating methods (`eth_sendTransaction`, `eth_sign`, `personal_sign`, ...) with a `denied` fault before they reach the provider. ```wit interface chain { use types.{chain-id, fault}; - /// A structured JSON-RPC error carrying the node code and revert bytes. record rpc-error { code: s32, message: string, data: option> } - - /// Either a shared host `fault` or a structured JSON-RPC error. variant chain-error { fault(fault), rpc(rpc-error) } + record rpc-request { method: string, params: string } + variant rpc-result { ok(string), err(chain-error) } - /// Execute a JSON-RPC request against the specified chain. - /// - /// The host routes to its configured provider for the given chain, - /// applying whatever middleware is appropriate for the platform - /// (timeout, retry, rate-limit, fallback on server; simple HTTP - /// on mobile; window.ethereum or injected provider in WebView). - /// - /// `method` includes the namespace prefix (e.g. "eth_call"). - /// `params` and the success value are JSON-encoded strings. + /// `method` includes the namespace prefix (e.g. "eth_call"); `params` + /// and the success value are JSON-encoded strings. request: func(chain-id: chain-id, method: string, params: string) -> result; - /// Additive 0.2 method: batched JSON-RPC. - request-batch: func(chain-id: chain-id, calls: list>) - -> result>, chain-error>; + /// Batched JSON-RPC over one chain. The result list matches `requests` + /// in length and order; hosts that cannot batch natively fall back to + /// sequential `request` calls. + request-batch: func(chain-id: chain-id, requests: list) + -> result, chain-error>; } ``` -**Platform implementations:** - -| Platform | `chain::request` backed by | -|----------|--------------------------| -| Server (Nexum) | alloy provider with tower middleware (timeout, retry, rate-limit, fallback) | -| Mobile (Flutter) | HTTP client (reqwest via FFI, or Dart `http` package) to configured RPC endpoint | -| WebView | JavaScript bridge -> `window.ethereum` (injected wallet) or native HTTP via message channel | -| Super app | Same as mobile, with per-module chain permissions | - -The Rust SDK's `HostTransport` (doc 07) works identically on all platforms - it implements alloy's `Transport` trait over `chain::request`, so module authors get the full alloy `Provider` API regardless of where the module runs. +The SDK's `HostTransport` (doc 07) implements alloy's `Transport` trait over `chain::request` / `chain::request-batch`, so module authors get the alloy `Provider` API regardless of host. -### `identity` - Cryptographic Identity +### `identity` -Provides key management and signing capabilities to modules. ECDSA secp256k1 by default (the Ethereum standard), extensible to other schemes. Modules can enumerate available accounts and request signatures over arbitrary data. - -The `chain` host implementation depends on `identity` internally - signing RPC methods such as `eth_sendTransaction` or `eth_signTypedData_v4` delegate to `identity` for the actual cryptographic signature. Modules can also import `identity` directly for raw signing operations outside of JSON-RPC (e.g. signing EIP-712 typed data for off-chain order submission). +Account enumeration and signing. ECDSA secp256k1 by default. The reference server host leaves this interface unimplemented: `accounts` returns an empty roster and `sign` / `sign-typed-data` return the `unsupported` fault. A keystore / KMS backend is not yet shipped. ```wit interface identity { use types.{fault}; - /// List available accounts (public keys or addresses). - /// Returns a list of account identifiers (e.g. 20-byte Ethereum addresses). + /// Account addresses (20-byte EVM) the host will sign for. Empty means + /// no signing capability. accounts: func() -> result>, fault>; - /// Sign arbitrary data with the specified account's private key. - /// Returns the signature bytes (e.g. 65-byte ECDSA signature with recovery id). - sign: func(account: list, data: list) -> result, fault>; + /// personal_sign semantics; returns a 65-byte signature. + sign: func(account: list, message: list) -> result, fault>; - /// Sign EIP-712 typed structured data. - /// `typed-data` is the JSON-encoded EIP-712 typed data structure. - /// Returns the signature bytes. + /// Sign EIP-712 typed data; `typed-data` is JSON-encoded. sign-typed-data: func(account: list, typed-data: string) -> result, fault>; } ``` -**Platform implementations:** - -| Platform | `identity` backed by | -|----------|---------------------| -| Server (Nexum) | Keystore file, AWS KMS, or HSM | -| Mobile (Flutter) | Device keychain (Keystore/Keychain) or wallet SDK | -| WebView | window.ethereum (wallet extension) or native bridge to keychain | -| Super app | Device keychain + per-module permission grants | - -**Relationship with `chain`:** +### `local-store` -The `chain` host implementation uses `identity` internally when it encounters signing methods. For example, when a module calls `chain::request` with `eth_sendTransaction`, the host: - -1. Constructs the transaction from the JSON-RPC params. -2. Calls `identity::sign` to produce the signature. -3. Sends the signed transaction via the provider. - -This means modules that only need to sign transactions via standard JSON-RPC methods do not need to import `identity` directly - `chain` handles it transparently. Modules that need raw signing (e.g. off-chain message signing for order submission, attestations, or custom protocols) import `identity` explicitly. - -### `local-store` - Local Key-Value Persistence - -The module's private scratchpad. **Local to the device/process** - does not replicate, sync, or share across instances. Scoped to one module: module A cannot read module B's local state. +The module's private, device-local scratchpad. Scoped to one module: module A cannot read module B's state. Does not replicate. ```wit interface local-store { use types.{fault}; - /// Get a value by key. Returns None if the key does not exist. get: func(key: string) -> result>, fault>; - - /// Set a key-value pair. Overwrites any existing value. - /// The host MAY enforce a size quota; if exceeded, returns fault.invalid-input. + /// The host may enforce a size quota; if exceeded, `set` returns err. set: func(key: string, value: list) -> result<_, fault>; - - /// Delete a key. No-op if the key does not exist. delete: func(key: string) -> result<_, fault>; - - /// List all keys matching a prefix. Empty prefix returns all keys. + /// Empty prefix returns all keys. list-keys: func(prefix: string) -> result, fault>; + contains: func(key: string) -> result; + len: func(key: string) -> result, fault>; + count: func(prefix: string) -> result; } ``` -**Platform implementations:** - -| Platform | `local-store` backed by | -|----------|-------------------------| -| Server (Nexum) | redb (per-module database file, ACID, MVCC) | -| Mobile (Flutter) | SQLite (per-module table or database, via `sqflite`) | -| WebView | IndexedDB (per-module object store) or `localStorage` | -| Super app | SQLite (shared database, per-module namespace isolation) | - -The semantics are deliberately minimal - get, set, delete, prefix scan. This is the LCD (lowest common denominator) that every platform can implement efficiently. Advanced features (transactions, MVCC, crash-safety) are host-specific and not exposed in the WIT. - -The server runtime's all-or-nothing transactional semantics (doc 04) remain an implementation detail of the Nexum host, not a guarantee modules can rely on across platforms. Modules that need stronger guarantees should design for idempotency. - -### `remote-store` - Decentralised Content-Addressed Storage +The server runtime's transactional semantics (doc 04) are an implementation detail, not a cross-platform guarantee; modules that need stronger guarantees design for idempotency. -Backed by Ethereum Swarm. Provides decentralised persistence beyond the local device - content-addressed, censorship-resistant, and accessible from any host on any device. +### `remote-store` -Swarm is both the distribution mechanism (modules are fetched from Swarm) and a runtime capability. This interface closes the loop - modules can publish to the same network they were distributed through. +Backed by Ethereum Swarm: content-addressed persistence beyond the local device, plus mutable feeds. The same network modules are distributed through (docs 02, 03). ```wit interface remote-store { use types.{fault}; - /// Upload raw data to the decentralised store. /// Returns the 32-byte content reference (Swarm address). - /// - /// The host routes to its configured Bee node. Postage batch - /// management is the host's responsibility - the module only - /// provides data and gets back a reference. upload: func(data: list) -> result, fault>; - - /// Download raw data by 32-byte content reference. - /// - /// The host fetches from its Bee node or a public gateway. - /// Returns the raw bytes. The caller is responsible for - /// interpreting the content (JSON, protobuf, WASM, etc.). download: func(reference: list) -> result, fault>; - /// Read the latest value from a mutable feed. - /// - /// Feeds are mutable pointers: (owner, topic) -> latest chunk. - /// `owner`: 20-byte Ethereum address of the feed owner. - /// `topic`: 32-byte topic hash. - /// - /// Returns None if the feed has no updates. - read-feed: func( - owner: list, - topic: list, - ) -> result>, fault>; - - /// Update a mutable feed with new data. - /// - /// The host signs the feed update with its configured identity - /// (Bee node's Ethereum key). Only the host's own feeds can be - /// updated - the owner is implicit (the host's address). - /// - /// `topic`: 32-byte topic hash. - /// `data`: the payload to publish. - /// - /// Returns the 32-byte reference of the new chunk. - write-feed: func( - topic: list, - data: list, - ) -> result, fault>; + /// Feeds are mutable pointers (owner, topic) -> latest chunk. + read-feed: func(owner: list, topic: list) -> result>, fault>; + /// The host signs the update with its configured identity; the owner + /// is implicit. Returns the 32-byte reference of the new chunk. + write-feed: func(topic: list, data: list) -> result, fault>; } ``` -**Platform implementations:** - -| Platform | `remote-store` backed by | -|----------|--------------------------| -| Server (Nexum) | Direct Bee API (`http://localhost:1633`) | -| Mobile (Flutter) | Bee API via HTTP (local light node or remote gateway) | -| WebView | JavaScript bridge -> native HTTP to Bee gateway | -| Super app | Embedded Bee light node or gateway proxy | - -**Why remote-store as a universal interface:** - -- **Decentralised persistence.** `local-store` is device-local. `remote-store` gives modules access to content-addressed storage that persists independent of any single device. -- **Content distribution.** Modules can publish data (feeds, references) that other modules or users can consume - without a central server. -- **Cross-device coordination.** Two instances of the same module on different devices can share data via feed topics - one writes via `write-feed`, the other reads via `read-feed`. -- **Consistency with distribution model.** Modules are already fetched from Swarm (doc 02, 03). Exposing `remote-store` at runtime means modules participate in the same content-addressed network they were distributed through. - -### `messaging` - Decentralised Messaging +### `messaging` -Backed by Waku. Provides real-time, privacy-preserving pub/sub messaging between modules, devices, and users. Unlike `remote-store` (persistent, content-addressed), `messaging` is transient and topic-based - fire-and-forget messages on content topics. +Backed by Waku: transient, topic-based pub/sub. Sending uses `publish`; receiving is declared as a manifest subscription and delivered through `on-event`. ```wit interface messaging { - use types.{fault}; - - record message { - content-topic: string, - payload: list, - timestamp: u64, // milliseconds since Unix epoch, UTC - /// Optional sender identity (protocol-dependent). - sender: option>, - } + use types.{fault, message}; - /// Publish a message to a content topic. - /// - /// The host routes to its configured Waku node. The message is - /// propagated to all subscribers of the content topic via the - /// Waku relay (gossipsub) or light push protocol. - /// - /// Content topics follow the format: //// - /// e.g. "/nexum/1/twap-updates/proto" + /// Content topics follow ////. publish: func(content-topic: string, payload: list) -> result<_, fault>; - - /// Query historical messages from the Waku store protocol. - /// - /// Returns messages matching the content topic within the - /// optional time range. Not all hosts support store queries - /// (depends on Waku node configuration). query: func( content-topic: string, start-time: option, @@ -310,139 +149,64 @@ interface messaging { } ``` -**Receiving messages** is handled through the event system, not the `messaging` interface. Modules declare message subscriptions in their manifest, and the host delivers them as events: - ```toml [[subscription]] kind = "message" content_topic = "/nexum/1/twap-updates/proto" ``` -The event variant in 0.2 carries `message` as a first-class variant: - -```wit -record message { - content-topic: string, - payload: list, - timestamp: u64, // milliseconds since Unix epoch, UTC - sender: option>, -} - -variant event { - block(block), - chain-logs(chain-logs), - tick(tick), - message(message), -} -``` - -This follows the same pattern as all other event sources: sending uses the import interface (`messaging::publish`), receiving uses the declarative subscription + `on-event` dispatch. - -**Platform implementations:** - -| Platform | `messaging` backed by | -|----------|-----------------| -| Server (Nexum) | Waku node (nwaku or go-waku) via JSON-RPC or REST API | -| Mobile (Flutter) | Waku light client via FFI (libwaku) or HTTP to remote Waku node | -| WebView | JavaScript bridge -> native Waku client, or js-waku in-browser | -| Super app | Embedded Waku light node | - -**Why messaging as a universal interface:** - -- **Module-to-module communication.** Two modules on different devices can exchange real-time messages via shared content topics. The TWAP monitor on a server can notify a mobile dashboard module that a new part was posted. -- **User notifications.** A headless server module can publish an alert to a content topic; the user's mobile app module subscribes and displays a notification. -- **Decentralised coordination.** Multiple instances of the same module (e.g. running on different operator nodes) can coordinate via messaging - leader election, work distribution, heartbeats. -- **Privacy.** Waku supports encrypted messaging and ephemeral relay. Modules can communicate without exposing data to the public chain. -- **Complementary to remote-store.** `remote-store` is for persistent content (data that should survive). `messaging` is for ephemeral signals (notifications, coordination, real-time feeds). Together they cover the full persistence spectrum. - -### `logging` - Structured Logging - -Unchanged from the current design: +### `logging` ```wit interface logging { enum level { trace, debug, info, warn, error } - - /// Emit a structured log message. - /// The host decides how to handle it (stdout, file, discard). log: func(level: level, message: string); } ``` -Every platform implements this trivially. On server: `tracing` crate. On mobile: platform logger (`android.util.Log`, `os_log`). In WebView: `console.log`. The SDK's `info!`, `debug!`, etc. macros compile to this. +### Universal world definition -### Universal World Definition +The shared types and the `event-module` world: ```wit -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface types { type chain-id = u64; - record block { - chain-id: chain-id, - number: u64, - hash: list, - timestamp: u64, // ms since Unix epoch, UTC - } + record block { chain-id: chain-id, number: u64, hash: list, timestamp: u64 } record chain-log { - address: list, - topics: list>, - data: list, - block-hash: option>, // block-scoped fields absent on a pending log - block-number: option, - block-timestamp: option, - transaction-hash: option>, - transaction-index: option, - log-index: option, - removed: bool, - } - - // A batch of logs from one subscription; the alloy log carries no chain - // id, so it sits here once and every log shares the subscription's chain. - record chain-logs { - chain-id: chain-id, - logs: list, + address: list, topics: list>, data: list, + block-hash: option>, block-number: option, + block-timestamp: option, transaction-hash: option>, + transaction-index: option, log-index: option, removed: bool, } - - record tick { - fired-at: u64, // ms since Unix epoch, UTC - } - + record chain-logs { chain-id: chain-id, logs: list } + record tick { fired-at: u64 } record message { - content-topic: string, - payload: list, - timestamp: u64, // ms since Unix epoch, UTC + content-topic: string, payload: list, timestamp: u64, sender: option>, } + /// A domain extension's own event kind and opaque payload; the core + /// routes by `kind` and never reads `payload`. + record custom-event { kind: string, payload: list } variant event { - block(block), - chain-logs(chain-logs), - tick(tick), - message(message), + block(block), chain-logs(chain-logs), tick(tick), + message(message), custom(custom-event), } - /// Opaque config (typed variant deferred to 0.3). type config = list>; - /// Shared cross-domain failure vocabulary. Richer interfaces embed it - /// as a case; interfaces with nothing to add report it directly. variant fault { unsupported(string), unavailable(string), denied(string), rate-limited(rate-limit), timeout, invalid-input(string), internal(string), } - - record rate-limit { - retry-after-ms: option, - } + record rate-limit { retry-after-ms: option } } -// ... chain, identity, local-store, remote-store, messaging, logging interfaces as above ... - -/// Event-driven module - automation, background processing. -/// No UI capabilities. Runs on any conforming host. Six imports in 0.2. +/// Event-driven module. No UI capabilities. Runs on any conforming host. world event-module { import chain; import identity; @@ -456,580 +220,93 @@ world event-module { } ``` -A module compiled against `nexum:host/event-module` is the **maximally portable** artifact. In 0.2 it runs on the server reference runtime; mobile and WebView hosts are planned (see the status banner at the top of this doc). - -## Layer 2: UI Interface +Time, randomness, and outbound HTTP are WASI concerns, not `nexum:host` interfaces: `wasi:clocks` and `wasi:random` are linked into every module store; `wasi:http/outgoing-handler` is linked gated per-module by the `[capabilities.http].allow` allowlist. -Interactive modules - those with a user-facing presence in a super app or WebView container - import the `ui` interface in addition to the Layer 1 universals. +The experimental `nexum:host/query-module` world (a pure `evaluate` entry point over `local-store` and `logging`) is published but has no host implementation in 0.2. -### Design Approach +## Layer 2: Capability Extensions -The `ui` interface is a **bridge**, not a rendering engine. It does not define a widget tree, layout system, or styling language. Instead, it provides the communication channel between the module's logic (running in WASM) and the host's UI surface (a WebView, native view, or terminal). +A capability adds imports to a module's manifest-derived world beyond the Layer 1 core. Two are additive, declared in the manifest's `[capabilities]` section and not part of the six-primitive core: -For WebView-based hosts (the primary target for interactive modules), the module's UI is a web application (HTML/CSS/JS) served into a WebView by the host. The `ui` interface gives the module control over this surface and access to native capabilities that a normal web page cannot reach. +- `client`: the `videre:venue/client` intent surface (see [Layer 3](#layer-3-domain-extensions-venue-adapters)). +- `http`: allowlisted `wasi:http/outgoing-handler`. -```wit -interface ui { - record ui-error { - code: u16, - message: string, - } - - /// Emit a UI update. - /// - /// For WebView hosts: `content` is an HTML fragment or a JSON - /// message that the WebView's JavaScript layer interprets. - /// For native hosts: `content` is a declarative description - /// (format negotiated via host-info). - /// - /// The `target` identifies which UI surface to update - /// (e.g. "main", "overlay", "notification-badge"). - render: func(target: string, content: string) -> result<_, ui-error>; - - /// Request navigation to a different view or module. - /// - /// `target`: a route string (e.g. "/settings", "module:price-alert"). - /// `params`: key-value parameters for the target. - navigate: func( - target: string, - params: list>, - ) -> result<_, ui-error>; - - /// Show a native notification (outside the WebView). - notify: func(title: string, body: string) -> result<_, ui-error>; - - /// Prompt the user for a yes/no decision via native dialog. - confirm: func(title: string, body: string) -> result; - - /// Query the host's UI capabilities. - record host-capabilities { - /// "android" | "ios" | "desktop" | "web" | "terminal" - platform: string, - /// Content format the host expects for render(). - /// "html" | "json-widget" | "markdown" - render-format: string, - supports-notifications: bool, - supports-biometric: bool, - } +A `ui` interface for interactive modules (a bridge between module logic and a host UI surface) is designed but ships no WIT; there is no `app-module` world in 0.2. - host-info: func() -> host-capabilities; -} -``` +## Layer 3: Domain Extensions (Venue Adapters) -### Module Exports for Interactive Modules - -Interactive modules export additional lifecycle hooks beyond `init` and `on-event`: +A domain (CoW Protocol, a DEX, a lending market) extends the platform as a **venue adapter**: a component authored with `#[videre_sdk::venue]` that exports the `videre:venue/adapter` interface and imports scoped transport only (`chain`, `messaging`, allowlisted `wasi:http`). The domain's wire protocol, body codec, and error projection live inside the adapter component; nothing domain-specific enters the host or any module world. ```wit -/// Interactive module - has a UI presence. -world app-module { - include event-module; - import ui; - - /// Called when the module's UI surface is first displayed. - /// Returns initial content to render. - export on-render: func() -> result; - - /// Called when the user interacts with a UI element. - /// - /// `element-id`: identifier of the element (set by the module in its render output). - /// `action`: interaction type ("click", "submit", "change", etc.). - /// `data`: optional payload (form data, input value, etc.). - export on-interact: func( - element-id: string, - action: string, - data: option, - ) -> result<_, string>; +package videre:venue@0.1.0; + +/// Worker (keeper) face. The host holds the venue registry; the keeper +/// names a venue by string. +interface client { + use videre:types/types.{quotation, receipt, intent-status, submit-outcome, venue-error}; + + quote: func(venue: string, body: list) -> result; + submit: func(venue: string, body: list) -> result; + observe: func(venue: string, receipt: receipt) -> result<_, venue-error>; + status: func(venue: string, receipt: receipt) -> result; + cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; } -``` - -This creates a bidirectional loop: - -```mermaid -flowchart TD - A["Host calls on-render"] --> B["Module returns initial UI content"] - B --> C["Host displays in WebView/native surface"] - C --> D["User interacts"] - D --> E["Host calls on-interact(element, action, data)"] - E --> F["Module processes interaction"] - F --> G["module calls ui::render(target, new-content) to update UI"] - F --> H["module calls chain::request to read chain state"] - F --> I["module calls local-store::set to persist"] - G --> C -``` - -The module's logic runs in the WASM sandbox. The UI runs in the WebView (or native surface). The `ui` interface is the bridge between them. This is analogous to Elm's update loop or React's message-passing model, but across the WASM-host boundary. - -### WebView Module Packaging -A WebView-based interactive module bundles its web assets alongside the WASM component: - -``` -price-dashboard/ -├── module.toml # manifest (declares world: app-module) -├── module.wasm # compiled WASM component -└── ui/ - ├── index.html # entry point (loaded into WebView) - ├── app.js # UI logic (receives on-interact, calls render) - └── style.css # styling -``` - -The host loads `index.html` into a WebView and injects the bridge JavaScript that connects DOM events to `on-interact` and `ui::render` calls to DOM updates. - -## Layer 3: Domain Extensions - -Domain-specific interfaces extend the universal layer for particular use cases. The pattern: - -```wit -package shepherd:cow@0.2.0; - -interface cow-api { - use nexum:host/types.{chain-id, fault}; - - record http-failure { status: u16, body: option } - record order-rejection { status: u16, error-type: string, description: string, data: option } - variant cow-api-error { fault(fault), http(http-failure), rejected(order-rejection) } - - request: func( - chain-id: chain-id, - method: string, - path: string, - body: option, - ) -> result; - - submit-order: func(chain-id: chain-id, order-data: list) - -> result; -} - -world shepherd { - include nexum:host/event-module; - import cow-api; +/// Provider (venue) face. Mirrors `client` without the venue selector: +/// one installed adapter answers for exactly one venue. +interface adapter { + use videre:types/types.{intent-header, quotation, receipt, intent-status, submit-outcome, venue-error}; + + body-versions: func() -> list; + derive-header: func(body: list) -> result; + quote: func(body: list) -> result; + submit: func(body: list) -> result; + status: func(receipt: receipt) -> result; + cancel: func(receipt: receipt) -> result<_, venue-error>; } ``` -Other domains follow the same pattern: +The two faces meet in the host. The venue platform (`videre/crates/videre-host`, one `nexum-runtime` extension registered at the composition root) holds the `VenueRegistry`, links `videre:venue/client` into keeper worlds, and routes each call: resolve the venue id to its installed adapter, run the advisory egress guard over the adapter's pure `derive-header` projection, then invoke the adapter face. Accepted submits go under a status watch; the platform polls the adapter's `status` and fans transitions back to subscribed modules as `intent-status` events. Intent bodies are opaque on this path; typing is a guest-side agreement between keeper and adapter over the venue's published `IntentBody` schema ([doc 05](05-sdk-design.md#bodies-the-intentbody-derive)). -```wit -// Hypothetical DeFi yield module -package defi:yield@0.1.0; +A new domain adds a component and (usually) a body crate, never a WIT package or a host change: write the adapter with `#[videre_sdk::venue]`, publish its codec vectors and header goldens, and install it via the engine's `[[adapters]]` table. The `shepherd` binary is exactly this composition: the core lattice plus the videre platform, with CoW entering only as the bundled `cow-venue` adapter. -interface vault { /* ... */ } -interface strategy { /* ... */ } +### The legacy read path: `shepherd:cow` -world yield-module { - include nexum:host/event-module; - import vault; - import strategy; -} -``` - -The `include` mechanism ensures that any domain-specific module inherits the full universal interface set. A `shepherd` module can call `chain::request`, `identity::sign`, `local-store::get`, `remote-store::upload`, `messaging::publish`, and `logging::log` - plus the CoW-specific `cow-api::request` and `cow-api::submit-order`. +The retired predecessor to venue adapters was a Layer-3 *world* extension: a `shepherd:cow/cow-api` interface (orderbook passthrough plus `submit-order`), a `shepherd` world importing it, and a host-side extension cone implementing it over a cached orderbook client. That path is deleted. The `cow-api` interface, the `shepherd` / `cow-ext` worlds, and the host cone are gone; orderbook I/O lives in the `cow-venue` adapter behind `videre:venue/client`. The `shepherd:cow` package remains only as `cow-events`, the package of record for the CoW on-chain event ABIs (signatures and topic-0 hashes) that keeper manifests and decoders are parity-tested against. [ADR-0005](adr/0005-cow-api-via-cached-orderbookapi.md) and [ADR-0006](adr/0006-cow-twap-ethflow-host-helpers.md) are superseded accordingly. ## Complete WIT Package Layout ``` wit/ ├── nexum-host/ -│ ├── types.wit # chain-id, block, log, tick, message, event, config, fault -│ ├── chain.wit # chain interface (consensus access + request-batch) -│ ├── identity.wit # identity interface (key management, signing) -│ ├── local-store.wit # local-store interface -│ ├── remote-store.wit # remote-store interface (Swarm) -│ ├── messaging.wit # messaging interface (Waku) -│ ├── logging.wit # logging interface -│ ├── ui.wit # ui interface + host-capabilities (planned hosts only) +│ ├── types.wit # chain-id, block, log, tick, message, custom-event, event, config, fault +│ ├── chain.wit # chain interface (request + request-batch) +│ ├── identity.wit # identity interface (accounts, signing) +│ ├── local-store.wit +│ ├── remote-store.wit # Swarm +│ ├── messaging.wit # Waku +│ ├── logging.wit │ ├── event-module.wit # event-module world (6 imports) -│ ├── query-module.wit # experimental: query-module world (no host impl in 0.2) -│ └── app-module.wit # app-module world (includes ui) - design only +│ └── query-module.wit # experimental: no host impl in 0.2 │ +├── videre-value-flow/ +│ └── types.wit # asset + asset-amount vocabulary +├── videre-types/ +│ └── types.wit # intent-header, quotation, receipt, submit-outcome, intent-status, venue-error +├── videre-venue/ +│ └── venue.wit # client + adapter interfaces, venue-adapter world └── shepherd-cow/ - ├── cow-api.wit # merged cow-api interface (request + submit-order) - └── shepherd.wit # shepherd world (includes event-module + cow-api) + └── cow-events.wit # CoW event-ABI package of record (legacy package name) ``` -The `nexum-host` package is domain-agnostic and reusable. The `shepherd-cow` package is the CoW Protocol extension. New domains add new packages without touching the universal layer. +The `nexum-host` package is domain-agnostic. The `videre` packages are the venue-neutral intent contract. `shepherd-cow` carries only the CoW event ABIs. New domains add adapter components, not packages. ## Platform Targets -### Server Runtime (Reference Implementation - Nexum) - -This is the current design (docs 01-07), adapted for the layered WIT. Shepherd is the Nexum distribution with CoW Protocol support. - -| Interface | Implementation | -|-----------|---------------| -| `chain` | alloy provider with tower middleware (timeout, retry, rate-limit, fallback) | -| `identity` | Keystore file, AWS KMS, or HSM - operator-configured signing backend | -| `local-store` | redb (per-module database file, ACID, MVCC, crash-safe) | -| `remote-store` | Bee API (`http://localhost:1633`) - operator runs a Bee node | -| `messaging` | Waku node (nwaku) via JSON-RPC or REST API | -| `logging` | `tracing` crate -> JSON structured logs | -| `cow-api` | reqwest HTTP client -> CoW Protocol API (REST passthrough + typed `submit-order`) | -| Event sources | `eth_subscribe` (blocks, logs), cron (Tokio interval), Waku relay (messages) | -| WASM engine | wasmtime 45.x (Component Model, fuel, epoch metering) | - -### Mobile App (Flutter/Dart) - Planned - -> **Status:** No mobile host ships in 0.2. The design below is the target architecture for a future release (0.3+, conditional on a named design partner). It's retained because the WIT contract was shaped to make this implementation possible, and the `query-module` world in 0.2 is the experimental contract a mobile/wallet embedder would target. - -A Flutter application would embed a WASM runtime and provide the universal interfaces via Dart implementations: - -```mermaid -flowchart TD - subgraph FlutterApp["Flutter App"] - subgraph WASMRuntime["WASM Runtime (via FFI)"] - ENGINE["wasmtime C API or wasmer_dart or wasm3 (lightweight, C-based)"] - ModA["Module A (headless)"] - ModB["Module B (headless)"] - end - - subgraph HostAdapter["Host Adapter (Dart)"] - HA_CHAIN["chain -> HTTP client to RPC endpoint"] - HA_ID["identity -> device keychain (Keystore/Keychain) or wallet SDK"] - HA_LS["local-store -> SQLite (sqflite)"] - HA_RS["remote-store -> HTTP to Bee gateway"] - HA_MSG["messaging -> libwaku via FFI"] - HA_LOG["logging -> platform logger"] - end - - subgraph EventSources["Event Sources (Dart)"] - ES["Block polling (HTTP, no WebSocket on mobile background), timer via Dart Timer, Waku subscription via light client, push notifications (optional)"] - end - - ModA --> HostAdapter - ModB --> HostAdapter - HostAdapter --> EventSources - end -``` - -**WASM engine options:** - -| Engine | Component Model | Mobile support | Notes | -|--------|----------------|----------------|-------| -| wasmtime (C API) | Full | aarch64 (iOS/Android ARM64) | Best compatibility, largest binary size (~15 MB) | -| wasmer | Partial | Good (wasmer_dart exists) | Component Model support is partial | -| wasm3 | None | Excellent (tiny C library, ~100 KB) | Interpreter only, no Component Model - requires core module + shim | - -For full Component Model support (identical module binaries across server and mobile), **wasmtime via C API** is the recommended path. Dart's FFI (`dart:ffi`) can call the wasmtime C API directly. The binary size cost (~15 MB) is acceptable for a mobile app. - -**Mobile-specific constraints:** - -- **Background execution.** iOS and Android aggressively suspend background processes. A mobile host cannot maintain persistent WebSocket subscriptions. Event sourcing must be adapted: poll on foreground, use push notifications or local alarms for time-sensitive events. -- **Battery.** Continuous block polling drains battery. The mobile host should use adaptive polling intervals and batch event processing. -- **Connectivity.** Mobile networks are intermittent. Host functions should handle offline gracefully (queue requests, retry on reconnect). -- **Waku light client.** Mobile devices should use Waku's light push and filter protocols rather than full relay to minimise bandwidth and battery consumption. - -### WebView (Browser Engine + Injected Host Functions) - Planned - -> **Status:** No WebView host ships in 0.2. The architecture below describes a future target. The `jco`-based transpilation path is the strongest candidate, but it depends on Component Model browser support stabilising and on a concrete embedder design partner. - -A WebView host would run inside a native app (or standalone browser). The WASM module executes in the browser's native WASM engine. Host functions are injected via a JavaScript bridge. - -```mermaid -flowchart TD - subgraph NativeApp["Native App Shell"] - subgraph WebView["WebView"] - WASMModule["WASM Module (browser's WASM engine)\nCalls imported functions:\nchain.request(...)\nidentity.sign(...)\nlocalStore.get(...)\nremoteStore.download(...)\nmessaging.publish(...)\nlogging.log(...)"] - - subgraph JSBridge["JavaScript Bridge (injected)"] - JS["window.nexumRuntime = {\n chain: { request: (c, m, p) =>\n nativeBridge.call('chain', ...) },\n identity: { accounts: () =>\n nativeBridge.call('identity', ...) },\n localStore: { get: (k) =>\n nativeBridge.call('store', ..) },\n remoteStore: { download: (ref) =>\n nativeBridge.call('store', ..) },\n messaging: { publish: (t, p) =>\n nativeBridge.call('messaging', ...) },\n logging: { log: (l, m) =>\n console.log(${`[l] m`}) }\n}"] - end - - WASMModule --> JSBridge - end - - subgraph NativeHost["Native Host Adapter"] - NH_CHAIN["chain -> HTTP to RPC / wallet bridge"] - NH_ID["identity -> window.ethereum / native keychain"] - NH_LS["local-store -> SQLite / IndexedDB"] - NH_RS["remote-store -> HTTP to Bee gateway"] - NH_MSG["messaging -> Waku node / js-waku"] - NH_LOG["logging -> native logger"] - end - - JSBridge -->|"message channel"| NativeHost - end -``` - -**Component Model in the browser:** - -Browsers don't natively support the WASM Component Model (as of early 2026). Two approaches: - -1. **`jco` transpilation** (recommended). The Bytecode Alliance's `jco` tool transpiles a WASM component to a core WASM module + JavaScript glue code. The JS glue implements the canonical ABI marshalling. The result runs in any browser. This means the **same `.wasm` component** built for the server can be transpiled and run in a WebView. - -2. **Core module variant.** Compile the module as a core WASM module (not a component) with a JS shim layer that maps the WIT interface to JavaScript imports. This requires a separate build target but avoids the `jco` dependency. - -Approach 1 is preferred - it preserves the single-artifact property (one `.wasm` component, multiple platforms). - -**WebView-specific capability: `window.ethereum`** - -In a browser context, the user may have a wallet extension (MetaMask, Rabby, etc.) that injects `window.ethereum`. The `chain::request` host function can optionally route through this: - -```javascript -// In the JS bridge -chain: { - request: async (chainId, method, params) => { - if (window.ethereum && useWalletProvider) { - // Route through user's wallet (gets signing capabilities too) - return await window.ethereum.request({ method, params: JSON.parse(params) }); - } else { - // Route through native bridge to configured RPC endpoint - return await nativeBridge.call('chain', { chainId, method, params }); - } - } -} -``` - -This is powerful: the same module that runs headless on a server (reading chain state via a configured RPC endpoint) can run in a WebView and read chain state via the user's wallet - gaining access to the user's connected accounts and signing capabilities. - -Similarly, the `identity` interface in a WebView context can delegate to `window.ethereum` for account enumeration and signing, providing a seamless bridge between the module's signing needs and the user's wallet extension. +The reference server runtime is the sole host. `shepherd` is the composition root: it registers the videre venue platform and bundles the `cow-venue` adapter over the core lattice (alloy provider pool for `chain`, redb for `local-store`, Bee for `remote-store`, Waku for `messaging`, `tracing` for `logging`, wasmtime Component Model engine). -**WebView-specific capability: `js-waku`** - -For messaging in the browser, `js-waku` provides a pure JavaScript Waku client. The `messaging` host function can route through `js-waku` directly in the WebView without needing the native bridge - peer-to-peer messaging from the browser. - -### Decentralised Super App - Planned - -> **Status:** The super app is the convergence of the mobile and WebView targets. No super-app host ships in 0.2. The content below describes the target architecture for a future release once mobile and WebView are live. - -The super app is the convergence of all targets. A native shell (Flutter) that would: - -1. **Discover modules** via ENS (doc 03) - the same discovery mechanism as the server runtime. -2. **Fetch modules** from Swarm/IPFS - the same content-addressed distribution. -3. **Run event-driven modules** in an embedded WASM runtime (automation, background tasks). -4. **Run interactive modules** in WebViews (UI, dashboards, transaction builders). -5. **Provide the universal interfaces** to all modules (chain, identity, local-store, remote-store, messaging, logging). -6. **Provide the UI interface** to interactive modules. - -```mermaid -flowchart TD - subgraph SuperApp["Super App Shell (Flutter)"] - subgraph ModMgr["Module Manager"] - DISC["Discovery: ENS -> Swarm -> content store -> verify"] - LIFE["Lifecycle: Load -> Init -> Run -> Restart -> Dead"] - PERM["Permissions: per-module capability grants"] - end - - subgraph HeadlessRT["Headless WASM Runtime"] - TWAP["TWAP Monitor"] - PRICE["Price Alert"] - end - - subgraph WebViewPool["WebView Pool"] - PORTFOLIO["Portfolio Dashboard (HTML)"] - DEX["DEX Swap Interface (HTML)"] - end - - subgraph HostLayer["Host Adapter Layer"] - HL_CHAIN["chain -> HTTP to RPC endpoints"] - HL_ID["identity -> device keychain + per-module grants"] - HL_LS["local-store -> SQLite"] - HL_RS["remote-store -> Bee light node / gateway"] - HL_MSG["messaging -> Waku light client"] - HL_LOG["logging -> app logger + optional cloud"] - HL_UI["ui -> WebView bridge (interactive modules)"] - end - - subgraph ShellUI["Shell UI (Flutter)"] - SHELL["Module gallery - Navigation - Settings - Wallet"] - end - - ModMgr --> HeadlessRT - ModMgr --> WebViewPool - TWAP --> HostLayer - PRICE --> HostLayer - PORTFOLIO --> HostLayer - DEX --> HostLayer - end -``` - -**What makes this different from Telegram/WeChat mini-programs:** - -| Aspect | Telegram/WeChat | Decentralised Super App | -|--------|-----------------|------------------------| -| Distribution | Central app store / bot platform | ENS -> Swarm/IPFS (no gatekeeper) | -| Integrity | Trust the platform | Content-addressed (hash-verified) | -| Execution | JavaScript in WebView (unrestricted) | WASM sandbox (capability-based) | -| Capabilities | Platform APIs (payments, camera, etc.) | Blockchain-native (consensus, identity, state, messaging) | -| Updates | Platform-mediated | Author updates ENS -> instant propagation | -| Censorship resistance | Platform can ban apps | ENS + Swarm = no single point of removal | -| Interoperability | Walled garden | Modules from any author, any domain | -| Communication | Platform's messaging API | Waku (decentralised, privacy-preserving) | - -**Permissions model:** - -The super app adds a capability-grant layer on top of the WIT world. When a module is installed, the user reviews what it imports: - -``` -"TWAP Monitor" requests: - ✓ chain - read blockchain state (chains: 42161) - ✓ identity - sign with your accounts - ✓ local-store - store data on your device - ✓ remote-store - read/write to Swarm network - ✓ messaging - send/receive messages (topics: /nexum/1/twap-*) - ✗ ui - (not requested - event-driven module) - ✓ cow-api - interact with CoW Protocol API and submit orders - - [Allow] [Deny] -``` - -The host only links interfaces the user has approved. A module that doesn't import `messaging` structurally cannot publish messages - the same structural sandboxing property that the server runtime uses (doc 01). - -## Host Adapter Specification - -Any platform that wants to run modules must implement the **Host Adapter** - the set of host functions backing the WIT interfaces. The specification defines the contract: - -### Required Behaviours - -In 0.2 each interface returns its own typed error over the shared `fault` vocabulary (`unsupported`, `unavailable`, `denied`, `rate-limited`, `timeout`, `invalid-input`, `internal`). The fault case is normative - embedders MUST pick the most specific case for each backend failure. See ADR-0011 and the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder-side mapping table. - -**`chain::request` / `chain::request-batch`** (Chain) -- MUST forward the JSON-RPC request to a provider for the given chain. -- MUST return the JSON-encoded result (the `result` field from the JSON-RPC response). -- MUST return `chain-error` for provider errors, method-not-found, and transport failures. A structured JSON-RPC error (a node code plus decoded revert bytes) MUST use the `rpc` case; otherwise use a `fault`: `invalid-input` for method-not-found, `unavailable`/`timeout` for transport, `rate-limited` for 429s, `denied` for 401/403. -- SHOULD enforce a method allowlist (configurable by the operator/user). -- MAY apply middleware (timeout, retry, rate-limit, fallback) - this is platform-specific. - -**`identity::accounts/sign/sign-typed-data`** (Identity) -- `accounts` MUST return the list of available account identifiers (addresses) for the current host configuration. -- `sign` MUST produce a valid cryptographic signature over the provided data using the specified account's private key. -- `sign-typed-data` MUST produce a valid EIP-712 signature over the provided typed data structure. -- MUST return a `fault`. User rejection is `denied`; unknown account is `invalid-input`; backend offline is `unavailable`. -- MAY prompt the user for approval before signing (platform-dependent - e.g. wallet extension popup in WebView, biometric prompt on mobile). -- SHOULD NOT expose private key material to the module. The module sends data in, gets a signature out. - -**`local-store::get/set/delete/list-keys`** -- MUST provide per-module isolation (module A cannot read module B's state). -- MUST persist across module restarts within the same host process/session. -- SHOULD persist across host process restarts (platform-dependent). -- MAY enforce size quotas. If exceeded, `set` returns `fault.invalid-input` (not a trap). -- MAY provide transactional semantics. Modules SHOULD NOT rely on this across platforms. - -**`remote-store::upload/download/read-feed/write-feed`** -- MUST route to a Swarm-compatible node or gateway. -- `upload` MUST return the 32-byte content reference of the stored data. -- `download` MUST return the raw bytes for a valid reference, or `fault.unavailable` for missing/unreachable content. -- `write-feed` signs with the host's identity. The owner is implicit. -- MAY return `fault.unavailable` for offline / no-node-configured. - -**`messaging::publish/query`** -- MUST route `publish` to a Waku-compatible node. -- `publish` MUST deliver the message to the content topic's relay network on a best-effort basis. -- `query` SHOULD return historical messages if the host's Waku node supports the store protocol. -- `query` MAY return an empty list or `fault.unsupported` if store is unavailable. -- MAY apply rate limits (returning `fault.rate-limited`) to prevent message spam. - -**`logging::log`** -- MUST accept log calls without blocking or erroring. -- MAY discard logs (e.g. below a configured level threshold). -- Output destination is entirely host-specific. - -**Event dispatch (`on-event`)** -- MUST call `init(config)` exactly once before any `on-event` calls. -- MUST call `on-event` for each subscribed event (per manifest). -- MUST support all four event variants: `block`, `logs`, `tick`, `message`. -- SHOULD guarantee in-order delivery within a single module. -- MAY dispatch events concurrently across modules. -- SHOULD handle panics/traps gracefully (restart module, not crash host). - -### Optional Behaviours (Platform-Specific) - -| Capability | Server | Mobile | WebView | -|------------|--------|--------|---------| -| Fuel metering | Yes (wasmtime) | Maybe (engine-dependent) | No (browser engine) | -| Epoch interruption | Yes (Tokio task) | No | No (browser manages scheduling) | -| Memory limits | Yes (`ResourceLimiter`) | Limited (engine-dependent) | No (browser enforces its own limits) | -| Transactional state | Yes (redb write txn) | Optional (SQLite txn) | No (IndexedDB is async) | -| WebSocket subscriptions | Yes | Limited (background constraints) | Yes (if tab is active) | -| Push-based events | N/A | Yes (FCM/APNs) | N/A | -| Waku full relay | Yes | No (light client) | Maybe (js-waku) | - -## Content-Addressed Distribution: Works Everywhere - -The packaging and distribution model (doc 02, 03) is already platform-agnostic: - -``` -Module author: - 1. Build WASM component - 2. Create manifest (module.toml) - 3. Upload bundle to Swarm -> get content hash - 4. Set ENS contenthash -> content hash - -Any host (server, mobile, WebView): - 1. Resolve ENS name -> contenthash - 2. Fetch bundle from Swarm (or IPFS/OCI/HTTP gateway) - 3. Verify sha256(module.wasm) matches manifest - 4. Load module -``` - -The only platform-specific part is **how** the host fetches from Swarm: -- Server: direct Bee API -- Mobile: Bee gateway over HTTP -- WebView: fetch API to Bee gateway - -The content hash is the trust anchor. The transport is interchangeable. +Mobile (Flutter embedding a WASM runtime), WebView (browser WASM engine with host functions injected over a JavaScript bridge), and a decentralised super app (ENS discovery plus Swarm distribution over the same interfaces) are design directions the portable WIT contract is shaped to admit; none ships in 0.2. ## SDK Layering -The SDK is designed to mirror the WIT layering. **The two-crate split is shipped: `nexum-sdk` carries the universal surface (host-trait seam, bind macro, chain / config / address helpers, `http::fetch`, tracing facade) and `shepherd-sdk` layers the CoW domain on top, with no re-export between them.** The host-trait seam is from [ADR-0009](adr/0009-host-trait-surface.md). The diagram below describes the 0.3+ target for the typed-client layer: - -```mermaid -graph TD - subgraph ShepherdSDK["shepherd-sdk (Domain-specific: CoW Protocol)"] - COW_ITEMS["Cow client,\n#[shepherd::module] macro\n(imports cow-api)"] - end - - subgraph NexumSDK["nexum-sdk (Universal: any blockchain app)"] - NEXUM_ITEMS["HostTransport, provider(),\nTypedState, RemoteStore,\nMessaging, Signer,\nlogging macros,\nFault / HostFault / ChainError,\n#[nexum::module] macro\n(imports chain + identity\n+ local-store\n+ remote-store + messaging\n+ logging)"] - end - - ShepherdSDK -->|"extends"| NexumSDK -``` - -- **`nexum-sdk` (shipped)** - the universal Rust SDK for any module targeting `nexum:host/event-module`. It ships the host-trait seam (`ChainHost`, `LocalStoreHost`, `LoggingHost`, supertrait `Host`), `Fault` / `HostFault` / `ChainError`, the `bind_host_via_wit_bindgen!` adapter macro, chain / config / address helpers, the `http::fetch` helper over wasi:http, and the guest tracing facade. Would additionally provide `HostTransport` (alloy `Transport` trait over `chain::request` / `chain::request-batch`), `provider(chain_id)`, `TypedState` (serde over `local-store`), `RemoteStore` (typed wrapper over `remote-store`), `Messaging` (typed wrapper over `messaging`), `Signer` (typed wrapper over `identity`). Any module author - CoW, DeFi, gaming, whatever - uses this. - -- **`shepherd-sdk` (shipped)** - the CoW-domain layer: the `CowApiHost` trait and `CowHost` bound, CoW helpers (`PollOutcome`, `RetryAction`, `gpv2_to_order_data`, `decode_revert_hex`, …), and the `bind_cow_host_via_wit_bindgen!` macro layering the generic adapter. In the 0.3+ target, it would extend `nexum-sdk` with the typed `Cow` client and the `#[shepherd::module]` proc macro. - -A module author building a generic blockchain automation module depends only on `nexum-sdk`; a CoW Protocol module depends on both `nexum-sdk` and `shepherd-sdk` and imports each directly. - -For **non-Rust** module authors (JavaScript, Python, Go, C++), the SDK is unnecessary - they use `wit-bindgen` directly against the WIT package for their target world. The WIT is the universal contract; the SDK is a Rust ergonomics layer on top. - -## Migration from 0.1 - -For the full 0.1 → 0.2 rename and behaviour change list, see the [Migration Guide](migration/0.1-to-0.2.md). The main themes: - -- WIT package `web3:runtime` → `nexum:host`; interfaces `csn` → `chain` and `msg` → `messaging`; worlds `headless-module` → `event-module` and `shepherd-module` → `shepherd`. -- CoW `cow` + `order` interfaces merged into `cow-api`. -- Each interface returns its own typed error over the shared `fault` vocabulary instead of five per-protocol error types. -- The `event-module` world imports the six primitives the docs always claimed (0.1's WIT was missing `identity` from the world definition). -- Manifest: `wasm = ...` → `component = ...`; `[[subscribe]]` → `[[subscription]]` with `kind` instead of `type`; new `[capabilities]` section drives optional/required imports; `[config]` values are now typed. -- Additive: the `http` capability (serviced by wasi:http, no new `nexum:host` WIT), `chain::request-batch`, and the experimental `query-module` world. - -## Summary - -### Primitive Taxonomy - -| Primitive | Interface | Implementation | Persistence | Scope | -|-----------|-----------|---------------|-------------|-------| -| Chain | `chain` | JSON-RPC (eth_*) | Blockchain | Global (chain) | -| Identity | `identity` | Keystore / KMS / HSM | Key material | Per-account | -| Local Store | `local-store` | redb / SQLite / IndexedDB | Device-local | Per-module | -| Remote Store | `remote-store` | Ethereum Swarm | Decentralised | Global (content-addressed) | -| Messaging | `messaging` | Waku | Ephemeral | Topic-based pub/sub | -| Logging | `logging` | tracing / console | None | Diagnostic | - -### Architecture - -| Concept | Scope | -|---------|-------| -| `nexum:host` WIT package | Universal - any blockchain app, any platform | -| `event-module` world (0.2, shipping) | Event-driven modules - server today, mobile/background planned | -| `query-module` world (0.2 experimental) | Request/response modules - WIT published, no host impl in 0.2 | -| `app-module` world | Interactive modules - design only; planned hosts | -| `shepherd:cow` WIT package | CoW Protocol domain extension | -| `shepherd` world | CoW automation modules (includes event-module + cow-api) | -| `nexum-sdk` crate (shipped) | Universal Rust SDK: host-trait seam (ADR-0009), Fault / HostFault / ChainError, bind macro, chain / config / address helpers, guest `http` helper, tracing facade. HostTransport, TypedState, RemoteStore, Messaging, Signer remain future direction | -| `shepherd-sdk` crate (shipped) | CoW-domain Rust SDK: cow-api trait + CoW helpers on top of `nexum-sdk`, no re-export between the layers. | -| Content-addressed distribution | Platform-agnostic (Swarm/IPFS, ENS discovery, hash verification) | -| Host Adapter | Platform-specific implementation of universal interfaces | - -The module binary is the portable artifact. The WIT contract is the universal interface. The host adapter is the platform-specific implementation. Everything else - packaging, distribution, discovery, SDK - layers cleanly on top. +[Doc 05](05-sdk-design.md) owns the SDK. In summary: `nexum-sdk` is the universal SDK for `nexum:host/event-module`; `videre-sdk` is the venue layer serving both venue faces; per-venue crates (`cow-venue`, `composable-cow`) sit on the venue layer. No crate re-exports another. Non-Rust authors use `wit-bindgen` directly against the WIT. diff --git a/docs/adr/0001-engine-toml-separate-from-nexum-toml.md b/docs/adr/0001-engine-toml-separate-from-nexum-toml.md index 2c294279..bb51999f 100644 --- a/docs/adr/0001-engine-toml-separate-from-nexum-toml.md +++ b/docs/adr/0001-engine-toml-separate-from-nexum-toml.md @@ -1,38 +1,24 @@ --- -status: proposed -implemented-in: nullislabs/shepherd#8, nullislabs/shepherd#9 +status: accepted --- # Operator config (`engine.toml`) is separate from module manifest (`module.toml`) ## Context -The engine needs two distinct kinds of configuration: what the **operator** decides at deployment time (which chains to connect to, where the local-store database lives, which modules to boot) and what the **module developer** declares at build time (required and optional capabilities, HTTP allowlist, module-specific config keys). These have different reviewers, different threat models, and change on different cadences. - -The filenames need to signal who owns each file directly. An operator opening a config file should know without prior context whether the file is their concern or the module developer's. A name like `nexum.toml` requires the reader to know that "nexum" refers to the runtime that hosts the module, which is one indirection too many; `module.toml` reads as "the module's manifest" with no prior context. +The runtime carries two kinds of configuration with different owners, reviewers, and change cadences: what the operator decides at deployment time (chains, local-store location, which modules to boot) and what the module developer declares at build time (required and optional capabilities, HTTP allowlist, module config keys). A module's capability declaration is a property of the build, so it belongs in the published bundle, not in the operator's local file. ## Decision -Two distinct files, distinct schemas, distinct loaders: - -- **`engine.toml`** - operator-owned, lives next to the engine binary or pointed to by `--engine-config`. Defines `[engine]` (state_dir, log_level), `[chains.]` (rpc_url), and `[[modules]]` (path, manifest). Loaded by `engine_config::EngineConfig::load`. -- **`module.toml`** - module-developer-owned, ships in the module's bundle alongside its `.wasm` component. Defines `[module]`, `[capabilities]` (required, optional, http allowlist), `[config]`. Loaded by `manifest::load`. +Two files, two schemas, two loaders: -The engine config carries the path to each module's manifest; the two never collapse into one file. The names `engine.toml` and `module.toml` map directly onto the two distinct roles, so a reader reaching either file knows whose concerns it covers. +- **`engine.toml`** operator-owned, next to the engine binary or pointed to by `--engine-config`. Defines `[engine]` (`state_dir`, `log_level`), `[chains.]` (`rpc_url`), and `[[modules]]` (path, manifest). Loaded by `engine_config::EngineConfig::load`. +- **`module.toml`** module-developer-owned, ships in the module bundle alongside its `.wasm` component. Defines `[module]`, `[capabilities]` (required, optional, http allowlist), `[config]`. Loaded by `manifest::load`. -## Considered options - -- **Single `shepherd.toml` with `[engine]`, `[chains]`, `[[modules]]` *and* nested `[modules..capabilities]` per module.** Rejected: conflates operator and developer concerns. A module's capability declaration is a property of the build, not the deployment - it belongs in the artifact, not in the operator's local file. Auditing a module's capabilities also becomes a per-deployment exercise instead of a property visible in the published bundle. -- **Keep the `nexum.toml` filename for the module manifest.** Rejected: the name does not signal who owns the file (engine vs module). `module.toml` reads as "the module's manifest" without prior context. -- **`module.toml` inside the engine config (module entries embed it inline).** Rejected for the same reason as the single-file proposal; also bloats `engine.toml`. -- **Drop `engine.toml` entirely; pass everything as CLI flags or env vars.** Rejected: per-chain RPC URLs and module lists are awkward as flags, and `RUST_LOG` already covers the only thing that env vars naturally express. +The engine config carries each module's manifest path; the two files never collapse into one. ## Consequences -- A deployment needs both files. A missing `engine.toml` falls back to "no chains, default state_dir" - the example logging module still runs; cow-api / chain backends report `unsupported`. -- A missing `module.toml` triggers the 0.1-compat deprecation warning in `manifest::fallback_manifest()` (defined in `crates/nexum-engine/src/manifest.rs`) and treats every linked capability as required. This fallback is scheduled for removal in 0.3 per `docs/migration/0.1-to-0.2.md`. -- Module-bundle redistribution carries `module.toml` with the artifact; engines do not need to ship templates. -- Future content-addressed module distribution (0.3) embeds `module.toml` in the bundle hash; `engine.toml` references modules by content address rather than filesystem path. The split survives that migration unchanged. -- Implementation impact: `crates/nexum-engine/src/manifest.rs` and `engine_config.rs` need to update the filename lookup from `nexum.toml` to `module.toml`. The 0.1-compat fallback in `manifest::fallback_manifest()` should accept both names during the transition; after 0.3 only `module.toml` is recognised. - -_Errata: `crates/nexum-engine` was renamed to `crates/nexum-runtime` + `crates/nexum-cli` in the 0.2 refactor._ +- A deployment needs both files. A missing `engine.toml` falls back to no chains and the default `state_dir` (`./data`); the example logging module still runs, chain-backed capabilities report `unsupported`. +- A `module.toml` without a `[capabilities]` block triggers the 0.1-compat deprecation warning in `manifest::fallback_manifest` (`nexum/crates/nexum-runtime/src/manifest/load.rs`) and treats every linked capability as required. +- Module-bundle redistribution carries `module.toml` with the artifact; engines ship no templates. diff --git a/docs/adr/0002-provider-pool-transport-by-scheme.md b/docs/adr/0002-provider-pool-transport-by-scheme.md index 0cecbb5c..1e0e924b 100644 --- a/docs/adr/0002-provider-pool-transport-by-scheme.md +++ b/docs/adr/0002-provider-pool-transport-by-scheme.md @@ -1,39 +1,28 @@ --- -status: proposed -implemented-in: nullislabs/shepherd#8, nullislabs/shepherd#9 +status: accepted --- # Per-chain alloy provider transport selected by URL scheme ## Context -`nexum:host/chain` covers both generic JSON-RPC dispatch (`request`) and event subscriptions (`subscribe-blocks`, `subscribe-logs`). Subscriptions require a duplex transport (`eth_subscribe` is push-only over a long-lived connection); request/response works on either HTTP or WebSocket. The operator configures one RPC endpoint per chain in `engine.toml`; the engine has to decide which alloy transport to use. +`nexum:host/chain` covers both generic JSON-RPC dispatch (`request`) and event subscriptions (`subscribe-blocks`, `subscribe-logs`). Subscriptions require a duplex transport; request/response works on either HTTP or WebSocket. The operator configures one `rpc_url` per chain in `engine.toml`, and the runtime picks the alloy transport from it. ## Decision -The `ProviderPool::from_config` constructor reads each chain's `rpc_url` and switches by URL scheme prefix: +`ProviderPool::from_config` switches on the URL scheme: -- `ws://` or `wss://` → `ProviderBuilder::new().connect_ws(WsConnect::new(url))`. Pubsub transport. Subscriptions and request/response both work. **This is the recommended configuration for any chain a module subscribes to.** -- `http://` or `https://` → `ProviderBuilder::new().connect_http(parsed)`. HTTP transport. Request/response only; `subscribe-blocks` and `subscribe-logs` surface as `fault.unsupported` to the guest. +- `ws://` / `wss://` connect via `connect_ws`. Pubsub transport: subscriptions and request/response both work. Recommended for any chain a module subscribes to. +- `http://` / `https://` connect via `connect_http`. Request/response only; `subscribe-blocks` and `subscribe-logs` return `fault.unsupported` to the guest. -Both transports erase to `DynProvider` so the rest of the engine is transport-agnostic. - -Alloy is capable of emulating `eth_subscribe` on HTTP via polling, but this is intentionally **not** enabled. The engine takes an opinionated stance favouring WebSockets for subscriptions; operators who want push-based events configure WSS endpoints. HTTP-only chains are supported for `request` traffic but not for subscriptions. +Both erase to `DynProvider`, so the rest of the runtime is transport-agnostic. Alloy can emulate `eth_subscribe` on HTTP by polling; this is deliberately not enabled. ## Non-goals -- **RPC failover, load balancing, and retry policies are explicitly out of scope for the engine.** This logic lives in upstream crates (alloy ships tower-style middleware for timeout / retry / rate-limit / fallback endpoint). The engine does not roll its own. Operators wanting failover configure it via alloy provider builders before passing them through, or rely on the provider's own fallback (Alchemy, Infura, etc. handle it server-side). -- Re-routing requests across chains, rebalancing across pools within a chain, and similar provider-management concerns are likewise alloy's responsibility. - -## Considered options - -- **Force WSS everywhere.** Rejected: many providers (Alchemy, Infura, self-hosted RPC) expose HTTP-only on free tiers, and modules that only need `request` (no subscriptions) shouldn't be blocked by a WSS requirement. -- **Explicit `transport = "ws" | "http"` field per chain in `engine.toml`.** Rejected for 0.2: redundant with the URL scheme, and operators already distinguish `wss://` from `https://` endpoints when copying them from their RPC provider's dashboard. Revisit if we add IPC (`/path/to/geth.ipc`) - scheme alone won't carry that. -- **Open both an HTTP and a WSS connection per chain.** Rejected: doubles connection count for the common case where one endpoint serves both, and forces operators to provide two URLs even when their provider returns identical data on both. +RPC failover, load balancing, and retry policy are out of scope. Alloy ships tower-style middleware for timeout, retry, rate-limit, and fallback endpoints; operators configure it on the provider builder, or rely on their provider's server-side fallback. ## Consequences -- Operators that need subscriptions must supply WSS URLs; HTTP-only chains downgrade to request-only mode at the host call boundary. -- Connection failures at boot are fatal (the engine refuses to start with a broken chain). This is intentional - silent fall-back to a half-functioning state masks misconfiguration that a module then rediscovers at first event. -- Adding IPC support is additive: extend the scheme match with `/` / `file://` and call `connect_ipc`. -- The `DynProvider` erasure costs a virtual dispatch per call - a measurable concern at scale, deferred to M4 if profiling shows it. +- Operators needing subscriptions supply WSS URLs; HTTP-only chains downgrade to request-only at the host call boundary. +- Connection failure at boot is fatal: the runtime refuses to start with a broken chain rather than masking misconfiguration a module rediscovers at first event. +- Adding IPC is additive: extend the scheme match with `file://` and call `connect_ipc`. diff --git a/docs/adr/0003-local-store-namespacing.md b/docs/adr/0003-local-store-namespacing.md index a7a5ac12..8bfe1f87 100644 --- a/docs/adr/0003-local-store-namespacing.md +++ b/docs/adr/0003-local-store-namespacing.md @@ -1,49 +1,24 @@ --- -status: proposed -implemented-in: nullislabs/shepherd#8 +status: accepted --- # Per-module namespacing in `local-store` via 32-byte deterministic hash prefix ## Context -`nexum:host/local-store` is a key-value store shared across all modules the engine runs. Two modules using the same key string (e.g. `"last-block"`) must see disjoint values; one module must never read or overwrite another's data. The engine knows each module's identity at instantiation time, so namespacing is a host-side concern. - -Two properties matter for the namespace prefix: - -1. **Deterministic and unspoofable.** An arbitrary `module_name` string read out of `module.toml` lets a malicious or careless operator give two modules the same name and have one read the other's state. A fixed-size hash derived from the module's canonical identity is harder to collide and removes the operator-supplied-text attack surface. -2. **Composes with ENS-based module discovery** (per `docs/03-module-discovery.md`): when a module is identified by an ENS name (e.g. `twap-monitor.shepherd.eth`), the ENS namehash is a natural prefix. ENS TXT records pinning the `.wasm` content hash provide a separate verification path against the loaded bundle. +`nexum:host/local-store` is a key-value store shared across every module the runtime runs. Two modules using the same key string must see disjoint values, and one module must never read or overwrite another's data. The runtime knows each module's identity at instantiation, so namespacing is a host-side concern. The prefix must be deterministic and unspoofable: an operator-supplied `module_name` string would let two modules collide by name, so the prefix derives from the module's canonical identity as a fixed-size hash. ## Decision -Single redb database file at `EngineConfig.engine.state_dir`, single shared table `nexum:local-store`. Every key handed to redb is composed host-side as: - -``` -[32-byte namespace prefix][raw key bytes] -``` - -The 32-byte prefix is computed deterministically from the module's canonical identity: - -- **ENS-identified modules** (M3+, per `docs/03`): prefix is `ens_namehash(name)` (EIP-137), e.g. `namehash("twap-monitor.shepherd.eth")`. -- **Locally-loaded modules** (current 0.2 scope, no ENS): prefix is `keccak256(module_name)` where `module_name` comes from `module.toml`'s `[module].name` field. - -Both produce a 32-byte digest with the same domain, so a module loaded locally during development and later published under an ENS name can keep its existing state by registering an alias (`alias = keccak256(name)`) the engine recognises during the migration window. The exact alias mechanism is out of scope for this ADR. - -Modules see plain key strings on both the read and write paths; the prefix is invisible to the WIT-facing API. +Single redb database file at `EngineConfig.engine.state_dir`, single shared table. Every key handed to redb is composed host-side as `[32-byte namespace prefix][raw key bytes]`. -## Considered options +The prefix is `keccak256(module_name)`, where `module_name` is `module.toml`'s `[module].name`. keccak256 shares the domain of the ENS namehash, so a module loaded locally and later published under an ENS name (see `docs/03-module-discovery.md`) can keep its state via an alias registered during migration; the alias mechanism is out of scope here. -- **Separator string** (`{module}:{key}`). Rejected: any module name containing `:` collides with another module's `:`-bearing key. A fixed-size hash is unambiguous regardless of payload bytes. -- **`[len:u8][module_name][key]` length-prefixed string.** Rejected: spoofable (the name is operator-supplied text), and does not align with the ENS-based discovery path that 0.3 will introduce. The 32-byte hash is deterministic and namespace-uniform. -- **One redb database file per module.** Rejected: multiplies open file handles linearly in modules, blocks any future cross-module atomic operations (not currently planned but cheap to keep on the table), and complicates backup tooling (N files vs 1). -- **One redb *table* per module within a single file.** Rejected: redb `TableDefinition` lifetimes are `'static`, so table names must be known at compile time. Dynamic table opening per module would force string-leak workarounds and exposes the same name-collision question as separator-based keys. -- **Engine-allocated incrementing module id.** Rejected: stable across reboots only if the engine persists the allocation table, which adds a chicken-and-egg dependency on the local-store itself. Determinism from the name avoids the dependency entirely. +Modules see plain key strings on both paths; the prefix is invisible to the WIT API. ## Consequences -- The prefix is fixed-size (32 bytes) and independent of module name length. Range scans over a single module's keys are O(log n + module-key-count) - fine for our workload. -- Migrations changing the prefix derivation (e.g., switching the local-mode hash function or the ENS resolver) would orphan every existing module's persisted state. The derivation must stay stable through 0.x; ENS-mode introduction in 0.3 happens additively via the alias mechanism, not by changing existing prefixes. -- A module's `list-keys` iterates over the namespace range (32-byte prefix scan); the host strips the prefix before returning to the guest. -- Module data versioning (schema migrations across module versions) is the module's responsibility. The local-store does not version values; modules MAY embed a `schema_version` byte in their stored payloads and migrate on `init` when the read value's version differs from the current code's expectation. -- ENS-based discovery (per docs/03) integrates without a prefix-format change: when a module is loaded by ENS name, the prefix is `namehash(name)`. The corresponding `.wasm` content hash is verified via ENS TXT records before loading, separately from the local-store prefix derivation. -- Spoofing protection: an operator cannot make module A read module B's state by renaming, because the prefix is the hash of the canonical name. Renaming a module to match another's name produces a name conflict the engine refuses at boot, rather than silent state takeover. +- The prefix is fixed-size and independent of key length. A module's `list-keys` iterates the 32-byte prefix range; the host strips the prefix before returning to the guest. +- Changing the prefix derivation would orphan every module's persisted state, so the derivation stays stable through 0.x; ENS-mode namespacing is introduced additively via the alias mechanism, not by changing existing prefixes. +- The store does not version values. Modules that need schema migration embed their own version marker in stored payloads and migrate on `init`. +- An operator cannot make module A read module B's state by renaming: matching names produces a boot-time conflict, not a silent state takeover. diff --git a/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md b/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md index c8ba48b4..2b587b29 100644 --- a/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md +++ b/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md @@ -1,38 +1,21 @@ --- -status: proposed -implemented-in: nullislabs/shepherd#10 +status: accepted --- -# Patch `cowprotocol` crate to the head of upstream PR #5 +# Patch the `cowprotocol` crate to a maintained fork ## Context -`cowprotocol` v1.0.0-alpha.3 (the version on crates.io) was cut from an early snapshot of `cowdao-grants/cow-rs` PR #5 at commit `1742ffa`. That PR is still open and is the canonical upstream channel for landing additions to the Rust SDK. Its head branch is `bleu/cow-rs:main`, currently at commit `c012404`, carrying 18 follow-up commits the engine materially depends on: - -- `composable::Proof` byte-width fix (consumed by the TWAP poll path). -- `OrderCreation` zero-`from` fast-fail (closes a MEDIUM severity finding in PR #5). -- `order_book` / `composable` submodule splits (cleaner imports on the engine side). - -ADR-0007 commits us to landing three protocol-level primitives into PR #5 directly (`OrderPostError` rich variants + `retry_hint`, `OrderBookApi::with_base_url`, and `wasm32` feature-gating) by pushing additional commits to its head branch. Each commit advances both PR #5 and the patch rev consumed here. - -There is no published `alpha.4` and no scheduled date for one; the engine cannot wait. +The workspace needs `cowprotocol` changes ahead of any published release: the `OrderCreationAppData` hash-only submission shape (`OrderCreation::new_app_data_hash_only`, watch-tower parity for conditional-order submission) and a WASI clock fix that keeps `js_sys` out of non-browser wasm builds. The latest crates.io release (`0.2.0-alpha.1`) carries neither. ## Decision -Add a workspace-level `[patch.crates-io]` redirecting `cowprotocol` to `https://github.com/bleu/cow-rs` at commit `c012404`. Every crate that declares `cowprotocol = "1.0.0-alpha.3"` (engine, modules, future SDK) silently picks up the patched build with no `Cargo.toml` change at the dependent site. - -This is not a parallel fork. `bleu/cow-rs:main` IS the head branch of upstream PR #5. Pushing to it updates PR #5; the patch rev advances by bumping a single workspace line. - -## Considered options +A single workspace-level `[patch.crates-io]` redirects `cowprotocol` to `https://github.com/nullislabs/cow-rs` at a pinned rev. Every crate declaring `cowprotocol` picks up the patched build with no change at the dependent site. Bumping the fork is a one-line rev edit. -- **Vendor the missing types locally.** Rejected: re-implementing `composable::Proof`, `OrderCreation`, etc. in the engine repo is the AI-duplication anti-pattern that the cow-rs SDK already solves. Reuse over reimplement applies. -- **Pin every dependent to `cow-rs` git directly.** Works but every new workspace member has to remember the git source. `[patch.crates-io]` centralises the override. -- **Open a separate PR per primitive against `cowdao-grants/cow-rs`.** Rejected: fragments the change across multiple PRs when one already exists at the appropriate granularity. Stacking commits on PR #5 keeps the change coherent and lets the cumulative diff be tracked in one place. -- **Wait for `alpha.4` to publish.** No ETA; the TWAP/EthFlow milestone cannot land without `composable::Proof` correct. +Rather than vendor the missing types locally (reuse over reimplement) or pin each dependent to a git source, the `[patch.crates-io]` override centralises the redirect. ## Consequences -- `cargo update` will re-resolve to the same `rev`; the lock pins it. -- Bumping the rev is a single-line workspace edit; reviewers see one diff per primitive added to PR #5. -- Drop the patch entirely once a published `cowprotocol` release contains both the alpha.3 follow-ups and the ADR-0007 protocol-primitive additions (`OrderPostError` rich variants + `retry_hint`, `OrderBookApi::with_base_url`, `wasm32` feature-gate). Until then, expect the patch rev to advance with every push to PR #5. -- Modules built against this workspace inherit the patch transitively; modules built standalone against crates.io will see `alpha.3` and may hit the very bugs the patch closes. Flag this in the SDK README when M3 lands. +- `cargo update` re-resolves to the same rev; the lock pins it. +- Drop the patch once a published `cowprotocol` release carries the hash-only constructor. The comment above `[patch.crates-io]` in the root `Cargo.toml` states the current drop condition. +- Modules built standalone against crates.io see the unpatched release and may hit the bugs the patch closes. diff --git a/docs/adr/0005-cow-api-via-cached-orderbookapi.md b/docs/adr/0005-cow-api-via-cached-orderbookapi.md index 37493689..0c5a570e 100644 --- a/docs/adr/0005-cow-api-via-cached-orderbookapi.md +++ b/docs/adr/0005-cow-api-via-cached-orderbookapi.md @@ -1,34 +1,15 @@ --- -status: proposed -implemented-in: nullislabs/shepherd#8 +status: superseded --- # `cow-api` host backend routes both `request` and `submit-order` through `cowprotocol::OrderBookApi` -## Context - -`shepherd:cow/cow-api` exposes two operations: a generic REST passthrough (`request`) and a typed order submission (`submit-order`). Either could be implemented with raw `reqwest` against `api.cow.fi/{slug}/api/v1`, but the published `cowprotocol` crate already ships an `OrderBookApi` client that knows the chain-specific base URL, the canonical paths, and the `post_order` codec. - -## Decision - -At engine boot, construct one `cowprotocol::OrderBookApi` per `cowprotocol::Chain` variant (currently Mainnet, Gnosis, Sepolia, ArbitrumOne, Base) into a `BTreeMap` keyed by EVM chain id. "Cached" here means built once during boot and reused for the engine's lifetime; clients are not lazy-constructed on each call nor LRU-evicted. The pool implements `Default` so callers instantiate it as `OrderBookPool::default()`; the trait impl populates the map with one entry per `cowprotocol::Chain` variant. +> **Superseded by the videre venue-adapter architecture.** The `shepherd:cow/cow-api` host extension and its `OrderBookApi` backend are retired: orderbook submission and status ride the `cow-venue` adapter component over `wasi:http`, driven through the `videre:venue/client` pool seam. -Both `cow-api` operations consult this pool: - -- `request` resolves the chain's `OrderBookApi`, reads `api.base_url()` for the prefix, joins the module-supplied path, and dispatches via a shared `reqwest::Client`. -- `submit-order` deserialises the JSON `OrderCreation` and calls `OrderBookApi::post_order` directly. The crate handles signing-scheme encoding, error mapping, and `OrderUid` extraction. - -Chains not in `cowprotocol::Chain` return `cow-api-error` carrying `fault.unsupported` at the host call boundary. - -## Considered options +## Context -- **Raw `reqwest` for both.** Rejected: forces us to maintain the chain → base-URL table (drifts whenever cowprotocol adds a chain) and reimplement `post_order`'s body codec and error mapping, the exact duplication the cow-rs SDK already eliminates. -- **`OrderBookApi` for `submit-order`, raw `reqwest` for `request`.** Tempting (request is opaque to the crate) but means two separate chain-resolution paths, two HTTP clients, and a second place to keep the chain set in sync. -- **Build `OrderBookApi` lazily on first call per chain.** Rejected: hides config errors at runtime. Up-front boot construction surfaces unknown chains immediately and amortises away the per-call cost. +`shepherd:cow/cow-api` exposed a generic REST passthrough (`request`) and a typed order submission (`submit-order`). The `cowprotocol` crate already shipped an `OrderBookApi` client that knew the chain base URL, canonical paths, and `post_order` codec. -## Consequences +## Decision (retired) -- Operator-supplied custom orderbook URLs (barn, staging, forked deployments) are out of scope for the default constructor and require a follow-on `OrderBookApi::with_base_url(chain_id, base_url)` constructor in the cow-rs crate (ADR-0007 item 2, not vendored locally). -- Adding a chain means a `cowprotocol::Chain` variant lands in cow-rs first; the engine inherits it on the next patched rev bump. -- The shared `reqwest::Client` enables connection pooling across both `request` and `submit-order` paths. -- Guest-side TWAP and EthFlow modules (ADR-0006) submit orders through this `cow-api` interface; no specialised host helpers wrap it. +At boot, build one `cowprotocol::OrderBookApi` per `cowprotocol::Chain` variant into a `BTreeMap` keyed by chain id, reused for the runtime's lifetime. `request` resolved the chain client and joined the module-supplied path; `submit-order` deserialized the JSON `OrderCreation` and called `OrderBookApi::post_order`. Chains outside `cowprotocol::Chain` returned `fault.unsupported`. diff --git a/docs/adr/0006-cow-twap-ethflow-host-helpers.md b/docs/adr/0006-cow-twap-ethflow-host-helpers.md index 96f8d02b..d59df80f 100644 --- a/docs/adr/0006-cow-twap-ethflow-host-helpers.md +++ b/docs/adr/0006-cow-twap-ethflow-host-helpers.md @@ -1,51 +1,21 @@ --- -status: proposed +status: superseded --- -# TWAP and EthFlow run as guest modules using low-level host primitives (no specialised `shepherd:cow` interfaces) +# TWAP and EthFlow run as guest modules using low-level host primitives -## Context - -TWAP (over ComposableCoW) and EthFlow are the two CoW workflows the M2 grant ships modules for. The natural-seeming approach is to add `shepherd:cow/twap` and `shepherd:cow/ethflow` WIT interfaces that the host implements on top of `cowprotocol` crate primitives, so modules would call `twap.poll-and-submit(...)` and `ethflow.submit-from-log(...)` as host functions. This ADR rejects that direction. +> **Superseded by the videre venue-adapter architecture.** The strategies-as-guest-modules line holds, but the protocol seam it assigned to `shepherd:cow/cow-api` is retired: modules submit typed intent bodies through the `videre:venue/client` pool seam, and the `cow-venue` adapter owns the orderbook edge. -The dividing line is protocol vs implementation. CoW Protocol primitives - order types, signing schemes, the orderbook REST surface - are protocol concerns and belong in shared layers (`cowprotocol` crate, `shepherd:cow/cow-api` interface). TWAP is one of many strategies built _on top of_ those primitives; ComposableCoW is the contract surface a TWAP module observes, but the act of polling, deciding when to submit, and reacting to orderbook errors is application logic. Putting that application logic in the host or in `cowprotocol` couples every consumer to one implementation and one error-handling policy. +## Context -Embedding a concrete TWAP implementation in an SDK is an architectural smell the grant explicitly seeks to alleviate. The grant seeks to enable Shepherd as the runtime where many independent strategy implementations coexist, each compiled to its own WASM module. A specialised `twap` interface in the host would defeat that goal: every Shepherd deployment would have to use the same polling implementation, the same error-mapping, the same retry hints, with no room for different strategies to differ on those choices. +TWAP (over ComposableCoW) and EthFlow are strategies built on top of CoW Protocol primitives, not protocol concerns themselves. The dividing line is protocol vs implementation: order types, signing schemes, and the orderbook surface belong in shared layers; polling, submit timing, and error reactions are application logic. Putting that logic in the host or in `cowprotocol` would force every deployment onto one implementation and one error-handling policy. ## Decision -The `shepherd:cow` WIT package contains only the existing `cow-api` interface (REST passthrough + `submit-order`), which is protocol-level. No `twap` interface, no `ethflow` interface, no host-side helpers specific to either workflow. - -TWAP and EthFlow modules implement their logic in Rust guest code using: - -- **`nexum:host/chain`** - `request` (for `eth_call`, `eth_getLogs`, etc.), `subscribe-blocks`, `subscribe-logs`. -- **`nexum:host/local-store`** - for watch lists, cursors, and backoff state. -- **`nexum:host/logging`** - for structured logs. -- **`shepherd:cow/cow-api`** - `submit-order` for orderbook submission. -- **`cowprotocol` crate** (consumed directly by the module, gated on the wasm32 feature work in ADR-0007) - for protocol types: `Order`, `OrderCreation`, `OrderUid`, signing schemes, `OrderPostError`, etc. -- **`alloy_sol_types`** (or equivalent) - for ABI-aware decoding of `ConditionalOrderCreated`, `OrderPlacement`, `getTradeableOrderWithSignature` return values, and similar Solidity-typed payloads. - -Concretely, a TWAP module's `on_event(block)` handler iterates the local-store watch set, makes an `eth_call` to `ComposableCoW.getTradeableOrderWithSignature(owner, params, "", [])` via `chain.request`, decodes the return (or revert reason) with `alloy_sol_types`, constructs an `OrderCreation` with `cowprotocol` types, and submits via `cow-api/submit-order`. Orderbook errors are interpreted via `OrderPostError::retry_hint()` (ADR-0007). Backoff state is persisted to `local-store`. All of this lives in module Rust source, not in the engine. - -An EthFlow module's `on_event(log)` handler decodes the `OrderPlacement` event with `alloy_sol_types`, constructs the `OrderCreation` (with the EIP-1271 signing scheme pointing at the `CoWSwapEthFlow` contract), and submits the same way. Module-side, no host helper required. - -## Considered options - -- **Specialised `shepherd:cow/twap` and `shepherd:cow/ethflow` interfaces** with rich `PollOutcome` variants and per-event host helpers, backed by `composable::poll_and_build_order` and `eth_flow::decode_placement` primitives in the `cowprotocol` crate. Rejected: this puts a single concrete TWAP / EthFlow implementation behind a WIT boundary, forcing every Shepherd deployment to use the same polling policy, the same error-mapping, the same retry hints. It also blurs the protocol-vs-implementation boundary the grant is meant to clarify. Multiple TWAP implementations (different polling cadences, different error tolerances, different cancel-on-loss thresholds) must be able to coexist as separate modules without changing the host or the SDK. -- **Move TWAP / EthFlow primitives into `cowprotocol` crate but skip the WIT interfaces**, leaving modules to call `composable::poll_and_build_order` from guest code. Rejected for the same reason: `cowprotocol` is the protocol SDK, not the strategy SDK. Putting TWAP logic there embeds an implementation in the shared layer, which is the smell the grant seeks to fix. -- **Ship a thin `shepherd-sdk` helper crate** that wraps the low-level primitive calls (eth_call, decode, submit) into a convenient `Twap::poll(...)` interface for guest modules. **Acceptable for M3** because the helper would live in guest-callable code, not behind a WIT boundary - a module that wants different polling policy just doesn't use the SDK helper. The host stays neutral. -- **EthFlow as pure passive observer (no submission)**. Rejected on closer read of `cowprotocol/services/crates/autopilot/src/database/onchain_order_events/ethflow_events.rs`: the canonical CoW flow expects the event to be relayed into the orderbook, which is what autopilot currently does internally. Shepherd's `ethflow-watcher` externalises that role, so the module does submit; just from guest code, not via a specialised host interface. -- **TWAP merkle-proof / `setRoot` support in v1.** Deferred. The 0.2 module only handles `ComposableCoW.create()` (empty proof, single conditional order). `setRoot` polling requires off-chain proof derivation; when a real module needs it, it will be implemented in guest code using the same low-level primitives, possibly with an SDK helper to encapsulate the proof bookkeeping. +The host stays protocol-neutral: no `twap` or `ethflow` host interface. TWAP and EthFlow modules implement their logic in guest Rust over the universal host primitives (`chain`, `local-store`, `logging`) plus the venue submit seam, using `cowprotocol` crate types for `Order` / `OrderCreation` / `OrderUid` / signing schemes and `alloy_sol_types` for ABI decoding. Different polling strategies coexist as separate modules chosen via `engine.toml`'s `[[modules]]`. ## Consequences -- `shepherd:cow@0.2.0` keeps `cow-api` as its only interface. No new WIT files in this ADR. -- `KNOWN_CAPABILITIES` in `crates/nexum-engine/src/manifest.rs` does **not** gain `"twap"` or `"ethflow"` entries. Modules declare the universal capabilities they actually use: `chain`, `local-store`, `logging`, `cow-api`. -- Modules ship larger (~150 LOC each estimated, up from the ~30 LOC the host-helper design implied), because event decoding, eth_call orchestration, OrderCreation construction, and error-hint interpretation now live in guest code. This is the explicit trade-off: more code per module, less coupling, more freedom for different strategies to coexist. -- Different TWAP polling strategies can coexist as different modules. Operators choose which to load via `engine.toml`'s `[[modules]]` array. -- The watch-tower TypeScript implementation remains the closest reference for what a TWAP module's logic looks like, but it is reference material, not a template the Rust module mirrors verbatim. A newer ComposableCoW iteration in development may simplify the polling surface significantly; the relevant decisions live in the module, not the host. -- `OrderPostError` rich variants + `retry_hint()` (ADR-0007 item 1, formerly item 3) become the primary protocol-level contract between the orderbook and any module submitting orders. Modules `match` on the typed error and apply the `RetryHint` (try-next-block / backoff-seconds / drop). This logic is generic across TWAP, EthFlow, stop-loss, and any future strategy. -- The M3 SDK (`shepherd-sdk` crate) is the natural home for ergonomic guest-side helpers: `WatchSet`, `PollLoop`, `BackoffLedger`, decode-and-submit utilities. The SDK is opt-in for module authors and lives entirely on the guest side; the host remains protocol-neutral. -- The architecture and sequence diagrams in `docs/diagrams/` that depict `twap.poll-and-submit` and `ethflow.submit-from-log` host calls reflect the rejected design and must be updated to show modules calling low-level primitives directly. - -_Errata: `crates/nexum-engine` was renamed to `crates/nexum-runtime` + `crates/nexum-cli` in the 0.2 refactor._ +- `KNOWN_CAPABILITIES` gains no `twap` or `ethflow` entry; modules declare only the universal capabilities they use. +- Modules ship larger because event decoding, `eth_call` orchestration, order construction, and error-hint handling live in guest code. This is the explicit trade-off: more code per module, less coupling. +- `OrderPostError::retry_hint` (ADR-0007) is the orderbook-submit contract shared across strategies. The poll mechanism itself is superseded by [ADR-0013](0013-composable-cow-structured-poll.md). diff --git a/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md b/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md index 3a828f5e..afa1f771 100644 --- a/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md +++ b/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md @@ -1,46 +1,21 @@ --- -status: proposed +status: accepted --- -# Push CoW Protocol primitives to `cow-rs` first, adopt in `nexum-engine` second +# Push CoW Protocol primitives to `cow-rs` first, adopt in the runtime second ## Context -Implementing ADR-0005 (cow-api backend) and supporting guest-side TWAP / EthFlow modules per ADR-0006 surfaces a recurring question: when the engine or its modules need a piece of CoW Protocol logic that the `cowprotocol` Rust SDK does not yet expose (rich orderbook error variants, custom orderbook URLs, wasm32 compatibility), do we write that logic locally and tidy it up upstream later, or do we add it to the open upstream PR first and only land the engine wiring afterwards? - -The failure mode is well-known: duplicating work that an existing crate could do is the AI-coding anti-pattern most likely to land in a contribution. The same risk applies to any engine-side reimplementation of protocol logic. - -The line between **protocol primitives** (which belong in `cowprotocol`) and **strategy implementations** (which belong in guest modules, per ADR-0006) is the operating principle. This ADR covers only the protocol-primitive additions; TWAP polling and EthFlow event decoding stay in guest modules and are explicitly **not** primitives we push to `cowprotocol`. +When the runtime or its modules need CoW Protocol logic the `cowprotocol` crate does not yet expose, the choice is to write it locally and tidy up upstream later, or add it upstream first and land the wiring afterwards. Duplicating logic an existing crate could own is the anti-pattern to avoid. Protocol primitives (order types, signing schemes, orderbook errors) belong in `cowprotocol`; keeper implementations (TWAP polling, EthFlow decoding) stay in guest modules per ADR-0006. ## Decision -Protocol-level CoW logic - anything that an indexer, a bot, or a non-`nexum` Rust consumer of CoW Protocol would also need to interact with the protocol - lands as additional commits on `cowdao-grants/cow-rs` PR #5 first (head branch `bleu/cow-rs:main`), and is consumed by `nexum-engine` and by guest modules via the `[patch.crates-io]` rev bump (ADR-0004). The engine and the modules never write throwaway local copies of the same logic with the intent to "port later". - -The concrete set of primitives this ADR commits to upstream, in priority order: - -1. **`cowprotocol::OrderPostError` rich variants + `retry_hint(&self) -> RetryHint`** - typed orderbook submission errors (`QuoteNotFound`, `InvalidQuote`, `InsufficientAllowance`, `InsufficientBalance`, `TooManyLimitOrders`, `InvalidAppData`, `AppDataFromMismatch`, `SellAmountOverflow`, `ZeroAmount`, `TransferSimulationFailed`, `ExcessiveValidTo`, …) with a `retry_hint()` helper classifying each into `TryNextBlock`, `BackoffSeconds(u64)`, or `Drop`. Mirrors watch-tower's `API_ERRORS_TRY_NEXT_BLOCK` / `API_ERRORS_BACKOFF` / `API_ERRORS_DROP` tables. Without this, every Rust consumer of CoW reinvents the same mapping, and modules spam the orderbook with permanently-broken orders. **Critical-path, not optional.** - -2. **`cowprotocol::OrderBookApi::with_base_url(chain_id, base_url)`** - custom-URL constructor for barn / staging / forked deployments. Unblocks per-chain orderbook URL overrides in `engine.toml` (ADR-0005). - -3. **`cowprotocol` `wasm32` compatibility** - feature-gate the `reqwest` dependency so guest modules can use the pure types (`Order`, `OrderCreation`, `OrderUid`, signing schemes, error variants) without dragging in an HTTP client. **Critical for ADR-0006**: modules implement TWAP and EthFlow logic in guest code and need `cowprotocol` types compiled to wasm32. Without this, guest modules fall back to duplicating type definitions. - -Lower-priority follow-ons (`OrderUid::from_slice`, retry middleware on `OrderBookApi`, `OrderCreation::from_gpv2`) are good-to-have but are not blocking for the M2 host or module scope. - -## Considered options +Protocol-level CoW logic, anything a non-`nexum` Rust consumer of the protocol would also need, lands in `cowprotocol` first and is consumed via the `[patch.crates-io]` rev bump (ADR-0004). The runtime never writes throwaway local copies with intent to port later. -- **Implement locally, refactor upstream later.** Faster short term but predictably leaves an indeterminate amount of duplicated logic in the engine, contradicts the conventions established on cow-rs PR #5, and grows technical debt every time cow-rs evolves the underlying types. Rejected. -- **Push TWAP / ComposableCoW primitives** (`composable::poll_and_build_order`) into `cowprotocol`. Rejected: TWAP is a concrete strategy on top of the protocol, not part of the protocol. Putting it in the SDK forces every consumer to use one polling implementation and one error-mapping policy. Per ADR-0006, TWAP polling lives in guest module code, not in shared layers. -- **Push EthFlow log-decoding primitives** (`eth_flow::decode_placement`) into `cowprotocol`. **Rejected for the same reason**: EthFlow event decoding is an implementation detail of how a particular module relays orders into the orderbook. The protocol layer defines the order types and the orderbook submission endpoint; the act of decoding an on-chain event into an `OrderCreation` is module-side logic. Modules decode `OrderPlacement` directly with `alloy_sol_types` and construct the `OrderCreation` with the EIP-1271 signing scheme. -- **Wait for cow-rs upstream maintainers to add these on their own.** No evidence anyone else is doing this work; the grant timeline does not permit waiting. -- **Vendor a fork of cow-rs inside `nullislabs/shepherd`.** Worst of all worlds: blocks neither the engine nor cow-rs from drifting, and forces every other CoW consumer to re-derive the same primitives. -- **Host-side `AppDataResolver` (LRU cache + GET against `/api/v1/app_data/{hash}`).** Rejected after verifying watch-tower's behavior: it never fetches app-data. The trader uploads the JSON to the orderbook via `PUT /api/v1/app_data/{hash}` separately; the relayer module just submits and reacts to `INVALID_APP_DATA` (backoff 1 min) / `APPDATA_FROM_MISMATCH` (drop) via the error map in item 1 above. +The primitives this covers: `OrderPostError` rich variants plus `retry_hint`, classifying each submission error into try-next-block / backoff / drop; and `wasm32` compatibility (feature-gating `reqwest`) so guest modules use the pure types compiled to wasm without an HTTP client. ## Consequences -- Every M2 engine or module issue that consumes one of the three primitives above is blocked on the corresponding commit landing in PR #5's head branch. Items 1, 2, 3 can be authored as independent commits and pushed in parallel rather than serially. -- `[patch.crates-io]` rev in the workspace `Cargo.toml` (ADR-0004) is bumped after each push to PR #5; the bump is the engine's signal that a new primitive is consumable. -- Commits added to PR #5 follow its established conventions: alloy reuse over local reimplementation, GPL-3.0, edition 2024, terse rustdoc. -- The engine repo stays small: `nexum-engine` contains WIT, host wiring, supervisor, redb store, alloy provider pool, and `engine.toml` schema, with nothing about CoW Protocol semantics. -- Guest modules consume `cowprotocol` types directly (gated on the wasm32 feature in item 3). The `shepherd-sdk` crate in M3 may add ergonomic wrappers on top, but those live on the guest side, not behind a WIT boundary. -- A follow-on Bleu module - the Rust-side equivalent of `cowprotocol/refunder` (permissionless `invalidateOrder` triggering for expired EthFlow orders) - becomes natural to ship once an ethflow-watcher module lands. Out of scope for M2 but explicitly enabled by the same primitives. -- TWAP polling logic (decode `ConditionalOrderCreated`, eth_call `getTradeableOrderWithSignature`, decode return, build `OrderCreation`) and EthFlow event decoding stay entirely in guest module code. The `cowprotocol` crate provides only the types and the orderbook client; the strategy is the module's. +- The runtime repo stays free of CoW Protocol semantics: it holds WIT, host wiring, supervisor, redb store, provider pool, and the `engine.toml` schema. +- Guest modules consume `cowprotocol` types directly, gated on the wasm32 feature. +- `cowprotocol` now publishes to crates.io (workspace pins `0.2.0`); a `[patch.crates-io]` override to `nullislabs/cow-rs` remains active pending a release with the hash-only `OrderCreationAppData` constructor (ADR-0004). diff --git a/docs/adr/0008-factory-subscriptions-in-manifest.md b/docs/adr/0008-factory-subscriptions-in-manifest.md index f7fff7da..6b9f253e 100644 --- a/docs/adr/0008-factory-subscriptions-in-manifest.md +++ b/docs/adr/0008-factory-subscriptions-in-manifest.md @@ -1,56 +1,28 @@ --- status: deferred -deferred-to: 0.3 --- -# Dynamic address registration for log subscriptions (deferred to 0.3) +# Dynamic address registration for log subscriptions ## Status -**Deferred to 0.3.** Neither TWAP nor EthFlow (the M2 grant deliverables) needs this capability, and the design's complexity is not justified by current need. - -This ADR is preserved as a reference for the design space; the final shape will be revisited when the first module actually requiring dynamic address registration emerges. +**Deferred.** No current module needs it, and the schema and host-function surface add runtime complexity nothing exercises yet. Preserved as a record of the design space; the shape is revisited when a module actually requiring dynamic address registration emerges. ## Context -Some module archetypes need to track contracts deployed dynamically by a factory, for example Uniswap V3 pools (deployed by `UniswapV3Factory`). Static `[[subscription]]` declarations in `module.toml` cannot express this: the child addresses are not known when the module's manifest is authored. - -Neither TWAP nor EthFlow needs this; both subscribe to a single well-known contract per chain. This ADR was originally framed as forward-looking work to land in 0.2's breaking-change window. - -## Why deferred - -Two considerations motivate the deferral: - -1. **`eth_getLogs` already supports topic-only filtering.** The JSON-RPC method accepts a filter without an `address` field, so a module subscribing to a topic across all addresses can be served by the existing primitives if the operator's RPC endpoint cooperates. If topic-only filters at the JSON-RPC layer are good enough for the common case, the engine does not need a manifest-and-host-function mechanism on top. -2. **The schema and host-function surface add engine complexity that no M2 deliverable consumes.** The historical-backfill story is the largest contributor to that complexity and was already trimmed once; deferring the rest in the same spirit avoids paying for a mechanism nothing exercises yet. - -Combined: the dynamic-subscription design is not load-bearing for M2 deliverables, and the simplest path (topic-only `eth_subscribe` filters with module-side address filtering) may suffice for a wide range of indexer use cases. The dynamic-registration mechanism originally proposed (Envio-style `register-address`) addresses scaling concerns at high address counts but should land when a real consumer is on the table to validate the trade-off. - -## Reference design (not adopted in 0.2) - -The original proposal - kept here so future discussions have a starting point - was a hybrid of static topics and dynamic addresses: - -- `[[subscription.template]]` block in `module.toml` declaring `chain_id`, `name`, `event_topics` (no address). -- `chain.register-address(chain_id, template_name, address)` host function for the module to add addresses at runtime. -- `chain.unregister-address(chain_id, template_name, address)` mirror function. -- `log-source.template(string)` variant on the event dispatch so modules route by template name. -- Engine maintains a single aggregated `eth_subscribe logs` per chain per template, with filter `(topic ∈ event_topics) ∧ (address ∈ current_set)`. The address set is mutated as the module discovers new contracts. -- Historical backfill (`from-block` argument on register, paginated `eth_getLogs` orchestration) was contentious and was already trimmed before deferral. +Some module archetypes track contracts deployed by a factory (for example Uniswap V3 pools). Static `[[subscription]]` declarations in `module.toml` cannot express this: the child addresses are unknown at manifest authorship. The current CoW modules do not need it; each subscribes to a single well-known contract per chain. -Envio HyperIndex's `context..register()` API is the closest existing pattern, validated in production for indexers tracking thousands of dynamically-discovered contracts. +`eth_getLogs` already accepts a topic-only filter (no `address` field), so a module can subscribe to a topic across all addresses and filter module-side, covering the common case without a new manifest-and-host mechanism. -## Alternatives left open for 0.3 +## Design space (not adopted) -- **Topic-only `[[subscription]]`** (no address field; engine forwards `eth_subscribe logs` with topic-only filter; module client-side filters logs by address it cares about). Simplest, no new host functions. Trade-off: firehose volume for common topics like `Transfer`. -- **Dynamic register-address** (the original reference design above). -- **Engine-extracted factory child addresses** (Ponder-style declarative schema with ABI-aware extraction rules). Schema complexity grows with exotic factory shapes. -- **No factory pattern; modules wanting dynamic discovery use raw `chain.subscribe-logs` with topic-only filter and persist the discovered address set themselves**. +- **Topic-only `[[subscription]]`**: no address field, module filters client-side. Simplest, no new host functions; trade-off is firehose volume for common topics. +- **Dynamic register-address**: a `[[subscription.template]]` block plus `chain.register-address` / `unregister-address` host functions maintaining a per-chain aggregated `eth_subscribe logs` whose address set the module mutates at runtime. Envio HyperIndex's `register()` is the closest existing pattern. +- **Runtime-extracted factory child addresses**: declarative ABI-aware extraction rules; schema complexity grows with exotic factory shapes. -The choice depends on what the first consumer actually needs. +The choice depends on what the first consumer needs. -## Consequences of deferring +## Consequences -- The `shepherd:cow` and `nexum:host` WIT surfaces remain unchanged in 0.2. -- `module.toml` schema does not gain `[[subscription.template]]` in 0.2. -- 0.2 is the breaking-change window; adding any of the above options in 0.3 may require a major version bump if the chosen shape extends `module.toml` or `nexum:host/chain` non-additively. This risk is accepted on the basis that the M2 grant deliverables do not require this surface. -- TWAP and EthFlow modules ship in 0.2 against the existing static `[[subscription]]` declarations (one address per subscription, known at manifest authorship time). This is consistent with how the autopilot ethflow indexer and watch-tower configure their subscriptions today. +- The `nexum:host` WIT surface and the `module.toml` schema stay unchanged: no `[[subscription.template]]`. +- Current modules ship against static `[[subscription]]` (one address per subscription, known at authorship). diff --git a/docs/adr/0009-host-trait-surface.md b/docs/adr/0009-host-trait-surface.md index ef4aae32..ebe59818 100644 --- a/docs/adr/0009-host-trait-surface.md +++ b/docs/adr/0009-host-trait-surface.md @@ -1,101 +1,25 @@ --- -status: proposed -implemented-in: bleu/nullis-shepherd#12, #13, #15, #22, #23, #24, #25 +status: superseded-in-part --- -# M3 Host trait surface: four per-capability traits + supertrait `Host`, with per-module `strategy.rs` / `lib.rs` split +# Host trait surface: per-capability traits plus a supertrait, with a `logic.rs` / `lib.rs` split -> **Superseded in part by [ADR-0011](0011-per-interface-typed-errors.md).** The single `HostError` / `HostErrorKind` envelope this ADR mirrors was later replaced by per-interface typed errors over a shared `fault` vocabulary: `ChainHost::request` returns `ChainError`, `CowApiHost::submit_order` returns `CowApiError`, and the remaining traits return `Fault`. The trait-surface and `strategy.rs` / `lib.rs` decisions below still hold; read `HostError` as its successor types. +> **The error envelope is superseded by [ADR-0011](0011-per-interface-typed-errors.md):** the single `HostError` / `HostErrorKind` record is replaced by per-interface typed errors over a shared `fault` vocabulary. The trait-surface and `logic.rs` / `lib.rs` decisions below still hold; read `HostError` as its successor types. ## Context -`docs/05-sdk-design.md` describes a much richer M5+ SDK (`#[nexum::module]` proc macro, alloy `Provider`, `TypedState`, `Signer`, named event handlers with async dispatch). M3's scope was narrower: deliver a testable host abstraction that lets module logic compile against an in-memory mock without a `wasm32-wasip2` toolchain, and that the M2 modules (twap-monitor, ethflow-watcher) can adopt without breaking their existing dispatch. - -The constraint is unusual: `wit_bindgen::generate!` emits per-cdylib types - every module gets its own `HostError`, `Event`, `Log`, etc. - so a single shared SDK type cannot be re-used across the wit boundary. Mocks live in their own crate (`shepherd-sdk-test`) and need to compile for the host target (not wasm). +The runtime needs a testable host abstraction so module logic compiles against an in-memory mock without a `wasm32-wasip2` toolchain. `wit_bindgen::generate!` emits per-cdylib types, so a single shared SDK type cannot cross the WIT boundary; the mocks live in their own crate (`nexum-sdk-test`) and compile for the host target. ## Decision Three coupled choices: -### 1. Four per-capability traits with a supertrait `Host` - -`shepherd-sdk` exposes four traits, one per host import: - -```rust -pub trait ChainHost { fn request(&self, chain_id: u64, method: &str, params: &str) -> Result; } -pub trait LocalStoreHost { fn get / set / delete / list_keys ... } -pub trait CowApiHost { fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result; } -pub trait LoggingHost { fn log(&self, level: LogLevel, message: &str); } - -pub trait Host: ChainHost + LocalStoreHost + CowApiHost + LoggingHost {} -impl Host for T {} -``` - -Module strategy code takes `&impl Host` (or ``), so it can call any of the four interfaces uniformly. Tests inject `shepherd_sdk_test::MockHost`; production inject `WitBindgenHost`. The blanket `impl Host for T` means callers never write `impl Host for MyHost {}` by hand. - -### 2. SDK-side `HostError` mirroring the wit struct field-for-field - -`shepherd_sdk::host::HostError` has the same fields as the wit-bindgen-generated `HostError` in each module crate, but is its own type: - -```rust -pub struct HostError { - pub domain: String, - pub kind: HostErrorKind, - pub code: i32, - pub message: String, - pub data: Option, -} -``` - -Each module's `lib.rs` writes a one-liner `convert_err` and `sdk_err_into_wit` to bridge the two. The traits stay world-neutral: `shepherd-sdk-test` compiles for the host target without needing a wasm toolchain, and the mocks are usable from any module's tests. - -### 3. Per-module `strategy.rs` + `lib.rs` split - -Every module is shaped as: - -- `strategy.rs` - pure logic. Imports `shepherd_sdk::host::{Host, HostError, LogLevel}`. Defines small carrier types (`LogView<'a>`, `BlockInfo`, `Settings`) so the strategy is wit-independent. Tests live here under `#[cfg(test)]` against `MockHost`. -- `lib.rs` - per-cdylib glue. `wit_bindgen::generate!`, the `WitBindgenHost` struct implementing all four traits with `chain::request` / `local_store::*` / `cow_api::submit_order` / `logging::log` calls, the `convert_err` + `sdk_err_into_wit` + `convert_level` helpers, and the `Guest` impl that destructures `types::Event` and delegates to `strategy`. - -Reference implementations: `modules/examples/price-alert/`, `modules/examples/stop-loss/`, `modules/twap-monitor/`, `modules/ethflow-watcher/`. The wit-bindgen adapter is intentionally mechanical and is a candidate for a future declarative macro in `shepherd-sdk` (the `#[nexum::module]` design in doc 05). - -## Considered options - -- **Single fat `Host` trait.** Rejected: pulls every module's tests into mocking the full surface even when the strategy only touches one or two capabilities. The four-trait split lets tests `respond_to` exactly the calls the strategy makes. -- **`#[nexum::module]` proc macro now.** Rejected for M3 scope. The proc macro is the right shape long-term (see doc 05) but adds a macro crate, parsing logic, and a debugging surface we did not need to ship M2 modules with MockHost coverage. The manual adapter is verbose but understandable in one read; we land the macro as M5 work. -- **Re-export wit-bindgen `HostError` from the SDK.** Rejected: the wit-bindgen types are per-cdylib. Re-exporting one module's `HostError` would break all others. A shared SDK struct with field-equivalent shape and module-local `From` impls is the only way the SDK stays world-neutral. -- **Strategy lives in `lib.rs` next to the wit-bindgen adapter.** Rejected after the price-alert refactor showed the dispatch matrix was not unit-testable without MockHost, and the twap-monitor / ethflow-watcher ports confirmed the split. The wit-bindgen adapter is ~150 lines of mechanical glue; the strategy is hundreds of lines of logic - colocating them obscures both. +1. **Four per-capability traits (`ChainHost`, `LocalStoreHost`, `CowApiHost`, `LoggingHost`) with a blanket-implemented supertrait `Host`.** Module code takes `&impl Host` and calls any interface uniformly; tests inject `nexum_sdk_test::MockHost`, production injects `WitBindgenHost`. The four-trait split lets a test mock only the calls its module makes. +2. **An SDK-side error type mirroring the WIT struct field-for-field**, its own type (the WIT-generated one is per-cdylib), bridged by a one-line `From` in each module's `lib.rs`. This keeps the traits world-neutral so `nexum-sdk-test` needs no wasm toolchain. Superseded by ADR-0011's typed errors. +3. **Per-module `logic.rs` (pure, wit-independent logic, unit tests against `MockHost`) plus `lib.rs` (per-cdylib `wit_bindgen::generate!` glue, the `WitBindgenHost` impl, and the `Guest` dispatch).** Colocating hundreds of lines of logic with the mechanical adapter obscures both. ## Consequences -- **Strategy code is testable in native Rust** without `wasm32-wasip2`. Every shepherd-side module ships a unit-test suite that exercises this seam via `MockHost`; CI is the authoritative count. -- **The `WitBindgenHost` adapter is duplicated across modules.** ~150 lines of identical glue (the four trait impls plus the two converters and `convert_level`). Acceptable today; the M5 `#[nexum::module]` macro is the path to eliminate it. -- **`shepherd-sdk-test` does not need wit-bindgen.** It depends only on `shepherd-sdk` and `std`; no wasm toolchain involved. Tests compile and run as plain Rust. -- **`HostError` round-trips lossily at the WIT boundary.** The wit-bindgen and SDK types have identical fields today; if either evolves (new variant on `HostErrorKind`, new field), modules need a one-line `From` update. **Applied in M4**: `HostErrorKind` and `LogLevel` are `#[non_exhaustive]`; each module's `sdk_err_into_wit` and `convert_level` adapter carries a wildcard arm mapping unknown SDK-side variants to `HostErrorKind::Internal` / `Level::Info` respectively. `RetryAction` and `PollOutcome` stay exhaustive (domain-locked to the cow-rs `OrderPostErrorKind::is_retriable` and `IConditionalOrder` Solidity interfaces). -- **The four-trait split is not an interface contract with mfw78's WIT.** WIT defines the wire shape; the SDK traits are a Rust-side ergonomics layer. The two evolve together but are not the same artifact. -- **Future capabilities (e.g. `messaging`, `remote-store`, `http`) add new traits.** Each new host interface becomes a new trait + new `MockX` in `shepherd-sdk-test`, and the supertrait `Host` is bumped to bound on the new trait. Modules that do not use the new capability are unaffected (they only need `` etc. on the subset they actually touch - the supertrait is a convenience for full-surface modules, not a hard requirement). - -## Capability enforcement vs. the WIT world (load-bearing assumption) - -`enforce_capabilities` (in `crates/nexum-engine/src/manifest/capabilities.rs`) checks the loaded component's *actual* import set against the manifest's `[capabilities].required + [capabilities].optional`. A component that imports a `nexum:host/` or `shepherd:cow/` whose `` is a known capability NOT in either list fails to boot with `CapabilityViolation`. - -This interacts with `wit_bindgen::generate!` in a way worth pinning here, because the example modules and the production modules use different strategies: - -| Module | WIT world | `generate!` mode | Capabilities the manifest declares | -|---|---|---|---| -| twap-monitor | `shepherd:cow/shepherd` (supertype) | `generate_all` | logging, local-store, chain, cow-api | -| ethflow-watcher | `shepherd:cow/shepherd` | `generate_all` | logging, local-store, cow-api (chain optional - PR #55 review) | -| stop-loss | `shepherd:cow/shepherd` | `generate_all` | logging, local-store, chain, cow-api | -| price-alert | `shepherd:cow/shepherd` | `generate_all` | logging, local-store, chain (no cow-api) | -| balance-tracker | `nexum:host/event-module` | `generate_all` | logging, local-store, chain | - -`price-alert` and `balance-tracker` compile against worlds that import `shepherd:cow/cow-api`, but their manifests do not declare it. Boot succeeds today because the `wasm-tools` / `wit-component` pipeline elides any WIT import the produced `.wasm` does not actually exercise from the component's import section. `enforce_capabilities` then sees the trimmed set and finds nothing missing. - -**The elision is load-bearing**, not a manifest convenience: if a future toolchain bump changes the elision behaviour (or if a module starts importing a capability transitively without declaring it), modules that worked before suddenly fail capability enforcement at boot. - -**Mitigation today**: rely on the elision and treat the assumption as part of the supported build pipeline. Both wasm-tools 1.x and wasmtime 41-45 elide unreferenced imports for our build profile; CI exercises this implicitly on every `cargo build --target wasm32-wasip2`. - -**Hardening planned for M5** (recorded here, NOT a 0.2 deliverable): generate a per-module world (`shepherd:cow/price-alert`, etc.) that only re-exports the capabilities the module declares. The M5 `#[nexum::module]` macro is the natural place to derive this world from the manifest. Eliminates the elision dependency. - -Until then, **a module that adds an import of an undeclared capability will fail capability enforcement at boot**, not at compile time. This is the intended behaviour - the alternative would be to widen the supertype world or to make enforcement lenient, both of which would damage least-privilege. - -_Errata: `crates/nexum-engine` was renamed to `crates/nexum-runtime` + `crates/nexum-cli` in the 0.2 refactor._ +- Module code is testable in native Rust without `wasm32-wasip2`; every module ships a `MockHost` unit-test suite. +- New capabilities add a new trait plus a `MockX` in `nexum-sdk-test`; modules that do not use a capability bound only on the subset they touch. +- The `#[nexum_sdk::module]` macro derives a per-module world from the manifest's `[capabilities]`, so a macro-built component's imports equal its declarations by construction and an undeclared capability is a compile-time error. `enforce_capabilities` (`nexum/crates/nexum-runtime/src/manifest/capabilities.rs`) is the boot-time backstop; a hand-rolled module compiled against the supertype world that imports an undeclared capability fails there rather than at compile time. diff --git a/docs/adr/0011-per-interface-typed-errors.md b/docs/adr/0011-per-interface-typed-errors.md index 662e2445..26a179c3 100644 --- a/docs/adr/0011-per-interface-typed-errors.md +++ b/docs/adr/0011-per-interface-typed-errors.md @@ -6,26 +6,20 @@ status: accepted ## Context -The host once returned one unified envelope, `host-error`, from every imported function and every module export: a record of `domain` (a stringly subsystem tag), a `host-error-kind` enum, a numeric `code` (a JSON-RPC code or an HTTP status, depending on the caller), a `message`, and an optional opaque `data` blob. Dispatch on the failure cause meant reading `kind`, sometimes cross-checking `domain` and `code`. Modules re-named their own domain in every error they built and prefixed each message with the module name, duplicating context the runtime already had (the module name, the interface). - -The envelope conflated two things that want to move independently: the shared cross-domain failure vocabulary (unavailable, timeout, denied, ...) and the per-interface structured detail (a JSON-RPC revert carries a node code and decoded revert bytes; an orderbook rejection carries a typed `{errorType, description}`). Squeezing both through one flat record meant every interface paid for fields it did not use and lost the fields it did. +The host once returned one flat envelope (`host-error`: a stringly `domain`, a `host-error-kind` enum, a numeric `code`, a message, an optional `data` blob) from every function and export. It conflated the shared cross-domain failure vocabulary (unavailable, timeout, denied) with per-interface structured detail (a JSON-RPC revert's node code and decoded bytes, an orderbook rejection's typed `{errorType, description}`). Every interface paid for fields it did not use and lost the fields it did, and modules restated their own identity in every error they built. ## Decision -Adopt the WASI idiom: each interface declares its own typed error, and the errors share one payload-bearing `fault` vocabulary for the cross-domain cases. - -`fault` has seven cases: `unsupported(string)`, `unavailable(string)`, `denied(string)`, `rate-limited(rate-limit)`, `timeout`, `invalid-input(string)`, and `internal(string)`. A richer interface embeds `fault` as one case of its own variant and adds the cases only it needs: `chain-error` adds an `rpc` case carrying the node code and decoded revert bytes; `cow-api-error` adds `http` and `rejected`. Interfaces with nothing to add report `fault` directly (local-store, and now the module exports). +Follow the WASI idiom: each interface declares its own typed error, and the errors share one payload-bearing `fault` vocabulary for the cross-domain cases. -The module exports (`init`, `on-event`, `evaluate`) return `result<_, fault>`. Module identity is the supervisor's business: it holds the module name and does not need each fault to re-declare it, so the `domain` self-naming and the message-prefix duplication in modules are gone. The supervisor derives its error metric label and structured-log `kind` field from the fault case (via the `HostFault` label), which drops a `format!("{:?}")` allocation per erroring dispatch. +`fault` has seven cases: `unsupported(string)`, `unavailable(string)`, `denied(string)`, `rate-limited(rate-limit)`, `timeout`, `invalid-input(string)`, and `internal(string)`. A richer interface embeds `fault` as one case of its own variant and adds only the cases it needs: `chain-error` adds an `rpc` case carrying the node code and decoded revert bytes; `cow-api-error` adds `http` and `rejected`. Interfaces with nothing to add report `fault` directly. -`host-error` and `host-error-kind` are deleted from `types.wit` and from every mirror: the runtime host constructors, both guest SDKs, both SDK test crates, and the module glue. The SDK exposes `Fault` (mirroring the wire vocabulary) plus the `HostFault` trait that recovers an embedded fault and a stable snake_case label; `From for Fault` folds a chain error into the shared vocabulary so a strategy aggregating store and chain calls returns one `Fault`. +The module exports (`init`, `on-event`, `evaluate`) return `result<_, fault>`. Module identity is the supervisor's business, so the `domain` self-naming and message-prefix duplication are gone; the supervisor derives its metric label and log `kind` from the fault case via the `HostFault` label. `host-error` and `host-error-kind` are deleted from `types.wit` and every mirror. The SDK exposes `Fault` plus the `HostFault` trait, with `From for Fault` folding a chain error into the shared vocabulary. -This is a pre-1.0 wire break. CI rebuilds every module wasm on a world change, so no compatibility shim is warranted. +This is a pre-1.0 wire break; CI rebuilds every module wasm on a world change, so no shim is warranted. ## Consequences - A caller dispatches on the structured cause by matching the typed variant, with no stringly `domain`/`code` cross-check. -- Interfaces carry exactly the detail they have; the shared cases stay uniform across interfaces and yield one stable label vocabulary for metrics and logs. -- Modules no longer restate their identity or prefix messages; the runtime supplies both. -- The numeric `code` and opaque `data` fields are gone. An interface that needs structured detail (the JSON-RPC code, decoded revert bytes) carries it in a typed case instead. -- Old bindings do not interoperate with the new world. Because this predates 1.0 and CI rebuilds all module wasms per world change, that break is accepted rather than shimmed. +- The shared cases yield one stable label vocabulary for metrics and logs; interfaces carry exactly the detail they have. +- The numeric `code` and opaque `data` fields are gone; structured detail lives in a typed case instead. diff --git a/docs/adr/0013-composable-cow-structured-poll.md b/docs/adr/0013-composable-cow-structured-poll.md index 70222401..ffb56ab3 100644 --- a/docs/adr/0013-composable-cow-structured-poll.md +++ b/docs/adr/0013-composable-cow-structured-poll.md @@ -1,47 +1,34 @@ --- -status: proposed +status: accepted --- # ComposableCoW poll is a structured non-reverting verdict; the module is a generic handler-agnostic monitor ## Context -ADR-0006 decided that TWAP and EthFlow run as guest modules over low-level host primitives, with the host protocol-neutral and no specialised `shepherd:cow/twap` interface. That decision stands. What ADR-0006 baked in, and what this ADR supersedes, is the poll *mechanism*: a module calling `ComposableCoW.getTradeableOrderWithSignature(owner, params, "", [])`, decoding the return *or the revert reason* with `alloy_sol_types`, mapping the revert selectors to a rich off-chain `PollOutcome`, and computing its own poll schedule (the TWAP epoch gates). ADR-0006 itself anticipated this change: "A newer ComposableCoW iteration in development may simplify the polling surface significantly." +ADR-0006 kept TWAP and EthFlow as guest modules over low-level host primitives, with the host protocol-neutral. What it baked in, and what this ADR supersedes, is the poll *mechanism*: a module calling `getTradeableOrderWithSignature`, decoding the return or the revert reason, mapping revert selectors to an off-chain `PollOutcome`, and computing its own schedule. -That iteration exists. The nullisLabs composable-cow fork (`/code/nullisLabs/composable-cow`, `docs/architecture.md`) splits settlement from polling and makes the polling path structured and non-reverting. `getTradeableOrderWithSignature()` and `checkOrder()` now return a `PollResult { GeneratorResult generator; FillStatus fill; uint filledAmount; Restriction restriction }`. The handler's `poll()` (via `BaseConditionalOrder.poll()`) wraps `generateOrder()` in a try/catch on-chain and returns a `GeneratorResult { code: POST | WAIT_TIMESTAMP | WAIT_BLOCK | TRY_NEXT_BLOCK | INVALID | NEEDS_INPUT; order; nextPollTimestamp; waitUntil; bytes4 reasonCode }`. The revert-decode moved on-chain; the schedule is supplied by the contract (`nextPollTimestamp` sentinels: `0` = poll at `validTo + 1`, `type(uint256).max` = stop); the fill and restriction overlays are composed by the registry, orthogonal to the verdict. - -The consequence is that a monitor no longer needs any handler-specific off-chain logic. TWAP's epoch arithmetic is on-chain in `getNextPollTimestamp()`; the off-chain side reads a struct field. One generic poller drives every handler (TWAP, StopLoss, GoodAfterTime, PerpetualStableSwap, TradeAboveThreshold): none of shepherd's flagship roster emits `PollNeedsOffchainInput`, so none needs an order-module sandbox. - -The blocker is deployment. The fork is `abiVersion 2.0.0-dev`, `deployments/networks.json` is `networks: {}`, and every chain shepherd targets has only the upstream reverting `ComposableCoW`. The poll wire cannot retarget to a contract that is not on-chain. +The nullisLabs composable-cow fork splits settlement from polling and makes the poll path structured and non-reverting: `getTradeableOrderWithSignature` and `checkOrder` return a `PollResult` whose `GeneratorResult` carries a `code` (`POST` | `WAIT_TIMESTAMP` | `WAIT_BLOCK` | `TRY_NEXT_BLOCK` | `INVALID` | `NEEDS_INPUT`), the order, schedule sentinels, and a `bytes4 reasonCode`. The revert-decode and schedule move on-chain, so a monitor needs no handler-specific off-chain logic and one generic poller drives every handler. ## Decision -The CoW module is a generic, handler-agnostic ComposableCoW monitor (a `ccow-monitor`), not a TWAP-specific strategy. It indexes `ConditionalOrderCreated`, polls each authorised order, and switches on `GeneratorResult.code` and nothing else: `POST` (with a signature emitted) submits the order through `nexum:intent/pool`; `WAIT_TIMESTAMP` / `WAIT_BLOCK` reschedule at `waitUntil`; `TRY_NEXT_BLOCK` re-polls; `INVALID` drops the watch; `NEEDS_INPUT` hands to the offchainInput layer or parks. It decodes no revert selectors, computes no schedule, and holds no per-handler branch. TWAP survives as a watch scope and golden vectors, not as code. - -The chassis (ADR-0009) supplies the mechanism: the watch-set and journal stores are unchanged; the two-gate store (`next_block:` / `next_epoch:`) now holds the contract-supplied `waitUntil` / `nextPollTimestamp` slots, so its value source moves from off-chain revert-decode to the contract verdict while its shape is unchanged. The `ConditionalSource` seam returns a structured `Verdict` mirroring `GeneratorResultCode` plus the hints; it does not decode or schedule. - -Two classification concerns stay separate: the poll verdict comes from the contract (`GeneratorResultCode` + `bytes4 reasonCode`, log only), while the CoW orderbook-API submit-error `errorType` table (the REST POST `/api/v1/orders` response) is a distinct data-driven concern that the module keeps. +The CoW module is a generic ComposableCoW monitor: it indexes `ConditionalOrderCreated`, polls each authorised order, and switches on the poll verdict alone (`POST` submits through `videre:venue/client`; `WAIT_TIMESTAMP` / `WAIT_BLOCK` reschedule; `TRY_NEXT_BLOCK` re-polls; `INVALID` drops the watch; `NEEDS_INPUT` parks). It decodes no revert selectors, computes no schedule, and holds no per-handler branch. -Migration is HYBRID and gated on deployment: +Migration is hybrid and gated on deployment: -- Migrate the Rust verdict seam now (zero contract dependency): the `ConditionalSource::Outcome` becomes the structured `Verdict`, and the sweep dispatches on it. -- Quarantine the deployed 1.x reverting poll behind a named `LegacyRevertAdapter` that maps the five upstream selectors to the `Verdict` (`PollTryAtEpoch` to `WAIT_TIMESTAMP`, `PollTryNextBlock` to `TRY_NEXT_BLOCK`, `PollNever` / `OrderNotValid` to `INVALID`). The module posts through the target seam against the deployed contract, so the grant demo stays green. -- When the fork's `deployments/networks.json` is non-empty on a shepherd target chain, replace the adapter with a direct `PollResult` struct-read, delete the adapter and `shepherd-sdk/src/cow/composable.rs`, and regenerate golden vectors against `bytes4 reasonCode` and `PollResult`. +- The Rust verdict seam migrates now (zero contract dependency): the poll resolves to a structured `Verdict` and the sweep dispatches on it. +- The deployed 1.x reverting poll is quarantined behind a `LegacyRevertAdapter` that maps each upstream selector onto a `Verdict`, so the module posts through the target seam against the deployed contract. +- When the fork's `deployments/networks.json` is non-empty on a target chain, the adapter is replaced by a direct `PollResult` struct-read and golden vectors regenerate against `reasonCode` / `PollResult`. -Merge gate: the poll retarget (`shepherd-sdk/src/cow/composable.rs`, `modules/*/src/strategy.rs`, and the acceptance of this ADR over the reverting model) must not merge until `deployments/networks.json` is non-empty on a shepherd target chain. +The orderbook-API submit-error `errorType` table (the REST POST response) is a separate data-driven concern the module keeps; it is not the poll contract. -## Considered options +## Current state -- **Redirect now: retarget the poll to the fork's structured surface immediately.** Rejected. The fork is deployed on no chain shepherd targets; every `eth_call` would revert selector-not-found, and shepherd cannot deploy a third party's registry on its own clock. This regresses grant M2 from "posts orders on testnet" to "compiles against an undeployed ABI." -- **Land-then-migrate: merge the M1 train on the old model, migrate wholesale after the grant.** Rejected as the default because it ships a public verdict seam (`PollOutcome`) that is immediately broken by the target, forcing a re-port of the consumer. The seam type carries no contract dependency, so it is migrated now for free. -- **Keep the off-chain revert-decode as the permanent model.** Rejected. It duplicates on-chain the decode the fork does once, per-handler, and it is the source of the handler-specific off-chain logic this ADR removes. +The seam migration has shipped: `shepherd/crates/composable-cow` exports the structured `Verdict`, the `run` sweep dispatches on it (`shepherd/crates/composable-cow/src/run.rs`), and the deployed reverting wire is served by `LegacyRevertAdapter`. The fork is deployed on no target chain, so `LegacyRevertAdapter` is the live poll path; the structured non-reverting `PollResult` wire is not yet exercised in production. The wire-swap remains gated on the fork deploying. ## Consequences -- `shepherd-sdk/src/cow/composable.rs` (the five-selector `sol!` mirror, `decode_revert`, `classify_poll_error`, the `PollOutcome` enum) is deleted at the wire-swap; its replacement is a struct-read, not a re-port. Until then it survives only as the `LegacyRevertAdapter`'s guts. -- The `twap-monitor` module generalises into `ccow-monitor`; the TWAP-specific poll body (`poll_one`, `decode_return`, `classify_poll_error`, the module-side `TryAtEpoch` / `TryOnBlock` gates) is removed. Existing MockHost tests remain the behaviour-identity proof through the seam migration. -- The chassis two-gate store is re-documented: `next_block:` and `next_epoch:` are contract-supplied `waitUntil` and `nextPollTimestamp` slots, not off-chain epoch math. -- New capabilities land as follow-ons and are the only places a per-handler off-chain concern reappears: `IOrderModule` for `NEEDS_INPUT` offchainInput acquisition, `IOrderManifest` enumeration with `offchainInput = abi.encode(index)` fan-out, and merkle-payload discovery. None is needed for the flagship roster. -- The rename set threads through the module and its vectors: `getTradeableOrder` to `generateOrder`; `PollTryAtEpoch(uint256, string)` to `PollTryAtTimestamp(uint256, bytes4)`; `PollNever` removed in favour of `OrderNotValid`; `getTradeableOrderWithSignature` returns `PollResult`, not `(order, bytes)`; string reason payloads become `bytes4 reasonCode`; the poll no longer reverts for order conditions. -- `docs/diagrams/sequence-twap.mmd` (already flagged in ADR-0006's consequences) is updated to show the structured poll: one `checkOrder` call returning a verdict and hints, no revert path. -- ADR-0006's host-neutrality decision is unchanged. This ADR supersedes only its poll-mechanism description. ADR-0007's `OrderPostError::retry_hint()` remains the orderbook-submit contract; it was never the poll contract. +- The five-selector revert mirror survives only as the `LegacyRevertAdapter` internals; at the wire-swap it is replaced by a struct-read, not re-ported. +- The two-gate store holds contract-supplied `waitUntil` / `nextPollTimestamp` slots rather than off-chain epoch math. +- `NEEDS_INPUT` offchainInput acquisition, manifest enumeration, and merkle-payload discovery land as follow-ons; none is needed by the current module roster. +- ADR-0006's host-neutrality decision is unchanged; this ADR supersedes only its poll-mechanism description. ADR-0007's `OrderPostError::retry_hint` remains the orderbook-submit contract, never the poll contract. diff --git a/docs/adr/0014-local-store-durability-model.md b/docs/adr/0014-local-store-durability-model.md new file mode 100644 index 00000000..4555e94e --- /dev/null +++ b/docs/adr/0014-local-store-durability-model.md @@ -0,0 +1,29 @@ +--- +status: accepted +--- + +# Local store durability is per-call, not per-event atomic + +## Context + +The `nexum:host/local-store` seam is call-by-call (get, set, delete, list-keys, contains, len, count) and the redb backend commits each set/delete in its own fsynced transaction. No transaction spans an `on_event` or `init` call; the dispatch path holds none. A handler that writes then traps (typed fault, panic, OutOfFuel, deadline, process crash) keeps every write already returned. Earlier docs claimed an implicit per-event write transaction that rolls back on trap; it never existed. + +The keeper Journal (#583-#587) reserves a marker before a venue submit and commits after, and a top-of-sweep reconcile re-posts stranded reservations. Its correctness requires the `RESERVED` marker, committed before the submit, to survive a trap during or after it. Per-event rollback would erase that marker while the venue may already hold the order: silent divergence or double-submit, the failure #574 closed. + +## Decision + +Per-call committed durability is the contract. Every set/delete/apply is fsync-durable when `Ok` returns, program-order, read-your-writes; a trap freezes state at the last completed call and never rewinds it. Single-actor serialised dispatch means no observer sees a torn mid-event state. + +Per-event atomicity is rejected in every form: + +- A held redb `WriteTransaction` across `on_event` locks the single shared write head across handler awaits (wasi:http, chain RPC) for up to the dispatch deadline, and the deadline drop leaks the open transaction while `HostState` survives, write-freezing every module through the poison-backoff window. +- A host-side staged overlay defers the `RESERVED` marker's durability past the wire send, so any durable-now escape hatch carries all load-bearing writes and the transaction protects nothing new. +- A snapshot or undo log is dominated by both. + +Cross-boundary atomicity is impossible: no store transaction undoes an order the venue holds. + +The sanctioned atomicity scope is one opt-in batch verb, `apply(ops: list)`, committed in a single synchronous host call (#609). Multi-key state invariants use `apply`; any write sequence crossing an await or an external effect uses the Journal, whose reliance on no-rollback is load-bearing, not a missing feature. A logical change that spans keys without `apply` orders its writes so every prefix is a valid state (the recoverable key last). + +## Consequences + +The store is a write-ahead intent log with key-value convenience, not a transactional database: traps freeze state, they never rewind it. At-least-once effects composed with the per-venue idempotency key (#574) are effectively exactly-once at the venue, the strongest guarantee attainable across the boundary. `apply` is an additive `nexum:host@0.1.0` verb landed pre-cleave. Whole-event staging is rejected rather than parked; reopen only on a measured need for cursor-joined exactly-once that redelivery plus idempotence demonstrably fails to cover. diff --git a/docs/deployment.md b/docs/deployment.md index db9be4f0..6cc400d8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,262 +1,124 @@ # Deploying Shepherd -This guide covers the **operator** side - running a `nexum` -instance against a fleet of WASM modules. For module-author topics -(building a module from scratch, writing tests, packaging) see the -[SDK overview](./sdk.md) and the [first-module -tutorial](./tutorial-first-module.md). +The operator side: the `engine.toml` reference, building module artefacts, and local runs. For the production deploy (systemd, backup, observability) see [`docs/production.md`](./production.md); for containers see [`docs/deployment/docker.md`](deployment/docker.md); for multiple chains see [`docs/deployment/multi-chain.md`](deployment/multi-chain.md). Module-author topics are in the [SDK overview](./sdk.md) and the [first-module tutorial](./tutorial-first-module.md). ## What an operator runs -A Shepherd deployment is one or more `nexum` processes, each -pointed at: +A deployment is one or more engine processes, each pointed at: -1. an `engine.toml` describing the local environment (chain RPCs, - resource caps, where state lives); -2. one or more `[[modules]]` entries listing `.wasm` artefacts and - their `module.toml` manifests; -3. a `state_dir` the engine creates / owns (the redb local-store - database). +1. an `engine.toml` describing the local environment (chain RPCs, resource caps, state directory); +2. `[[modules]]` entries listing `.wasm` artefacts and their `module.toml` manifests; +3. `[[adapters]]` entries listing venue-adapter components (the bundled `cow-venue` adapter); +4. a `state_dir` the engine creates and owns (the redb local-store). -Modules are statically declared in `engine.toml`. The engine does -not pull them from a registry today; you ship the `.wasm` files -alongside the binary and reference them by path. +CoW order submission needs the venue platform, so run the `shepherd` binary, not the bare `nexum`. Modules and adapters are declared statically; the engine does not pull them from a registry today. ## `engine.toml` reference +`engine.example.toml` at the repo root is the annotated template. The shape: + ```toml [engine] -# Directory the local-store redb file (and future engine artefacts) -# will be created under. Created automatically at boot. +# Local-store redb directory, created at boot. state_dir = "./data" - -# `tracing_subscriber::EnvFilter`-compatible directive. `RUST_LOG` -# overrides at process start. +# `tracing_subscriber::EnvFilter` directive; `RUST_LOG` overrides at start. log_level = "info" -# Resource caps applied to every module store at instantiation. -# wasmtime traps a module that overruns either; the supervisor then -# logs and continues on the next event. -[engine.limits] -# Fuel budget granted before every `on_event` invocation. -# 1 unit ~ 1 wasm instruction. 1 billion ~ ~1 second of pure compute. -fuel_per_event = 1_000_000_000 -# Per-dispatch wall-clock backstop, in seconds; bounds host-call time fuel does not meter. Default 120. -event_deadline_secs = 120 -# Linear-memory ceiling per module, in bytes. Default 64 MiB. -memory_bytes = 67_108_864 - -# One [chains.] table per chain the engine should be able to -# reach. Chain ids are EVM decimal. -# -# ws:// + wss:// — alloy pubsub transport (REQUIRED for the -# eth_subscribe-backed [[subscription]] kinds: -# `block`, `log`). -# http:// + https:// — HTTP transport; request/response only, -# no subscriptions. -# -# Mix and match: a chain used only for eth_call (e.g. a Chainlink -# oracle module) can be HTTP; chains carrying log subscriptions -# need WebSocket. - +[engine.metrics] +# Prometheus exporter. Disabled unless enabled = true (bare `nexum` never binds it). +enabled = true +bind_addr = "127.0.0.1:9100" + +# Per-module wasmtime resource caps. Every field is optional; omitted +# values resolve to built-in defaults. Applies uniformly to every module. +[limits] +fuel_per_event = 1_000_000_000 # ~1s of pure compute; wasmtime traps on exhaustion +event_deadline_secs = 120 # wall-clock backstop for unmetered host-call time (min 1) +memory_bytes = 67_108_864 # 64 MiB linear-memory ceiling +state_bytes = 52_428_800 # 50 MiB local-store quota + +# One [chains.] per chain, keyed by EVM decimal id. `ws://`/`wss://` +# engage the pubsub transport (blocks push via eth_subscribe); `http://`/ +# `https://` poll (blocks via eth_getBlockByNumber, logs via eth_getLogs). +# Both work; prefer wss:// where the provider offers it. [chains.1] rpc_url = "https://ethereum-rpc.publicnode.com" - -[chains.100] -rpc_url = "https://rpc.gnosischain.com" +# request_timeout_secs = 30 # per-request JSON-RPC timeout (default 30, 0 rejected at boot) [chains.11155111] rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" - -[chains.42161] -rpc_url = "https://arb1.arbitrum.io/rpc" - -# Extension-owned tables. The engine hands each [extensions.] -# table to the matching extension verbatim; the engine itself never -# interprets them. The cow-api extension reads per-chain orderbook -# base URL overrides here - chains without an entry use the canonical -# api.cow.fi URL. Point this at a staging/barn instance or a local -# mock (tools/orderbook-mock for the load test). -[extensions.cow.orderbook_urls] -# 11155111 = "http://localhost:9999" ``` -### `[[modules]]` entries - -> 0.2 takes the module path + manifest as positional CLI args (a -> single module per engine process). The multi-module -> `[[modules]]` array is shipped by the supervisor work in nullislabs/shepherd PR #9. +The full `[limits.*]` subtables (`http`, `chain`, `logs`, `poison`, `dispatch`, `quota`, `watch`) are documented inline in `engine.example.toml`. -Once the supervisor PR lands, the syntax is: +### `[[modules]]` and `[[adapters]]` ```toml [[modules]] -name = "twap-monitor" -wasm = "modules/twap-monitor.wasm" -manifest = "modules/twap-monitor/module.toml" +path = "modules/twap-monitor.wasm" +manifest = "shepherd/modules/twap-monitor/module.toml" # defaults to module.toml beside `path` -[[modules]] -name = "ethflow-watcher" -wasm = "modules/ethflow-watcher.wasm" -manifest = "modules/ethflow-watcher/module.toml" +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "shepherd/crates/cow-venue/module.toml" +http_allow = ["api.cow.fi"] # outbound wasi:http allowlist the operator grants ``` +The orderbook base URL is not an engine setting: the `cow-venue` adapter reads it from its own `module.toml` `[config]` (`chain`, and optional `orderbook-url` to point at a barn or mock). See [`docs/deployment/multi-chain.md`](deployment/multi-chain.md). + ## Building module `.wasm` artefacts -Modules compile to the `wasm32-wasip2` target. Add the target once -per dev machine: +Modules compile to `wasm32-wasip2`. Add the target once: ```sh rustup target add wasm32-wasip2 ``` -Then build release artefacts from the workspace root: +Build release artefacts from the workspace root: ```sh cargo build --target wasm32-wasip2 --release \ -p twap-monitor -p ethflow-watcher +cargo build --target wasm32-wasip2 --release -p cow-venue --features adapter ``` -The `.wasm` files land in -`target/wasm32-wasip2/release/{twap_monitor,ethflow_watcher}.wasm`. -Copy them to wherever your `engine.toml` points (typical: -`./modules/` next to the binary). - -Size sanity check after a build (CI guards regression): +Artefacts land in `target/wasm32-wasip2/release/*.wasm`. Copy them to wherever `engine.toml` points. CI guards a size regression: ```sh ls -lh target/wasm32-wasip2/release/*.wasm ``` -The M2 modules sit at 270–310 KB optimised. Sudden +10× growth -usually means a fresh dependency landed in the wasm graph — review -`cargo tree -p --target wasm32-wasip2` to confirm. - -## Single-binary local runs +## Local runs -The 0.2 engine ships as the `nexum` binary. From the -workspace root, dispatch a module against a test event: +Build and run the `shepherd` binary against an `engine.toml`: ```sh -cargo run -p nexum-cli -- \ - target/wasm32-wasip2/release/twap_monitor.wasm \ - modules/twap-monitor/module.toml -``` - -On a fresh checkout, the engine creates `./data/local-store.redb`, -opens RPC providers for the chains in `engine.toml`, loads the -component, calls `init`, and dispatches a synthetic block event. -Console output is `tracing` JSON (or pretty if you set -`RUST_LOG=info,nexum_runtime=debug`). - -For systemd-style production runs, see `docs/production.md`. - -## Docker - -A reference Dockerfile + Compose file is planned for M5. -Until that lands, build manually: - -```dockerfile -# (sketch — full Dockerfile is planned for M5) -FROM rust:1.91 as build -COPY . /src -WORKDIR /src -RUN cargo build --release -p nexum-cli -RUN rustup target add wasm32-wasip2 \ - && cargo build --target wasm32-wasip2 --release \ - -p twap-monitor -p ethflow-watcher - -FROM gcr.io/distroless/cc-debian12 -COPY --from=build /src/target/release/nexum /usr/local/bin/ -COPY --from=build /src/target/wasm32-wasip2/release/*.wasm /modules/ -COPY engine.toml /etc/shepherd/engine.toml -ENTRYPOINT ["/usr/local/bin/nexum"] +cargo run -p shepherd -- --engine-config engine.toml ``` -Mount the `state_dir` as a volume so the redb file survives container -restarts. - -## Observability - -### Logs - -Every host backend logs through `tracing`. Set `RUST_LOG` to filter: +The single-module shortcut takes positional paths and synthesizes a one-module config: ```sh -RUST_LOG=info,nexum_runtime=debug,nexum_runtime::host::cow_orderbook=trace \ - cargo run -p nexum-cli -- ... -``` - -Recommended baseline for production: - -``` -RUST_LOG=info,nexum_runtime::host=debug -``` - -The structured-logging audit consolidates the field set -across every dispatch / state change / submission path so a single -JSON grep reconstructs each order's timeline. - -### Prometheus metrics - -A planned metrics exporter wires a `metrics-exporter-prometheus` endpoint at -`engine.toml::[engine.metrics].bind_addr` (default -`127.0.0.1:9100`). Once it lands, scrape with: - -```yaml -scrape_configs: - - job_name: shepherd - static_configs: - - targets: ['shepherd-host:9100'] +cargo run -p shepherd -- \ + target/wasm32-wasip2/release/twap_monitor.wasm \ + shepherd/modules/twap-monitor/module.toml ``` -Suggested Grafana panels (dashboard JSON planned): - -- Module uptime — `shepherd_module_uptime_seconds{module}` -- Event latency p50 / p95 / p99 — - `shepherd_event_latency_seconds{module}` -- Submit success rate — - `rate(shepherd_cow_api_submit_total{outcome="success"}[5m])` - / - `rate(shepherd_cow_api_submit_total[5m])` -- Fuel headroom — - `1 - (shepherd_fuel_consumed / 1_000_000_000)` -- Memory pressure — - `shepherd_memory_peak_bytes / 67_108_864` - -## Backups - -`state_dir/local-store.redb` is the only durable state the engine -holds. redb's WAL means a file-level snapshot taken while the -engine is running is consistent; for safety, either: - -- Pause the engine (`systemctl stop shepherd`), copy the file, then - restart. Sub-second downtime on a small store. -- Use `redb::Database::backup` from a sidecar. - -The store is per-module-namespaced (32-byte keccak prefix per -`module.name`), so a fresh deployment can re-import partial backups -without cross-module bleed. +At boot the engine creates `state_dir/local-store.redb`, opens RPC providers, loads components, calls `init`, and begins dispatching. Console output is `tracing` JSON, or pretty with `--pretty-logs`. For systemd runs see [`docs/production.md`](./production.md). ## Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| -| `init failed: unsupported` | Module imports a capability that needs a chain RPC not configured. | Add the missing `[chains.]` entry to `engine.toml`. | -| `unknown chain ... (no engine.toml RPC entry)` | Module dispatched `chain::request` for a chain not in `engine.toml`. | Same — add the chain. | -| `OutOfFuel` trap, immediate restart loop | Module's `on_event` exceeds `[engine.limits].fuel_per_event`. | Bump `fuel_per_event`, or audit the module's loop bounds. | -| `MemoryOutOfBounds` trap | Module's linear-memory growth exceeds `[engine.limits].memory_bytes`. | Bump `memory_bytes`; profile the module for runaway allocations. | -| `dispatch exceeded its ...s wall-clock deadline`, module marked dead | A host call (RPC / HTTP) blocked, or a chain of slow calls, ran past `[engine.limits].event_deadline_secs`. | Audit the module's host calls; tighten the per-call chain/HTTP timeouts, or bump `event_deadline_secs` if the workload is legitimately long. | -| `submit failed (... InvalidAppData)` | Module sent an `OrderCreation` with a non-empty app-data hash but `app_data = "{}"`. | Out of M2 scope — modules currently only support `EMPTY_APP_DATA_JSON`. Patch is on the M3 follow-up board. | +| `init failed: unsupported` | Module needs a chain RPC not configured. | Add the missing `[chains.]` entry. | +| `unknown chain ... (no engine.toml RPC entry)` | `chain::request` for a chain not in `engine.toml`. | Add the chain. | +| `OutOfFuel` trap, restart loop | `on_event` exceeds `[limits] fuel_per_event`. | Bump `fuel_per_event`, or audit the module's loop bounds. | +| `MemoryOutOfBounds` trap | Linear-memory growth exceeds `[limits] memory_bytes`. | Bump `memory_bytes`; profile the module. | +| dispatch exceeded its wall-clock deadline, module marked dead | A host call blocked past `[limits] event_deadline_secs`. | Tighten the module's host-call timeouts, or bump `event_deadline_secs`. | ## Reference - [SDK overview](./sdk.md) - [First-module tutorial](./tutorial-first-module.md) -- ADR-0001 (`docs/adr/0001-engine-toml-separate-from-nexum-toml.md`) - — why `engine.toml` and `module.toml` are split. -- ADR-0003 (`docs/adr/0003-local-store-namespacing.md`) — how the - `state_dir/local-store.redb` file partitions across modules. -- ADR-0005 (`docs/adr/0005-cow-api-via-cached-orderbookapi.md`) — - how the `cow-api` host backend caches per-chain `OrderBookApi` - clients. +- ADR-0001 (`docs/adr/0001-engine-toml-separate-from-nexum-toml.md`): why `engine.toml` and `module.toml` are split. +- ADR-0003 (`docs/adr/0003-local-store-namespacing.md`): how `state_dir/local-store.redb` partitions across modules. diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index a01c4a4e..6c48958f 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -1,194 +1,87 @@ -# Docker deployment runbook +# Docker deployment -Operator-facing quickstart for running Shepherd in production via the -published container image. For the full hardening surface (systemd -unit, backup recipes, RPC selection, alerting rules) read -`docs/production.md`. +Running Shepherd from the published container image. The hardening surface (systemd, backup, RPC selection, alerting) is in [`docs/production.md`](../production.md); the `engine.toml` reference is in [`docs/deployment.md`](../deployment.md). -The image is published on every push to `main` and on every -`v*` tag: +The image is published to `ghcr.io/nullislabs/shepherd` on every push to `main` and every `v*` tag: ``` -ghcr.io/bleu/nullis-shepherd:latest # main branch HEAD -ghcr.io/bleu/nullis-shepherd:sha- # exact-build pin -ghcr.io/bleu/nullis-shepherd:v0.2.0 # tag +ghcr.io/nullislabs/shepherd:latest # main HEAD +ghcr.io/nullislabs/shepherd:sha- # exact-build pin +ghcr.io/nullislabs/shepherd:v0.2.0 # release tag ``` -`linux/amd64` only for now (the soak VM is x86_64; add `arm64` once -an operator surfaces a real need). +The image is a multi-stage build: a `debian:bookworm-slim` runtime, `tini` as PID 1 (forwards SIGINT/SIGTERM), a non-root `shepherd` user, the `shepherd` binary, and the four production modules plus the `cow-venue` adapter baked under `/opt/shepherd/`. `linux/amd64` only. ---- - -## 1. First boot on a fresh VM +## 1. First boot ```bash -# On the VM: -git clone https://github.com/bleu/nullis-shepherd /opt/shepherd +git clone https://github.com/nullislabs/shepherd /opt/shepherd cd /opt/shepherd -# Operator-supplied RPC URLs. `.env` is gitignored; the template -# committed at `.env.example` lists every variable the engine -# substitutes into `engine.docker.toml` via `${VAR}` placeholders. +# Operator RPC URLs. `.env` is gitignored; `.env.example` lists every +# variable the engine substitutes into engine.docker.toml via ${VAR}. cp .env.example .env -${EDITOR:-vi} .env # paste your paid wss:// URLs +${EDITOR:-vi} .env -# Pull the published image (no local build needed). docker compose pull - -# Start the engine. Compose reads `.env` automatically and passes -# the listed variables into the container, where the engine -# substitutes them at config-load time. docker compose up -d - -# Logs (JSON line-per-event, see `docs/production.md §5`). docker compose logs -f shepherd ``` -If you want the observability stack on the same host: +The observability profile adds Prometheus on the same host: ```bash -docker compose --profile observability up -d -# Prometheus UI: http://127.0.0.1:9090 +docker compose --profile observability up -d # Prometheus UI: http://127.0.0.1:9090 ``` -The metrics endpoint binds the **host's loopback** by default -(`127.0.0.1:9100`); the Prometheus container scrapes via the -compose-internal DNS name `shepherd:9100`. Never expose `:9100` to -the public internet without authn — see `docs/production.md §7`. - ---- - -## 2. Configuring `engine.toml` - -The image bind-mounts the committed `engine.docker.toml` at -`/etc/shepherd/engine.toml` read-only. It uses `${VAR}` placeholders -for every paid-RPC URL, which the engine substitutes at load time -from environment (Docker compose forwards them in from `.env`). -A missing variable fails the boot fast with the exact name. - -To run with a custom config (different module mix, extra chains) -instead of `engine.docker.toml`, point compose at it via -`SHEPHERD_ENGINE_CONFIG=./engine.local.toml` in `.env` — the bind -mount picks up whichever path is set. - -Minimum production shape if you write your own: - -```toml -[engine] -state_dir = "/var/lib/shepherd" # mapped to the `shepherd-state` named volume -log_level = "info" - -[engine.metrics] -enabled = true -bind_addr = "0.0.0.0:9100" # inside the container; compose maps to 127.0.0.1 - -# One per chain you subscribe to. `${VAR}` placeholders are -# substituted at load time from environment — keep the actual URL -# in `.env`, not in any committed file. Must be `wss://`; the -# engine emits a boot-time ERROR otherwise (see docs/production.md §6). -[chains.11155111] -rpc_url = "${SEPOLIA_RPC_URL}" - -[chains.42161] -rpc_url = "${ARBITRUM_RPC_URL}" - -# One [[modules]] per .wasm baked into /opt/shepherd/modules/. -# `manifest` defaults to /module.toml if omitted. -[[modules]] -path = "/opt/shepherd/modules/twap_monitor.wasm" -manifest = "/opt/shepherd/manifests/twap-monitor.toml" - -[[modules]] -path = "/opt/shepherd/modules/ethflow_watcher.wasm" -manifest = "/opt/shepherd/manifests/ethflow-watcher.toml" -# Add price-alert / balance-tracker / stop-loss the same way. -``` +The metrics endpoint binds the host loopback (`127.0.0.1:9100`); the Prometheus container scrapes via the compose DNS name `shepherd:9100`. Never expose `:9100` publicly without authn (see [`docs/production.md`](../production.md)). -If you want compose to use this file instead of the bundled -`engine.docker.toml`, set `SHEPHERD_ENGINE_CONFIG=./engine.local.toml` -in `.env` and put your file there (the `*.local.toml` pattern is -already gitignored). +## 2. Configuring the engine -Public RPCs throttle `eth_subscribe` + `eth_getLogs` under sustained -load (independently confirmed by the baseline-latency tool -- see -`docs/operations/baselines/`). The soak explicitly requires paid -endpoints. +The image bind-mounts the committed `engine.docker.toml` at `/etc/shepherd/engine.toml` read-only. It uses `${VAR}` placeholders for every RPC URL, substituted at load time from the environment; a missing variable fails the boot with the exact name. To run a custom config, set `SHEPHERD_ENGINE_CONFIG=./engine.local.toml` in `.env` (the `*.local.toml` pattern is gitignored). ---- +Inside the container the metrics exporter binds `0.0.0.0:9100` so the compose port mapping reaches it; the mapping keeps it on the host loopback. Public RPCs throttle `eth_subscribe` and `eth_getLogs` under load, so use paid endpoints. -## 3. Upgrade / rollback +## 3. Upgrade and rollback ```bash -# Roll forward to the latest main-branch build. -docker compose pull -docker compose up -d # picks up the new image; graceful - # shutdown drains in-flight dispatch - # before the new container takes over. +# Roll forward to the latest main build. Graceful shutdown drains the +# in-flight dispatch before the new container takes over. +docker compose pull && docker compose up -d -# Roll back to a specific build. -export SHEPHERD_IMAGE=ghcr.io/bleu/nullis-shepherd:sha-abc1234 +# Pin a specific build. +export SHEPHERD_IMAGE=ghcr.io/nullislabs/shepherd:sha-abc1234 docker compose up -d - -# Cold roll: stop, prune image, pull fresh. -docker compose down -docker image rm ghcr.io/bleu/nullis-shepherd:latest -docker compose pull && docker compose up -d ``` -The `shepherd-state` named volume survives container recreation — -the redb file with all `submitted:` / `dropped:` / `backoff:` markers -persists across upgrades by design (idempotency lives there). +The `shepherd-state` named volume survives container recreation, so the redb file and its idempotency markers persist across upgrades. ---- +## 4. Building locally -## 4. Building the image locally - -The CI publishes on every push, so the local build path is only for -testing un-merged changes: - -```bash -docker compose build # uses repo-root Dockerfile -docker compose up -d # runs the locally-built image -``` - -To pin the locally-built tag and avoid accidentally pulling `:latest`: +For testing unmerged changes: ```bash +docker compose build # repo-root Dockerfile export SHEPHERD_IMAGE=shepherd:local docker build -t "$SHEPHERD_IMAGE" . docker compose up -d ``` ---- - -## 5. Verifying the deploy +## 5. Verifying ```bash -# Engine is up, modules are loaded, no module is quarantined. +# Engine up, no module or adapter quarantined. curl -s http://127.0.0.1:9100/metrics \ - | grep -E '^shepherd_(module_poisoned|module_restarts_total|stream_reconnects_total)' + | grep -E '^shepherd_(module_poisoned|adapter_poisoned|stream_reconnects_total)' -# Tail the structured logs. -docker compose logs -f shepherd | grep -E '"level":(("ERROR")|("WARN"))' - -# In a separate shell: confirm the engine wrote a last-dispatched- -# block marker after the first 30s of uptime (proof the supervisor -# is dispatching events, not just idle-looping). +docker compose logs -f shepherd | grep -E '"level":("ERROR"|"WARN")' docker compose exec shepherd ls -la /var/lib/shepherd/ ``` -Green: `shepherd_module_poisoned == 0`, no ERROR/WARN lines beyond -boot, and a non-empty redb file under `/var/lib/shepherd/`. - ---- +Green: poisoned gauges `0`, no ERROR/WARN beyond boot, and a non-empty `local-store.redb` under `/var/lib/shepherd/`. -## 6. Cross-references +## 6. See also -- `docs/production.md` — full process-level deploy (systemd path), - backup recipes, RPC selection, alerting rules, runbook. -- `docs/06-production-hardening.md` — resource-limit design (fuel, - memory, storage), restart policy, RPC resilience, observability - design. -- `docs/operations/m3-testnet-runbook.md` — staging validation - playbook; reuse the same steps before the production soak. -- `engine.example.toml` — annotated reference for the engine config. +- [`docs/production.md`](../production.md): systemd, backup, RPC selection, alerting. +- [`docs/06-production-hardening.md`](../06-production-hardening.md): resource-limit design, restart policy, RPC resilience. +- `engine.example.toml`: annotated engine config reference. diff --git a/docs/deployment/multi-chain.md b/docs/deployment/multi-chain.md new file mode 100644 index 00000000..bf1c4a47 --- /dev/null +++ b/docs/deployment/multi-chain.md @@ -0,0 +1,133 @@ +# Multi-chain deployment + +Running Shepherd against multiple EVM chains. The engine dispatches each module only to the chains it subscribes to, so one process can serve modules watching Mainnet, Gnosis Chain, Arbitrum One, and Base at once. For the base `engine.toml` reference see [`docs/deployment.md`](../deployment.md). + +Keeper modules subscribe per chain and may span chains freely. Venue adapters do not: the `cow-venue` adapter fixes its orderbook chain at `init` from its own `[config] chain`, and registers under the fixed venue id `cow` (`CowVenue::ID`), which carries no chain component. Two cow adapters in one process would register under the same id. Single-process multi-chain submission is a non-goal: run one engine process per submitting chain, each paired with the matching adapter manifest (`module.toml` for Mainnet, `module.sepolia.toml` for Sepolia). + +## Chain support matrix + +The chains this repo's modules target. The CoW orderbook supports more (the `cowprotocol` crate's `Chain` enum also carries BNB, Polygon, Avalanche, Linea, Plasma); the same wiring applies. A `[chains.]` entry for a chain with no orderbook deployment still opens block and log subscriptions, but any submission on it fails at runtime. + +| Chain | Chain ID | Orderbook slug | Barn (staging) | +|-------|----------|----------------|----------------| +| Ethereum Mainnet | 1 | `mainnet` | yes | +| Gnosis Chain | 100 | `xdai` | yes | +| Base | 8453 | `base` | no | +| Arbitrum One | 42161 | `arbitrum_one` | yes | +| Sepolia | 11155111 | `sepolia` | yes | + +## Per-chain RPC wiring + +One `[chains.]` table per chain. `ws://`/`wss://` engage the pubsub transport (blocks push via `eth_subscribe(newHeads)`); `http://`/`https://` poll (blocks via `eth_getBlockByNumber`, logs via `eth_getLogs`). Both transports carry block and log subscriptions; there is no WebSocket requirement. Prefer `wss://` where the provider offers it, since push is lower-latency than polling. + +```toml +[chains.1] # Ethereum Mainnet +rpc_url = "${MAINNET_RPC_URL}" + +[chains.100] # Gnosis Chain +rpc_url = "${GNOSIS_RPC_URL}" + +[chains.8453] # Base +rpc_url = "${BASE_RPC_URL}" + +[chains.42161] # Arbitrum One +rpc_url = "${ARBITRUM_RPC_URL}" + +[chains.11155111] # Sepolia +rpc_url = "${SEPOLIA_RPC_URL}" +``` + +`${VAR}` tokens are substituted at boot from the environment; a missing variable fails fast with the exact name. Under Docker Compose, forward the variables from the host `.env`: + +```yaml +environment: + MAINNET_RPC_URL: + GNOSIS_RPC_URL: + BASE_RPC_URL: + ARBITRUM_RPC_URL: + SEPOLIA_RPC_URL: +``` + +## Orderbook URLs + +The `cow-venue` adapter resolves the orderbook base URL from its `[config] chain` using the canonical `https://api.cow.fi//` pattern. Override it per adapter in the adapter's `module.toml` to point at a barn (staging) instance or a local mock: + +```toml +# shepherd/crates/cow-venue/module.toml +[config] +chain = 11155111 +orderbook-url = "https://barn.api.cow.fi/sepolia/" +``` + +| Chain | Production URL | Barn (staging) URL | +|-------|---------------|-------------------| +| Mainnet (1) | `https://api.cow.fi/mainnet/` | `https://barn.api.cow.fi/mainnet/` | +| Gnosis (100) | `https://api.cow.fi/xdai/` | `https://barn.api.cow.fi/xdai/` | +| Base (8453) | `https://api.cow.fi/base/` | none | +| Arbitrum One (42161) | `https://api.cow.fi/arbitrum_one/` | `https://barn.api.cow.fi/arbitrum_one/` | +| Sepolia (11155111) | `https://api.cow.fi/sepolia/` | `https://barn.api.cow.fi/sepolia/` | + +## Contract addresses + +CREATE2-stable, identical on every supported chain: + +| Contract | Address | +|----------|---------| +| `GPv2Settlement` | `0x9008D19f58AAbD9eD0D60971565AA8510560ab41` | +| `GPv2VaultRelayer` | `0xC92E8bdf79f0507f65a392b0ab4667716BFE0110` | +| `ComposableCoW` | `0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74` | + +EthFlow is not CREATE2-stable. The current production deployment is address-identical on every chain CoW supports (the `cowprotocol` crate pins it as `ETH_FLOW_PRODUCTION` from `cowprotocol/ethflowcontract` `networks.prod.json`): + +``` +0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC +``` + +Legacy per-version EthFlow deployments live at other addresses, and a future version may land per-chain. When porting `ethflow-watcher` to a new chain, verify the `[[subscription]] address` against `networks.prod.json` for that chain. + +## Subscription duplication + +A module subscribes per chain: to watch the same event on multiple chains, declare one `[[subscription]]` block per chain. The engine opens a separate stream for each and routes dispatches independently, tagging each event with its `chain_id`. + +```toml +# twap-monitor on Mainnet + Gnosis: ComposableCoW.ConditionalOrderCreated +[[subscription]] +kind = "chain-log" +chain_id = 1 +address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" +event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361" + +[[subscription]] +kind = "block" +chain_id = 1 + +[[subscription]] +kind = "chain-log" +chain_id = 100 +address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" +event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361" + +[[subscription]] +kind = "block" +chain_id = 100 +``` + +## Event topics + +keccak256 of the event signatures, identical on every chain; only the EthFlow `address` changes. Package of record: `wit/shepherd-cow/cow-events.wit`. + +| Event | Topic-0 | +|-------|---------| +| `ComposableCoW.ConditionalOrderCreated(address,(address,bytes32,bytes))` | `0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361` | +| `CoWSwapEthFlow.OrderPlacement(...)` | `0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9` | + +## Resource sizing + +Each new chain adds one always-on block subscription, N log subscriptions (one per module subscribing on that chain), and M `eth_call` per block for polling modules (M scales with active orders). Budget a paid RPC tier per chain; see [`docs/production.md`](../production.md) for capacity and the `shepherd_chain_request_total{outcome="err"}` degradation signal. + +## See also + +- [`docs/deployment.md`](../deployment.md): `engine.toml` reference and single-module quickstart. +- [`docs/production.md`](../production.md): systemd, RPC selection, alerting. +- [`docs/deployment/docker.md`](./docker.md): container image layout. +- `shepherd/modules/twap-monitor/module.toml`, `shepherd/modules/ethflow-watcher/module.toml`: canonical subscription examples. diff --git a/docs/design/carve-workspace.md b/docs/design/carve-workspace.md new file mode 100644 index 00000000..39b93173 --- /dev/null +++ b/docs/design/carve-workspace.md @@ -0,0 +1,47 @@ +# Transitional three-grouping workspace (M5) + +The monorepo is reorganized into the three prospective repo roots as one cargo +workspace, so it keeps building as a single unit with a shared `Cargo.lock` up to +the physical carve (#407). This is the refactor-now-cut-later step (#403). + +## Groupings + +Each top-level dir is a future repo root. Tier order is `nexum <- videre <- shepherd`. + +| Dir | Tier | Repo | Contents | +|-------------|------|------------------|----------| +| `nexum/` | L1 | `nexum-runtime` | universal runtime, SDK, macros, launcher, `nexum-cli` bare bin; universal-package example modules and runtime fixtures | +| `videre/` | L2 | `videre` | intent/venue SDK, host, macros, status-body; the generic `echo` reference venue | +| `shepherd/` | L3 | `shepherd` | `cow-venue`, `composable-cow`, the `shepherd` composition-root bin, the cow reference modules | + +Layout within a group: `/crates/*`, `/modules/*`, `/tools/*`. + +## Dependency invariant + +A crate depends only within its own tier or a lower one. An upward edge (nexum on +videre, videre on shepherd) would become a circular repo dependency at the carve, +so it is rejected in CI by `scripts/check-carve-groups.sh` (job `carve-groups`). +The physical layout is the source of truth: a crate's group is its top-level dir, +and the check derives every edge's tier from cargo metadata. There is no separate +mapping to drift. + +## Cross-repo dependency medium + +Cross-group edges stay as intra-workspace path-deps in this transitional umbrella, +preserving the single hoisted dependency table and shared lockfile. The medium +converges in three steps: + +1. Path-deps (now) — one workspace, one `Cargo.lock`, atomic folds. +2. Git-tag pins (at carve, #407) — each repo pins its cross-repo deps to a tagged + release; the `carve-groups` gate is joined by a dep-sync check that the pins + resolve to the tagged versions. +3. crates.io (post-carve) — published semver releases replace the git-tag pins. + +## WIT resolution + +WIT stays in a single root `wit/` for this step. `resolve_wit_package` +(`nexum/crates/nexum-world`) walks manifest-dir ancestors to the nearest `wit/`, +so every crate resolves the shared tree regardless of depth. Splitting `wit/` into +per-group `wit/` + `wit/deps/` requires the crate-local wit-deps flip and is done +in #404/#405, not here. The hardcoded `wit_bindgen::generate!` path lists that +bypass the resolver were re-based one level deeper by the move. diff --git a/docs/design/linker-extension-seam.md b/docs/design/linker-extension-seam.md index 76525a92..45703ef1 100644 --- a/docs/design/linker-extension-seam.md +++ b/docs/design/linker-extension-seam.md @@ -1,118 +1,46 @@ # The linker extension seam -## Why +## What -The core host binds the `nexum:host/event-module` world: the six core -primitives (chain, identity, local-store, remote-store, messaging, logging) -plus the allowlisted wasi:http outgoing surface. A domain capability such as -cow-api is not a core seam. It plugs into the host through an extension seam -that is assembled at the composition root, so the core runtime compiles and -runs with no domain backend at all (`Ext = ()`, no hooks registered). +The core host binds the `nexum:host/event-module` world: the six core primitives (chain, identity, local-store, remote-store, messaging, logging) plus the allowlisted `wasi:http` outgoing surface. A domain capability such as a venue platform is not a core seam. It plugs into the host through an extension assembled at the composition root, so the core runtime compiles and runs with no domain backend at all (`Ext = ()`, no extensions registered). -An extension contributes four things that travel together: +## The `Extension` trait -1. an `Ext` payload carrying its backend, held in the runtime `HostState`; -2. a linker hook that adds its WIT interfaces to each module linker; -3. a capability namespace so enforcement recognises its imports; -4. its own operator config, parsed from the `[extensions.]` table - of `engine.toml`. +One trait, `Extension` (`host::extension`), is what a domain contributes. Its members: -## The seam +- `namespace()`: the namespace it owns; keys its service on `HostServices`. +- `capabilities() -> NamespaceCaps`: the `{ prefix, ifaces }` merged into +enforcement so a module importing its interfaces still validates. +- `link(&mut Linker>)`: adds its WIT imports to each worker +linker, after the core interfaces and before instantiation. Takes only `&mut Linker`, never the wasmtime `Store` (not `Sync`), so the seam stays compatible with a future per-extension call router that serializes access to a `Store`. +- `service() -> Option>`: a type-erased service +published under the namespace on the shared `HostServices` map and downcast at the call site. +- `provider() -> Option>>`: a provider component +kind (e.g. the venue-adapter kind) the extension installs. +- `manifest_sections`, `admit_provider`, `admit_worker`: the non-core +manifest sections it claims and its install-time predicates over them (an `Err` refuses the install fail-fast). +- `subscriptions`, `events`: the manifest subscription kinds it emits and +the event sources it opens once the engine is booted. -### `Ext` slot and the `ExtState` accessor +An extension defines its own `bindgen!` for its world, generating a `Host` trait local to the extension, and implements it for the foreign `HostState` (orphan-legal: the trait is local). It reaches its backend either through the `HostServices` map (`state.services.get::(namespace)`, downcast) or, for a per-store payload, through the `ExtState` accessor over the lattice `Ext` slot (`RuntimeTypes::Ext`, held as `HostState.ext`). The shipped venue platform uses the service map. The bindgen shares `nexum:host/types` with the core bindings via `with`, so the extension's `fault` is the same type the core host constructs. -`RuntimeTypes` names an associated `Ext: Clone + Send + Sync + 'static`. The -per-module `HostState` holds one `ext: T::Ext`. The generic accessor -trait is the load-bearing piece: +## Registration and enforcement -```rust -pub trait ExtState { - type Ext; - fn ext(&self) -> &Self::Ext; -} -impl ExtState for HostState { - type Ext = T::Ext; - fn ext(&self) -> &Self::Ext { &self.ext } -} -``` +`CapabilityRegistry` starts from the core namespace (`nexum:host/`) and registers each extension's namespace; `enforce_capabilities` and manifest name validation both consult it. The composition root assembles the `Vec>>` once and threads it through the runtime builder (`with_extensions`), which builds the linker and the registry from it; the supervisor caches the list so the module-restart path rebuilds an identical linker. -An extension defines its own `bindgen!` for its world. That generates a -`Host` trait local to the extension. The extension implements it for the -foreign `HostState`, which is orphan-legal because the trait is local. -To reach its own backend without knowing the concrete lattice `T`, the impl -goes through `ExtState::ext`, then bounds the payload on an -extension-defined trait: +An extension lives in its own crate depending on the runtime for the seam types (`HostState`, `Extension`, the `nexum:host/types` bindgen) and depended on by the composition-root binary. The runtime carries no dependency on any extension crate, so a domain cone stays out of the bare engine. The `shepherd` binary registers one extension, the videre venue platform (`videre_host::platform`), through its `Runtime::extensions` impl. -```rust -pub trait CowBackend { type Cow: CowApi; fn cow(&self) -> &Self::Cow; } +## Extension config -impl cow_bindings::...::Host for HostState -where T: RuntimeTypes, T::Ext: CowBackend { - async fn request(&mut self, ...) { self.ext().cow().request(...).await } -} -``` +`engine.toml` stays domain-free. The engine deserializes every `[extensions.]` table into an opaque `toml::Value` (`EngineConfig::extensions`) and never interprets it; the composition root hands each extension its own entry to parse. Venue adapter components install from the `[[adapters]]` table. -Two traits, two owners: `ExtState` is the runtime's generic reach into the -slot; `CowBackend` is the extension's own payload shape. The bindgen shares -`nexum:host/types` with the core bindings via `with`, so the extension's -`fault` is the same type the core host constructs, and `cow-api-error` embeds -it alongside the extension's own `http` and `rejected` cases. +## Normative rule: import narrowing and boot ordering -### Linker hook and capability registry +Modules built through `#[nexum_sdk::module]` compile against a per-module world derived from their manifest's `[capabilities]`, so a module that never declares an extension capability has no such import and boots with a core-only linker by construction. A module that DOES import an extension interface instantiates only if, before instantiation: -An extension is one value: - -```rust -pub struct Extension { - pub link: LinkerHook, // Arc Result<()>> - pub capabilities: NamespaceCaps, // { prefix, ifaces } -} -``` - -`build_linker` binds the core world then runs each hook. `CapabilityRegistry` -starts from the core namespace (`nexum:host/`) and registers each extension's -namespace; `enforce_capabilities` and manifest name validation both consult -it. The composition root (`nexum-cli`'s `launch::run_from_config`) assembles -the `Extension` list once and threads it into the generic -`nexum_runtime::bootstrap::run`, which builds the linker and the registry -from it. The supervisor caches the list so the module-restart path rebuilds -an identical linker. - -An extension such as cow-api lives in its own crate (`shepherd-cow-host`) -that depends on the runtime for the seam types (`HostState`, `Extension`, -the `nexum:host/types` bindgen) and is depended on by `nexum-cli` at the -composition root. The runtime carries no dependency on any extension crate, -so the cow cone stays out of the bare engine. - -The hook takes only `&mut Linker`, never the wasmtime `Store` (which is not -`Sync`). This keeps the seam compatible with a future per-extension call -router that serialises access to a `Store`. - -### Extension config - -`engine.toml` stays domain-free. The engine deserialises every -`[extensions.]` table into an opaque `toml::Value` -(`EngineConfig::extensions`) and never interprets it; the composition root -hands the extension its own entry to parse into a typed struct (cow-api's -`CowConfig` reads `[extensions.cow]`, today one `orderbook_urls` per-chain -map). - -## Normative rule: elision and boot ordering - -Modules are compiled against the supertype world. The `wasm-tools` pipeline -elides any WIT import the produced component does not exercise, so a module -that never touches cow-api boots with a core-only linker. A module that DOES -import an extension interface instantiates only if, before instantiation: - -- the extension's linker hook is registered (else an unsatisfied-import trap), AND +- the extension's linker hook is registered (else an unsatisfied-import +trap), AND - the extension's capability namespace is registered (else the manifest's - declaration of that capability is rejected as unknown, or the imported - interface is not recognised as a declared capability). +declaration of that capability is rejected as unknown). -Therefore the linker hook and the capability namespace of an extension MUST -be registered as a pair, from the same `Extension` value, before any module -is instantiated. Registering one without the other is a boot-time failure, -not a compile-time one. This is exercised in both directions: the runtime's -supervisor tests pin the negative (a cow-importing module fails to boot with -the extension absent), and `shepherd-cow-host`'s own boot tests pin the -positive (the same module boots and dispatches with the extension present). +Therefore the linker hook and the capability namespace of an extension MUST be registered as a pair, from the same `Extension` value, before any module is instantiated. Registering one without the other is a boot-time failure, not a compile-time one. diff --git a/docs/diagrams/README.md b/docs/diagrams/README.md index ada2eb5e..756b05f3 100644 --- a/docs/diagrams/README.md +++ b/docs/diagrams/README.md @@ -1,14 +1,16 @@ # Diagrams -Mermaid sources and rendered PNGs covering the engine architecture, the CoW workflows that the M2 modules implement (TWAP and EthFlow, both as guest modules using low-level host primitives), and the engine internals that new contributors most often need to reason about. +Mermaid sources and rendered PNGs covering the engine architecture, the CoW workflows (TWAP and EthFlow), and the engine internals that new contributors most often need to reason about. + +> The rendered CoW-flow diagrams predate the venue-adapter rework: they show a `shepherd:cow/cow-api` host interface and an in-engine `OrderBookPool`. Order submission is now the `videre:venue` venue-adapter contract (the CoW venue is the `cow-venue` crate) and `shepherd:cow` carries only the `cow-events` enum. Treat the CoW-submission path in these diagrams as historical until the sources are regenerated. ## Architecture and CoW flows | File | Type | Shows | |---|---|---| -| `architecture.png` / `.mmd` | Component | Static view: external infra, nexum internals, WASM modules (twap-monitor, ethflow-watcher) consuming low-level host primitives, and the `cowprotocol` crate (consumed via `[patch.crates-io]` and the wasm32 feature). The `shepherd:cow` package contains only `cow-api`; no specialised TWAP or EthFlow interfaces. | -| `sequence-ethflow.png` / `.mmd` | Sequence | `OrderPlacement` on-chain event handled entirely in the `ethflow-watcher` guest module: `alloy_sol_types` decodes the event, the module builds an `OrderCreation` with the EIP-1271 signing scheme using `cowprotocol` types, and submits via `cow-api/submit-order`. The orderbook error path runs through `OrderPostError::try_from(cow-api-error).retry_hint()`. | -| `sequence-twap.png` / `.mmd` | Sequence | `ConditionalOrderCreated` registration plus the per-block polling loop driven by the `twap-monitor` guest module: `alloy_sol_types` decodes registrations and `eth_call` returns, the module makes the `getTradeableOrderWithSignature` call via `chain.request`, builds `OrderCreation` via `cowprotocol` types, and submits via `cow-api/submit-order`. Orderbook errors flow through `OrderPostError::retry_hint`. | +| `architecture.png` / `.mmd` | Component | Static view: external infra, nexum internals, WASM modules (twap-monitor, ethflow-watcher) consuming host primitives, and the `cowprotocol` crate (consumed via `[patch.crates-io]` and the wasm32 feature). | +| `sequence-ethflow.png` / `.mmd` | Sequence | `OrderPlacement` on-chain event handled in the `ethflow-watcher` guest module: `alloy_sol_types` decodes the event, the module builds an order with the EIP-1271 signing scheme using `cowprotocol` types, and submits it. | +| `sequence-twap.png` / `.mmd` | Sequence | `ConditionalOrderCreated` registration plus the per-block polling loop in the `twap-monitor` guest module: `alloy_sol_types` decodes registrations and `eth_call` returns, the module makes the `getTradeableOrderWithSignature` call via `chain.request`, builds the order via `cowprotocol` types, and submits it. | ## Engine internals (for contributors) diff --git a/docs/diagrams/diagrams.md b/docs/diagrams/diagrams.md index a9d6c21a..2eec4e0b 100644 --- a/docs/diagrams/diagrams.md +++ b/docs/diagrams/diagrams.md @@ -1,8 +1,8 @@ # Shepherd - Architecture Diagrams -Visual reference for the Shepherd engine, its interactions with Nexum, CoW Protocol, and the WASM module layer. Derived from ADRs 0001–0008 and the internal architecture document. +Visual reference for the Shepherd engine, its interactions with Nexum, CoW Protocol, and the WASM module layer. -> **Scope note** - diagrams 1–4 and 7–8 reflect the **M1 implemented state** plus the **M2 target design** as described by the ADRs. Diagrams 5–6 (TWAP, EthFlow) describe **guest-module-driven flows**: the modules do all the protocol work themselves using low-level host primitives, with no specialised `twap` or `ethflow` host interfaces. Where the current code differs from the target design, a note is included in the relevant block reference. +> **Scope note.** These diagrams predate the venue-adapter rework and still show a `shepherd:cow/cow-api` host interface with an in-engine `OrderBookPool`. Current: order submission is the `videre:venue` venue-adapter contract (a keeper drives venues through `videre:venue/client`; the CoW venue is the `cow-venue` crate), and `shepherd:cow` carries only the `cow-events` enum. Read the CoW-submission path below as historical. The universal-primitive, boot, dispatch, and lifecycle diagrams remain accurate. --- @@ -15,7 +15,7 @@ graph TD ET["engine.toml · module.toml\n(operator config + module manifest)"] SUP["Supervisor::boot"] POOLS["ProviderPool · OrderBookPool · LocalStore"] - HS["HostState (per module)\nnexum:host@0.2.0 + shepherd:cow@0.2.0"] + HS["HostState (per module)\nnexum:host@0.1.0 + shepherd:cow@0.1.0"] EL["EventLoop - futures::stream::select_all\nfan-out block/chain-log streams to subscribers"] MODS["WASM Modules\ntwap.wasm · eth-flow.wasm\n(self-contained protocol logic in guest)"] BC["Blockchain (Sepolia / Mainnet / …)\nComposableCoW · CowEthFlow · RPC Node"] @@ -47,7 +47,7 @@ graph TD | **EventLoop** | The main async loop. Runs all block-header and log-event streams concurrently via `futures::stream::select_all`. When a stream fires, it routes the event to every module that subscribed to it in their `module.toml`. | | **WASM Modules** | The guest programs. Each module exports `init(config)` (called once at boot) and `on_event(event)` (called on every relevant block or log). They contain the protocol logic themselves: TWAP polling, EthFlow event decoding, OrderCreation construction. They call back into the host through universal WIT interfaces only - no CoW-specific helper interfaces (ADR-0006). | | **Blockchain** | The EVM chain being watched. Delivers new block headers and contract log events over a persistent WebSocket (`eth_subscribe`). Also handles `eth_call` for on-chain reads (e.g. checking whether a TWAP order is ready). | -| **bleu/cow-rs [patch.crates-io]** | The Rust crate containing CoW Protocol **primitives**: order types, signing schemes, the orderbook HTTP client, and the typed orderbook error model (`OrderPostError` + `retry_hint`). Pulled via `[patch.crates-io]` pointing at the head of upstream PR #5. Modules consume the types directly via the `wasm32` feature; the engine consumes the orderbook client via its `cow-api` host backend. No TWAP or EthFlow strategy logic lives here - that stays in module code (ADR-0007). | +| **bleu/cow-rs [patch.crates-io]** | The Rust crate containing CoW Protocol **primitives**: order types, signing schemes, the orderbook HTTP client, and the typed orderbook error model (`OrderPostError` + `retry_hint`). Pulled via `[patch.crates-io]` pointing at the head of upstream PR #5. Modules consume the types directly via the `wasm32` feature; the engine consumes the orderbook client via its `cow-api` host backend. No TWAP or EthFlow keeper logic lives here - that stays in module code (ADR-0007). | | **api.cow.fi (Orderbook REST)** | The CoW Protocol orderbook service. Accepts `POST /orders` to register new orders. Trader-uploaded app-data documents are PUT to `/app_data/{hash}` separately by whoever signed the order (not by the relayer module). | --- @@ -161,8 +161,8 @@ Two WIT packages: the universal `nexum:host` and the CoW-specific `shepherd:cow` ```mermaid graph TD - NH["nexum:host@0.2.0\n(universal - no CoW knowledge)"] - SC["shepherd:cow@0.2.0\n(CoW Protocol extensions)"] + NH["nexum:host@0.1.0\n(universal - no CoW knowledge)"] + SC["shepherd:cow@0.1.0\n(CoW Protocol extensions)"] NH --> n1["chain ✅ implemented\nrequest(chain-id, method, params)\nrequest-batch(chain-id, requests)\n - \nsubscribe-blocks · subscribe-logs →\n engine-managed via module.toml subscriptions\nregister-address · unregister-address →\n 🕓 deferred to 0.3 (ADR-0008)"] NH --> n2["local-store ✅ implemented\nget(key) · set(key, value)\ndelete(key) · list-keys(prefix)\nnamespacing: 32-byte hash prefix (ADR-0003)"] @@ -182,14 +182,14 @@ graph TD | Interface | What it does | |---|---| -| **nexum:host@0.2.0** | The base WIT package. Any module running in the engine - CoW-aware or not - imports from here. Defines shared types (`chain-id`, `chain-log`, `fault`) used by both packages. | +| **nexum:host@0.1.0** | The base WIT package. Any module running in the engine - CoW-aware or not - imports from here. Defines shared types (`chain-id`, `chain-log`, `fault`) used by both packages. | | **chain** | Reads from the blockchain via JSON-RPC. `request` sends a single call; `request-batch` sends several in one round-trip. **Subscriptions are not callable WIT functions** - they are declared in `module.toml` and opened by the engine at boot. Dynamic `register-address` for factory patterns is deferred to 0.3 (ADR-0008). | | **local-store** | Persistent key-value storage that survives restarts. Operations: `get(key)`, `set(key, value)`, `delete(key)`, `list-keys(prefix)`. The host prefixes every key with a 32-byte deterministic namespace (`keccak256(module_name)` locally, or `ens_namehash(name)` when ENS-loaded) so modules are fully isolated and the namespace cannot be spoofed (ADR-0003). | | **identity · messaging · remote-store** | Capabilities stubbed at 0.2 - they return `Unsupported`. `identity` will provide keystore-backed signing. `messaging` will send Waku messages. `remote-store` will read/write Swarm/IPFS. | | **logging** | Lightweight utility. `logging` emits to the engine's `tracing` subscriber (inherits `RUST_LOG` filters). Time and secure randomness are available ambiently via `wasi:clocks` and `wasi:random`. | | **(outbound HTTP)** | Not a `nexum:host` interface: a module that declares the `http` capability imports the standard `wasi:http/outgoing-handler`, and the host checks every outgoing request against the manifest's `[capabilities.http].allow` list before any connection is made. | -| **shepherd:cow@0.2.0** | The CoW Protocol extension package. Imports `nexum:host/types` for shared types so modules don't re-define `chain-id` or `chain-log`. Only CoW-aware modules need to import this package. Contains exactly **one** interface in 0.2: `cow-api`. | -| **cow-api** | Generic orderbook access. `request` is a raw REST passthrough (returns JSON string). `submit-order` takes raw order bytes and returns a `result` where the string is the order UID. Routes through the engine's `OrderBookPool`. This is the only protocol-level CoW interface in 0.2 - the boundary between "what CoW Protocol *is*" (orderbook submission, order types) and "what's implemented *on top* of CoW" (TWAP polling, EthFlow event handling). | +| **shepherd:cow@0.1.0** | The CoW Protocol package. In current code it carries a single interface, `cow-events`: the canonical decoded on-chain event enum (`ConditionalOrderCreated`, `ConditionalOrderRemoved`, `OrderPlacement`) with pinned topic-0 hashes, parity-tested against keeper constants and manifests. The `cow-api` host interface the diagram shows is gone. | +| **cow-api** (historical) | Orderbook access was once a host interface (`request` REST passthrough + `submit-order`) backed by an in-engine `OrderBookPool`. Order submission is now the `videre:venue` venue-adapter contract: a keeper calls `videre:venue/client`, and the `cow-venue` adapter component speaks the CoW orderbook. See doc 08. | | **(no twap interface)** | Per ADR-0006, no specialised TWAP host interface exists. The TWAP module implements polling, decoding, and submission entirely in guest code, using `chain.request` for `eth_call`, `local-store` for state, `alloy_sol_types` (in-module) for ABI decoding, `cowprotocol` types for `OrderCreation`, and `cow-api.submit-order` for orderbook submission. Multiple TWAP strategies can coexist as separate modules with different polling policies and error tolerances. | | **(no ethflow interface)** | Per ADR-0006, no specialised EthFlow host interface exists. The EthFlow module decodes `OrderPlacement` directly in guest code via `alloy_sol_types`, constructs the `OrderCreation` with the EIP-1271 signing scheme via `cowprotocol` types, and submits via `cow-api`. | @@ -315,7 +315,7 @@ sequenceDiagram | **ComposableCoW Contract** | The on-chain conditional order registry. Accepts TWAP parameters via `create()` and emits `ConditionalOrderCreated`. Also exposes `getTradeableOrderWithSignature()`, which the engine polls to check whether the current TWAP part is ready to trade. | | **RPC Node** | The WebSocket connection to the chain. Delivers log events (subscriptions) and handles `eth_call` (synchronous reads). Must be `wss://` for this flow since it uses subscriptions. | | **EventLoop** | Receives raw events from the RPC node and routes them to the module that subscribed to them. Opaque to the flow - it just calls `on_event`. | -| **twap module (WASM guest)** | Contains the entire TWAP strategy: decoding registrations, deciding when to poll (using stored hints), reacting to revert reasons, building orders, interpreting orderbook errors. Calls into the host only through universal WIT primitives. | +| **twap module (WASM guest)** | Contains the entire TWAP keeper: decoding registrations, deciding when to poll (using stored hints), reacting to revert reasons, building orders, interpreting orderbook errors. Calls into the host only through universal WIT primitives. | | **alloy_sol_types (in module)** | The ABI-aware decoder. Compiled into the module's WASM. Decodes `ConditionalOrderCreated` from raw log bytes; decodes the `getTradeableOrderWithSignature` return; interprets revert reasons. No host involvement for decoding. | | **cowprotocol types (in module)** | The protocol-level types from `bleu/cow-rs`, consumed by the module via the wasm32 feature (ADR-0007 item 3). Used to build `OrderCreation`, manipulate `OrderUid`, and pattern-match `OrderPostError`. The crate's HTTP client (`OrderBookApi`) is **not** used directly by the module - orderbook submission goes through the host's `cow-api`. | | **HostState (Rust)** | Provides only the universal primitives (`chain.request`, `local-store.*`, `cow-api.submit-order`). Knows nothing about TWAP semantics. | @@ -423,9 +423,9 @@ flowchart TD |---|---| | **WASM Module** | The guest program. It calls imported WIT functions exactly like regular function calls - it has no visibility into the host machinery behind them. | | **wasmtime Linker** | `Linker` built once at startup. `wasmtime::component::bindgen!` generates a `Shepherd` world struct and one trait per WIT interface (e.g. `shepherd::cow::cow_api::Host`, `nexum::host::local_store::Host`). `Shepherd::add_to_linker(&mut linker, \|state\| state)` registers every trait method as a host function. After that, calls from WASM resolve with zero dynamic dispatch overhead - the vtable is built at link time, not per-call. | -| **HostState - manifest.required check** | In 0.2, capability enforcement is **link-time**, not per-call. At boot, `manifest::enforce_capabilities` (in `crates/nexum-runtime/src/manifest/capabilities.rs`) cross-checks every capability-bearing WIT import of the component against the `[capabilities].required` ∪ `[capabilities].optional` set in `module.toml`, with names validated against `KNOWN_CAPABILITIES`. A module that imports a capability it did not declare fails instantiation - it never reaches dispatch. This is structurally equivalent to per-call gating for the 0.2 capability set: an undeclared capability cannot link the relevant WIT, so unauthorised calls fail at component link rather than per-call dispatch. Per-call gating in `HostState` (returning `fault.denied` per invocation, useful for finer-grained policies or capability revocation at runtime) remains a future direction for 0.3+ if a richer threat model demands it; it is not in 0.2 scope. | +| **HostState - manifest.required check** | In 0.2, capability enforcement is **link-time**, not per-call. At boot, `manifest::enforce_capabilities` (in `nexum/crates/nexum-runtime/src/manifest/capabilities.rs`) cross-checks every capability-bearing WIT import of the component against the `[capabilities].required` ∪ `[capabilities].optional` set in `module.toml`, with names validated against `KNOWN_CAPABILITIES`. A module that imports a capability it did not declare fails instantiation - it never reaches dispatch. This is structurally equivalent to per-call gating for the 0.2 capability set: an undeclared capability cannot link the relevant WIT, so unauthorised calls fail at component link rather than per-call dispatch. Per-call gating in `HostState` (returning `fault.denied` per invocation, useful for finer-grained policies or capability revocation at runtime) remains a future direction for 0.3+ if a richer threat model demands it; it is not in 0.2 scope. | | **tracing::info!** | Every host call emits a structured trace event (capability name, chain id, etc.). Operators use `RUST_LOG=shepherd=debug` to see every call a module makes. | -| **host backend Rust function** | `HostState` implements one generated trait per WIT interface. Each `async fn` in the trait receives `&mut self` (giving access to all host resources) and returns the WIT-mapped Rust type. There are no CoW-strategy-specific backends - only the universal ones plus `cow-api` (ADR-0006). | +| **host backend Rust function** | `HostState` implements one generated trait per WIT interface. Each `async fn` in the trait receives `&mut self` (giving access to all host resources) and returns the WIT-mapped Rust type. There are no CoW-specific backends - only the universal ones plus `cow-api` (ADR-0006). | | **OrderBookPool** | Looks up the `OrderBookApi` client for the requested chain and calls `post_order`. Returns a 56-byte `OrderUid` on success or an `OrderPostError`-bearing host error on failure. | | **ProviderPool (chain.request)** | Looks up the alloy provider for the requested chain and dispatches the JSON-RPC call (`eth_call`, `eth_getLogs`, etc.). | | **engine-managed streams (chain.subscribe-*)** | Subscriptions are not exposed as runtime-callable host functions in 0.2. They are opened by the engine at boot from each module's declared `[[subscription]]` entries; events flow into the module via `on_event`. Dynamic `register-address` for factory patterns is deferred (ADR-0008). | @@ -465,9 +465,9 @@ graph TD |---|---| | **cowdao-grants/cow-rs** | The upstream CoW Protocol Rust SDK, maintained by the DAO. Version `alpha.3` is published to crates.io but predates 18 follow-up commits Bleu has been pushing through PR #5. This is the PR base - changes land here eventually. | | **bleu/cow-rs** | Bleu's repository, which is simultaneously the head branch of the DAO's open PR #5. Every commit Bleu pushes here also advances PR #5 for upstream review. This is not a long-lived parallel fork - it is the active PR branch (ADR-0004). | -| **Protocol primitives added to PR #5** | The three additions Bleu is pushing into PR #5: `OrderPostError` rich variants + `retry_hint()` (critical for module error handling), `OrderBookApi::with_base_url` (barn / staging / forked deployments), and `wasm32` feature-gating (critical so guest modules can consume `cowprotocol` types). All three are protocol primitives - they describe what CoW Protocol *is*, not how a particular strategy uses it. TWAP polling and EthFlow event decoding are explicitly *not* added here; they stay in module code (ADR-0007). | +| **Protocol primitives added to PR #5** | The three additions Bleu is pushing into PR #5: `OrderPostError` rich variants + `retry_hint()` (critical for module error handling), `OrderBookApi::with_base_url` (barn / staging / forked deployments), and `wasm32` feature-gating (critical so guest modules can consume `cowprotocol` types). All three are protocol primitives - they describe what CoW Protocol *is*, not how a particular module uses it. TWAP polling and EthFlow event decoding are explicitly *not* added here; they stay in module code (ADR-0007). | | **Already in PR #5** | The types and orderbook client Bleu's modules consume but did not add: `Order`, `OrderCreation`, `OrderUid`, signing-scheme enums, and `OrderBookApi`. These existed in PR #5 before the M2 work. | | **[patch.crates-io]** | A single line in the workspace `Cargo.toml` that tells Cargo to use `bleu/cow-rs` at a specific git rev instead of the `alpha.3` release on crates.io. Bumping the rev is the only change needed to pick up a new primitive after it is pushed to `bleu/cow-rs` (ADR-0004). | -| **nexum** | The engine binary. Contains the WIT host implementations, Supervisor, EventLoop, config loaders, and alloy/redb integration. Contains no CoW Protocol logic - protocol primitives live in `bleu/cow-rs`; strategy logic lives in guest modules. | +| **nexum** | The engine binary. Contains the WIT host implementations, Supervisor, EventLoop, config loaders, and alloy/redb integration. Contains no CoW Protocol logic - protocol primitives live in `bleu/cow-rs`; module logic lives in guest modules. | | **shepherd:cow/cow-api WIT** | The only CoW-specific WIT interface in 0.2. The engine implements it (host side); WASM modules import it (guest side). Backed by `OrderBookPool` (and through that, `OrderBookApi` from `cow-rs`). | -| **WASM modules (twap · eth-flow)** | The grant deliverables. Compiled to `.wasm` Component Model binaries. Import only universal WIT interfaces (`chain`, `local-store`, `logging`) plus `shepherd:cow/cow-api`. Consume `cowprotocol` types directly through the wasm32 feature for building `OrderCreation` and pattern-matching on `OrderPostError`. Contain all TWAP and EthFlow strategy logic themselves (ADR-0006). | +| **WASM modules (twap · eth-flow)** | The grant deliverables. Compiled to `.wasm` Component Model binaries. Import only universal WIT interfaces (`chain`, `local-store`, `logging`) plus `shepherd:cow/cow-api`. Consume `cowprotocol` types directly through the wasm32 feature for building `OrderCreation` and pattern-matching on `OrderPostError`. Contain all TWAP and EthFlow keeper logic themselves (ADR-0006). | diff --git a/docs/diagrams/engine-boot.mmd b/docs/diagrams/engine-boot.mmd index ef7ea35b..5c08e634 100644 --- a/docs/diagrams/engine-boot.mmd +++ b/docs/diagrams/engine-boot.mmd @@ -30,7 +30,7 @@ sequenceDiagram PP-->>Bin: pool ready Bin->>LS: LocalStore::open(state_dir) - Note over LS: Creates redb file if missing,
materialises shared table. + Note over LS: Creates redb file if missing,
initialises shared table. LS-->>Bin: store ready Bin->>OBP: OrderBookPool::default() diff --git a/docs/diagrams/sequence-twap.mmd b/docs/diagrams/sequence-twap.mmd index ebb7766a..2a06efc9 100644 --- a/docs/diagrams/sequence-twap.mmd +++ b/docs/diagrams/sequence-twap.mmd @@ -31,7 +31,7 @@ sequenceDiagram RPC-->>Host: return value or revert Host-->>Mod: result (JSON encoded) Mod->>SolDec: decode return or interpret revert reason - SolDec-->>Mod: PollOutcome (module-defined enum) + SolDec-->>Mod: Verdict (module-defined enum) alt Ready (order, signature) Mod->>Cow: build OrderCreation diff --git a/docs/migration/0.1-to-0.2.md b/docs/migration/0.1-to-0.2.md deleted file mode 100644 index bcee2980..00000000 --- a/docs/migration/0.1-to-0.2.md +++ /dev/null @@ -1,514 +0,0 @@ -# Migrating from Nexum 0.1 to 0.2 - -Nexum 0.2 is a single coordinated breaking-change release. It does the renames, the error-model unification, the missing primitives, and the capability-negotiation work in one window so module authors only pay the migration tax once. There will not be another breaking release of comparable scope before 1.0. - -This guide is written for two audiences: - -- **Module authors** - you write WASM components that import the Nexum WIT. -- **Host embedders** - you build the runtime that loads modules (the server daemon, a mobile wallet, a browser host). - -Each section is tagged `[author]`, `[embedder]`, or `[both]`. - ---- - -## TL;DR - what changed [both] - -| Area | 0.1 | 0.2 | -|---|---|---| -| WIT package | `web3:runtime` | `nexum:host` | -| Consensus interface | `csn` | `chain` | -| Messaging interface | `msg` | `messaging` | -| Default world | `headless-module` | `event-module` | -| CoW world | `shepherd:cow/shepherd-module` | `shepherd:cow/shepherd` | -| CoW interfaces | `cow` + `order` | `cow-api` (merged) | -| Feed methods | `feed-get` / `feed-set` | `read-feed` / `write-feed` | -| Event variants | `block-data` / `log-entry` / `message-data` / `timer(u64)` | `block` / `log` / `message` / `tick { fired-at }` | -| Errors | 5 different shapes + bare `string` | per-interface typed errors over a shared `fault` vocabulary | -| Capabilities | All six imports mandatory | Manifest-negotiated, optional imports trap on call | -| Engine crate | `nxm-engine` | `nexum-engine` | -| Manifest file | `nexum.toml` (some docs said `shepherd.toml`) | `nexum.toml` (canonical) | -| Manifest field | `wasm = "sha256:..."` | `component = "sha256:..."` | -| Manifest section | `[[subscribe]]` | `[[subscription]]` | -| Config type | `list>` (stringified) | unchanged in 0.2; typed variant on the 0.3 roadmap | -| New capabilities | - | `http` (wasi:http, allowlisted); time and randomness are ambient via wasi:clocks / wasi:random | -| New RPC method | - | `chain::request-batch` (additive) | -| New world | - | `query-module` (experimental, no host impl shipped) | - -If you only do four things: update your `nexum.toml`, run the sed cheat-sheet at the bottom, replace your error handling with the new `fault` vocabulary, and declare your capabilities explicitly. Everything else is mechanical. - ---- - -## 1. WIT renames [author] - -### Package rename - -```diff -- use web3:runtime/types.{config, event}; -- use web3:runtime/chain.{chain-id}; -+ use nexum:host/types.{config, event}; -+ use nexum:host/chain.{chain-id}; -``` - -Why: `web3:` precommitted the engine to crypto-only branding. The package is now named after the engine; web3-specific capabilities live inside it as interfaces. - -### Interface renames - -| 0.1 | 0.2 | Rationale | -|---|---|---| -| `csn` | `chain` | `csn` was unreadable; `chain.request(chainId, method, params)` reads itself. | -| `msg` | `messaging` | `msg` collided with its own `message` record; ambiguous in non-Rust bindings. | -| `cow` + `order` | `cow-api` (one interface) | `cow::cow::request` triple-stutter eliminated; `order::submit` merged as `cow-api::submit-order`. | - -### World renames - -```diff -- world headless-module { -+ world event-module { - import chain; - import identity; // NOTE: was missing from 0.1 WIT; now present - import local-store; - import remote-store; - import messaging; - import logging; - export init: func(config: config) -> result<_, string>; - export on-event: func(event: event) -> result<_, string>; - } -``` - -```diff -- world shepherd-module { -- include headless-module; -- import cow; -- import order; -+ world shepherd { -+ include event-module; -+ import cow-api; - } -``` - -### Function renames (verb-first, fully spelled) - -```diff - interface remote-store { -- feed-get: func(owner: list, topic: list) -> result>, store-error>; -- feed-set: func(topic: list, data: list) -> result, store-error>; -+ read-feed: func(owner: list, topic: list) -> result>, fault>; -+ write-feed: func(topic: list, data: list) -> result, fault>; - } -``` - -### Type and field renames - -```diff - interface types { -- record block-data { ... } -- record log-entry { ..., tx-hash: list, ... } -- record message-data { ... } -- variant event { -- block(block-data), -- logs(list), -- timer(u64), -- message(message-data), -- } -+ record block { ... } -+ record log { ..., transaction-hash: list, ... } -+ record message { ... } -+ record tick { fired-at: u64 } // milliseconds since Unix epoch, UTC -+ variant event { -+ block(block), -+ logs(list), -+ tick(tick), -+ message(message), -+ } - } -``` - -Two semantic notes: - -- All `u64` timestamps in 0.2 are **milliseconds since Unix epoch, UTC**. The 0.1 WIT did not specify a unit and several sources used seconds. Audit any timestamp arithmetic you do. -- `tick` (formerly `timer`) is now a record, not a bare `u64`. In bindings it reads `event.tick.firedAt` instead of `event.timer === 1700000000`. - -> **Breaking: the chain-event log is now `chain-log`.** The `log` record and the `logs(list)` event arm shown above have been renamed to `chain-log` and `chain-logs(chain-logs)` so the on-chain event vocabulary no longer collides with the diagnostics logging pipeline (`nexum:host/logging`, `[limits.logs]`), which is untouched. Three consequences: a manifest with `kind = "log"` fails load with an unknown-kind error naming the valid set (`block`, `chain-log`, `cron`); a component built against the old world's `log` record or `logs` arm no longer links against the current world (rebuild against the renamed WIT); and guest strategy handlers rename `on_logs` to `on_chain_logs`. The `block`, `tick`, and `message` arms are unchanged. - -> **Breaking: the chain-log record now carries the full RPC log shape.** The record was reshaped to mirror `alloy_rpc_types_eth::Log` field for field so a guest reconstructs the native alloy log without loss: `block-hash`, `block-timestamp`, `transaction-index`, and `removed` are added; `log-index` and `transaction-index` widen to `u64`; and the block-scoped fields become `option<>` (absent on a pending log). The chain id moves off the per-log record onto a new `chain-logs { chain-id, logs }` batch that the `chain-logs` event arm now wraps, since a delivery always shares one chain and the alloy log type carries no chain id of its own. Guests receive `nexum_sdk::events::Log` (alloy's RPC log) directly and decode `sol!` events against `log.inner`; the SDK bind macro emits the WIT-record-to-alloy conversion, so module glue maps a batch straight to `Vec`. - ---- - -## 2. Error model unification [both] - -The five 0.1 error shapes (`json-rpc-error`, `identity-error`, `msg-error`, `store-error`, `api-error`) plus bare `string` errors give way to the WASI idiom: each interface declares its own typed error, and the errors share one payload-bearing `fault` vocabulary for the cross-domain cases (see [ADR-0011](../adr/0011-per-interface-typed-errors.md)). - -```wit -interface types { - // The shared cross-domain vocabulary. Each payload-bearing case - // carries a human-readable detail; rate-limited carries backoff. - variant fault { - unsupported(string), // capability declared but not provisioned - unavailable(string), // capability exists, backend is down/offline - denied(string), // user or policy rejected - rate-limited(rate-limit), - timeout, - invalid-input(string), - internal(string), // host bug - } - - record rate-limit { - retry-after-ms: option, - } -} -``` - -Interfaces with nothing to add report `fault` directly (identity, local-store, remote-store, messaging, and the module exports). A richer interface embeds `fault` as one case of its own variant and adds the cases only it needs: `chain-error` adds an `rpc` case carrying the node code and decoded revert bytes; `cow-api-error` adds `http` and `rejected`. - -```wit -interface chain { - record rpc-error { code: s32, message: string, data: option> } - variant chain-error { fault(fault), rpc(rpc-error) } -} -``` - -### Author migration - -The stringly `domain`/`code` cross-check is gone; dispatch on the typed variant instead. - -```diff -- match chain::request(1, "eth_call", params) { -- Ok(s) => parse(s), -- Err(JsonRpcError { code, message, .. }) if code == -32000 => retry(), -- Err(e) => bail!("rpc failed: {}", e.message), -- } -+ use nexum_sdk::host::{ChainError, Fault}; -+ match host.request(1, "eth_call", params) { -+ Ok(s) => parse(s), -+ Err(ChainError::Fault(Fault::Unavailable(_) | Fault::Timeout)) => retry(), -+ Err(ChainError::Fault(Fault::RateLimited(rl))) => backoff(rl.retry_after_ms), -+ Err(ChainError::Fault(Fault::Denied(_))) => abort("denied"), -+ Err(ChainError::Rpc(rpc)) => decode_revert(rpc.data), // node code + revert bytes -+ Err(e) => bail!("{e}"), -+ } -``` - -`local-store` errors are no longer bare `string`s: they are a plain `fault`. The interface is the failure domain, so the fault omits any subsystem tag; the case tells you whether you hit a quota (`invalid-input`), the backend is down (`unavailable`), etc. - -Module export signatures also change: - -```diff -- export init: func(config: config) -> result<_, string>; -- export on-event: func(event: event) -> result<_, string>; -+ export init: func(config: config) -> result<_, fault>; -+ export on-event: func(event: event) -> result<_, fault>; -``` - -Module identity is the supervisor's business, so module errors are plain `fault` cases; you no longer restate your module name or prefix your messages. A strategy that aggregates store and chain calls into one `fault` relies on the SDK's `From for Fault` fold for `?`. - -### Embedder migration - -Hosts implementing capability traits now return the interface's typed error. Chain calls return `chain-error` (use the `rpc` case for a structured JSON-RPC error; otherwise a `fault`); the rest return `fault` directly. Map each backend failure to the right case: - -| Backend signal | `fault` case | -|---|---| -| Connection refused / DNS fail / offline | `unavailable` | -| Provider HTTP 4xx (other than 401/403/429) | `invalid-input` | -| Provider HTTP 401/403 | `denied` | -| Provider HTTP 429 | `rate-limited` | -| Provider HTTP 5xx / timeout | `unavailable` or `timeout` (prefer the more specific) | -| Structured JSON-RPC error (node `code`, revert `data`) | `chain-error.rpc` (not a fault) | -| User rejected signing in wallet UI | `denied` | -| Module asked for a capability the host doesn't provide | `unsupported` | -| Bug / panic / internal invariant violated | `internal` | - ---- - -## 3. Manifest changes [both] - -### File rename - -If any code, docs, or scripts reference `shepherd.toml`, change to `nexum.toml`. This was a doc/code inconsistency in 0.1; canonical is `nexum.toml`. - -### Field and section renames - -```diff - [module] - name = "twap-monitor" - version = "0.3.0" -- wasm = "sha256:9f86d081..." -+ component = "sha256:9f86d081..." - -- [[subscribe]] -- type = "block" -- chain_id = 42161 -+ [[subscription]] -+ kind = "block" -+ chain_id = 42161 -``` - -`type` → `kind` because `type` is reserved in several binding languages. - -`[module.resources]` (per-module resource caps) and a top-level `[chains]` table were dropped from this example rather than renamed: neither exists in 0.2's manifest schema - resource limits are global engine defaults today (`docs/02-modules-events-packaging.md`'s "Future direction" note), and chain ids are declared per-`[[subscription]]` (`chain_id`) rather than in a manifest-wide table. - -### Capability declaration (new, required) - -In 0.1 the world declared which interfaces a module imported, and instantiation failed if any were unsatisfied. In 0.2, imports declared `optional` in the manifest install a trap stub on the host side - calling them returns `fault.unsupported` rather than failing instantiation. - -```toml -[capabilities] -required = ["chain", "local-store", "logging"] -optional = ["messaging", "remote-store"] # module continues if host doesn't provide - -[capabilities.http] -allow = ["api.coingecko.com", "discord.com"] -``` - -If you omit `[capabilities]` entirely, 0.2 falls back to "all imports required" - same as 0.1 behaviour - and prints a deprecation warning at load. Add the section in your next module update; the implicit-all fallback will be removed in 0.3. - -### Config: unchanged in 0.2 - -`[config]` values continue to flow through to the guest as `list>` - the host flattens TOML scalars (numbers, booleans) to their string form on the way through, same as 0.1. If you currently parse `"50"` into `u64`, that code continues to work unchanged: - -```rust -let bps: u64 = config.iter() - .find(|(k, _)| k == "slippage_bps") - .map(|(_, v)| v.parse()) - .transpose()? - .unwrap_or(50); -``` - -**Deferred to 0.3.** A typed `config-value` variant (string / integer / boolean / list) and a `#[derive(NexumConfig)]` helper are on the 0.3 roadmap, bundled with the manifest-parser work (see §3) so the typing story lands as one coherent feature. - ---- - -## 4. New capabilities (additive) [author] - -These didn't exist in 0.1 and don't break anything. Adopt them to remove workarounds. - -> **Breaking if you tracked the 0.2 drafts.** Earlier 0.2 drafts published `nexum:host/clock` and `nexum:host/http` WIT interfaces. Both are gone from the package: a component importing either no longer links against the 0.2 world, and a manifest declaring `clock` under `[capabilities]` fails load with an unknown-capability error. Time is ambient `wasi:clocks` with no declaration, and outbound HTTP is a `wasi:http` import (the SDK's `http::fetch` wraps it) plus the `http` capability declaration with a `[capabilities.http].allow` list, as described below. Components built against the final 0.1 surface are unaffected beyond the renames in this guide. - -### Time (ambient wasi:clocks) - -Wall-clock and monotonic time are WASI concerns, not `nexum:host` interfaces: the host links `wasi:clocks` into every module store, so a module reads time through any wasi:clocks binding with no capability declaration. - -Replaces the 0.1 workaround of "only know the time inside `on_block` via `block.timestamp`." - -### Randomness (ambient wasi:random) - -Secure randomness is a WASI concern, not a `nexum:host` interface: the host links `wasi:random` into every module store, so a module draws CSPRNG bytes through any wasi:random binding with no capability declaration. - -Replaces the 0.1 workaround of "you can't, period." - -### `http` (allowlisted) - -Outbound HTTP is the standard `wasi:http/outgoing-handler` interface, not a `nexum:host` one. The host links `wasi:http/{outgoing-handler, types}` into every module store; a module imports it with any wasi:http client binding and declares the `http` capability in its manifest. - -Requires a domain allowlist in `nexum.toml`: - -```toml -[capabilities] -optional = ["http"] - -[capabilities.http] -allow = ["api.coingecko.com", "*.discord.com"] -``` - -Hosts MUST enforce the allowlist on every outgoing request (exact host match or `*.domain` suffix, case-insensitive, ports ignored); off-list hosts fail with the wasi:http `HTTP-request-denied` error code. The host does not follow redirects, so each hop is a fresh request checked against the same list. The operator sees the union of granted domains at module load. This replaces the 0.1 anti-pattern of tunnelling alerts through Waku. - -The host also bounds every request: guest-set request-options timeouts are clamped to the engine's `[limits.http]` maxima (unset ones inherit them), the whole exchange runs under a total deadline, and a response body beyond the configured cap fails with `HTTP-response-body-size`. The knobs and defaults live in `engine.example.toml`. - -### `chain::request-batch` - -```wit -interface chain { - use types.{chain-id, fault}; - - record rpc-error { code: s32, message: string, data: option> } - variant chain-error { fault(fault), rpc(rpc-error) } - - /// A single JSON-RPC request to be executed as part of a batch. - record rpc-request { - method: string, - params: string, - } - - /// Result of a single request inside a batch. Each entry is independent; - /// one failing call does not abort the others. - variant rpc-result { - ok(string), - err(chain-error), - } - - request: func(chain-id: chain-id, method: string, params: string) - -> result; - - /// Hosts that cannot batch natively MUST fall back to sequential - /// `request` calls; the returned list is the same length as `requests` - /// and in the same order. - request-batch: func(chain-id: chain-id, requests: list) - -> result, chain-error>; -} -``` - -Additive. The alloy-backed `HostTransport` now routes `RequestPacket::Batch` through `request-batch` - your existing `provider.multicall(...).await` actually batches on the wire in 0.2 (it didn't in 0.1, despite the docs). - ---- - -## 5. New world: `query-module` (experimental) [author] - -A request/response world for modules that aren't event-driven (wallet rule evaluators, signature validators, pricing oracles). - -```wit -world query-module { - import local-store; - import logging; - // chain, identity, http, etc. are optional via manifest - - export init: func(config: config) -> result<_, fault>; - export evaluate: func(input: list) -> result, fault>; -} -``` - -**Status: WIT is published, no host implementation ships in 0.2.** The 0.2 server runtime only supports `event-module` and `shepherd`. The world is published so module authors can target it experimentally and so embedders building mobile/wallet hosts have a stable contract to implement against. Production support lands in 0.3. - -If you're writing a module that fits this shape, target it now and stub the host with `MockHost` for testing. - ---- - -## 6. Engine crate rename [embedder] - -```diff - [dependencies] -- nxm-engine = "0.1" -+ nexum-engine = "0.2" -``` - -The 0.1 release renamed `nexum-host` → `nxm-engine`. 0.2 reverses that to `nexum-engine` for consistency with `shepherd-sdk` / `shepherd-sdk-test` (and the future `nexum-sdk` / `cargo-nexum` direction described in doc 05). - -```diff -- use nxm_engine::{Engine, Module}; -+ use nexum_engine::{Engine, Module}; -``` - -The Rust API surface is otherwise unchanged in 0.2. The C ABI and `nexum-host` embedder facade (for non-Rust hosts) are explicitly **deferred to a later release** pending mobile validation; do not assume they exist in 0.2. - ---- - -## 7. SDK changes [author] - -### Rust SDK - -```diff -- use nexum_sdk::{provider, Identity, MsgClient, RemoteStore}; -+ use nexum_sdk::{provider, Signer, Messaging, RemoteStore}; -``` - -| 0.1 type | 0.2 type | Notes | -|---|---|---| -| `IdentityClient` | `Signer` | Trait renamed to reflect what it does | -| `MsgClient` | `Messaging` | Drops the meaningless `Client` suffix | -| `CowClient` | `Cow` | Same | -| `HostTransport` | (internal) | Now `pub(crate)`; you access it through `provider()` | -| `block_on` (re-export) | (removed from public API) | Hidden behind the `#[nexum::module]` macro | -| `Error` (multiple variants per domain) | `Fault` + `HostFault` / `ChainError` | Per-interface typed errors over the shared vocabulary; see §2 | - -### Proc macro - -`#[nexum::module]` and `#[shepherd::module]` are unchanged in shape. They now generate against `event-module` / `shepherd` worlds. If you targeted `headless-module` explicitly anywhere, rename to `event-module`. - -### Non-Rust SDKs - -The WIT renames propagate mechanically through `wit-bindgen`. Regenerate your bindings against the 0.2 WIT and your existing call sites - adjusted for the renames in §1 - will type-check. - ---- - -## 8. Mechanical rename cheat sheet [both] - -For mechanical search/replace in your codebase. Apply in order; some replacements depend on earlier ones. - -```bash -# WIT package -rg -l 'web3:runtime' | xargs sed -i 's/web3:runtime/nexum:host/g' - -# Interface names (do these before function names - some functions reference the old interface in paths) -rg -l '\bcsn\b' | xargs sed -i 's/\bcsn\b/chain/g' -rg -l '\bmsg\b' | xargs sed -i 's/\bmsg\b/messaging/g' - -# Worlds -rg -l 'headless-module' | xargs sed -i 's/headless-module/event-module/g' -rg -l 'headless_module' | xargs sed -i 's/headless_module/event_module/g' - -# CoW interface stutter -rg -l '\bcow::cow::' | xargs sed -i 's/\bcow::cow::/cow_api::/g' -# (manual: merge `order` imports into `cow-api`; rename `order::submit` to `cow-api::submit-order`) - -# Feed methods -rg -l '\bfeed-get\b' | xargs sed -i 's/\bfeed-get\b/read-feed/g' -rg -l '\bfeed-set\b' | xargs sed -i 's/\bfeed-set\b/write-feed/g' -rg -l '\bfeed_get\b' | xargs sed -i 's/\bfeed_get\b/read_feed/g' -rg -l '\bfeed_set\b' | xargs sed -i 's/\bfeed_set\b/write_feed/g' - -# Type renames -rg -l '\bblock-data\b' | xargs sed -i 's/\bblock-data\b/block/g' -rg -l '\blog-entry\b' | xargs sed -i 's/\blog-entry\b/log/g' -rg -l '\bmessage-data\b' | xargs sed -i 's/\bmessage-data\b/message/g' -rg -l '\btx-hash\b' | xargs sed -i 's/\btx-hash\b/transaction-hash/g' -rg -l '\btx_hash\b' | xargs sed -i 's/\btx_hash\b/transaction_hash/g' - -# Crate rename (Cargo.toml + use statements) -rg -l '\bnxm-engine\b' | xargs sed -i 's/\bnxm-engine\b/nexum-engine/g' -rg -l '\bnxm_engine\b' | xargs sed -i 's/\bnxm_engine\b/nexum_engine/g' - -# Manifest section -rg -l '\[\[subscribe\]\]' | xargs sed -i 's/\[\[subscribe\]\]/[[subscription]]/g' - -# Manifest field -rg -l '^wasm = ' | xargs sed -i 's/^wasm = /component = /' -``` - -Things that **cannot** be sedded - do these by hand: - -- `timer(u64)` → `tick(tick)` with the new `tick { fired-at: u64 }` record. Call sites that pattern-match `Event::Timer(ts)` become `Event::Tick(tick) => tick.fired_at`. -- Error handling. The five old error types are gone; you can't mechanically rewrite a `match` against `JsonRpcError { code, .. }` into the new typed variants (`ChainError::Rpc`, the `fault` cases). Do these per-call-site. -- Splitting `cow` + `order` into a single `cow-api`. Rewrite the imports and adjust function paths. -- Adding `[capabilities]` to `nexum.toml`. Declare what your module actually uses; this is a meaningful audit. - ---- - -## 9. Verification checklist [both] - -After running the renames: - -- [ ] `cargo check --workspace --all-targets` is clean (Rust + bindings). -- [ ] `cargo check --target wasm32-wasip2 -p ` is clean. -- [ ] `cargo test --workspace --no-fail-fast` passes. -- [ ] Your bindgen invocations point at the package's own WIT dir (`wit/nexum-host/`) - or, when consuming both `nexum:host` and a domain-extension package, list both paths explicitly. The 0.1 vendored `deps/` pattern is no longer used in the reference repo. -- [ ] `nexum.toml` has a `[capabilities]` section listing what the module uses. -- [ ] `nexum.toml` references `component = "sha256:..."` not `wasm = ...`. -- [ ] All `[[subscribe]]` sections renamed to `[[subscription]]` with `kind` (not `type`). -- [ ] No remaining references to `web3:runtime`, `csn`, `msg`, `headless-module`, `nxm-engine`, `shepherd.toml`, `feed-get`/`feed-set`, `block-data`/`log-entry`/`message-data`, `tx-hash`. -- [ ] All `Result<_, String>` from module exports replaced with `Result<_, fault>`. -- [ ] Error matching code dispatches on the typed variant (`fault` cases, `ChainError::Rpc`), not protocol-specific error codes. -- [ ] If you used `chrono`/timestamp arithmetic, audited for the seconds-vs-ms change (0.2 is always ms UTC). -- [ ] If you used `provider.multicall(...).await`, confirmed it now actually batches on the wire (`chain::request-batch` shows in tracing). - -> **No `cargo nexum` toolchain in 0.2.** A `cargo-nexum` cargo subcommand (with `new`, `check`, `package`, `run --mock`, `migrate`) is on the 0.3 roadmap. Until then, use `cargo` directly and the `just` recipes in the reference repo. - ---- - -## 10. Deprecation policy going forward [both] - -0.2 is the breaking-change window. The contracts below are stable starting at 0.2.0: - -- WIT package name `nexum:host` and interface names within it. -- The per-interface typed errors over the shared `fault` vocabulary. -- The `nexum.toml` manifest schema. -- The `#[nexum::module]` macro surface. - -Additive changes (new interfaces, new manifest fields, new SDK helpers) may land in any 0.2.x release. Existing identifiers will not be removed or repurposed before 1.0 without a deprecation cycle of at least one minor release. - -The mobile/wallet host story (`query-module` production support, C ABI, `nexum-host` embedder crate) is on the 0.3 roadmap, conditional on a named design partner. The 0.2 `query-module` WIT is an experimental option, not a stable contract; expect changes to its error variants and request/response payload conventions before the 0.3 host ships. - ---- - -## 11. Getting help - -- Open an issue at the repo with the `migration-0.2` label. -- The full 0.2 WIT lives in `wit/nexum-host/` (formerly `wit/web3-runtime/`). -- The §8 cheat sheet has the mechanical sed commands; a `cargo nexum migrate --from 0.1` codemod that wraps them safely is planned for 0.3 alongside the rest of the `cargo-nexum` toolchain. diff --git a/docs/operations/backtest-reports/backtest-7d-2026-06-22.md b/docs/operations/backtest-reports/backtest-7d-2026-06-22.md deleted file mode 100644 index a1796243..00000000 --- a/docs/operations/backtest-reports/backtest-7d-2026-06-22.md +++ /dev/null @@ -1,289 +0,0 @@ -# Pre-soak backtest - 7d window on Sepolia (2026-07-10T18:55:13Z) - -Replays every collected EthFlow `OrderPlacement` event through the production `ethflow_watcher::strategy::on_chain_logs` code path via `shepherd_sdk_test::MockHost`. The orderbook is **never hit**: the MockHost programs a catch-all 200 for all `cow_api_request` calls so the observe+verify strategy sees every fixture as already indexed. Success is measured by whether the strategy wrote the exact `observed:{uid}` marker to the local store after the 200 confirmation. - -## Run metadata - -| Field | Value | -|---|---| -| Chain | Sepolia (id=11155111) | -| Window | 7d (11066372..11116772) | -| Collected at | 2026-06-22T15:47:06Z | -| RPC | `https://sepolia.drpc.org` | -| Orderbook | `https://api.cow.fi/sepolia/api/v1` | -| EthFlow owner | `0xba3cb449bd2b4adddbc894d8697f5170800eadec` | -| ComposableCoW | `0xfdafc9d1902f4e0b84f65f49f244b32b31013b74` | -| Accept threshold | 95% | - -## EthFlow replay summary - -- Events replayed: **240** -- Observed: **240** (100.0%) - -Accepted (Observed + RejectedExpected): **240/240 = 100.0%** - PASS vs. threshold (95%). - -## Anomalies - -None. Every replayed event landed in `Observed` or `RejectedExpected`. - -## TWAP lane status - -26 `ConditionalOrderCreated` events were collected in this window. **Replay deferred to Phase 2B** because driving `twap_monitor::strategy::on_block` requires walking each watch's `eth_call(getTradeableOrderWithSignature)` per-block - a workload public-tier RPCs refuse (see the baseline-latency finding). The fixtures are committed for the future re-run; the TWAP gap on the sign-off is intentional and tracked separately. - -## Sign-off - -**PASS.** EthFlow replay clears the 95% acceptance bar with no outstanding anomalies. All fixtures were Observed (strategy wrote `observed:{uid}` to local store). Soak is unblocked from the backtest side; remaining blockers are external (paid RPC + VM for the wall-clock run). - -## Reproducing - -```bash -python3 tools/backtest-collect/backtest_collect.py --days 7 -cargo run -p shepherd-backtest -- \ - --fixtures tools/backtest-collect/fixtures-YYYY-MM-DD.json -``` - -## Appendix: per-event classification - -| # | uid | block | timestamp | class | -|---:|---|---:|---:|---| -| 1 | `0x5e43c584..ffffff` | 11066776 | 1781541696 | Observed | -| 2 | `0xf56cba95..ffffff` | 11066777 | 1781541708 | Observed | -| 3 | `0xb47d7c7f..ffffff` | 11066798 | 1781541960 | Observed | -| 4 | `0x4069c497..ffffff` | 11066799 | 1781541972 | Observed | -| 5 | `0xed31c793..ffffff` | 11066818 | 1781542200 | Observed | -| 6 | `0x5da63e4e..ffffff` | 11066819 | 1781542212 | Observed | -| 7 | `0x6c3227cb..ffffff` | 11066844 | 1781542512 | Observed | -| 8 | `0x97fa5d54..ffffff` | 11066844 | 1781542512 | Observed | -| 9 | `0xe5197c47..ffffff` | 11066863 | 1781542740 | Observed | -| 10 | `0xf13dc9a1..ffffff` | 11066863 | 1781542740 | Observed | -| 11 | `0x6118ba38..ffffff` | 11067068 | 1781545200 | Observed | -| 12 | `0xb2efc9f1..ffffff` | 11067190 | 1781546664 | Observed | -| 13 | `0x7f76bfa1..ffffff` | 11067200 | 1781546784 | Observed | -| 14 | `0x2c6cca82..ffffff` | 11067729 | 1781553132 | Observed | -| 15 | `0xe32c8541..ffffff` | 11068198 | 1781558760 | Observed | -| 16 | `0x5af30b3e..ffffff` | 11068620 | 1781563836 | Observed | -| 17 | `0xfa067d01..ffffff` | 11069102 | 1781569632 | Observed | -| 18 | `0xf242a25b..ffffff` | 11069119 | 1781569836 | Observed | -| 19 | `0x8bd36dc7..ffffff` | 11069495 | 1781574348 | Observed | -| 20 | `0x591da4be..ffffff` | 11069501 | 1781574420 | Observed | -| 21 | `0xa9a747d5..ffffff` | 11069948 | 1781579796 | Observed | -| 22 | `0x8b778cc7..ffffff` | 11070077 | 1781581344 | Observed | -| 23 | `0x0a56f14f..ffffff` | 11070107 | 1781581704 | Observed | -| 24 | `0x43445124..ffffff` | 11070306 | 1781584104 | Observed | -| 25 | `0x01026a74..ffffff` | 11070324 | 1781584320 | Observed | -| 26 | `0x71b74cf6..ffffff` | 11070394 | 1781585160 | Observed | -| 27 | `0x8834595a..ffffff` | 11070716 | 1781589024 | Observed | -| 28 | `0x6f4fae10..ffffff` | 11071674 | 1781600520 | Observed | -| 29 | `0x50211d94..ffffff` | 11071675 | 1781600532 | Observed | -| 30 | `0xcd925b4d..ffffff` | 11071676 | 1781600544 | Observed | -| 31 | `0x297cb16d..ffffff` | 11071677 | 1781600556 | Observed | -| 32 | `0x57e24641..ffffff` | 11071679 | 1781600580 | Observed | -| 33 | `0xb30c35c2..ffffff` | 11071681 | 1781600604 | Observed | -| 34 | `0x743f9609..ffffff` | 11071682 | 1781600616 | Observed | -| 35 | `0x713bc286..ffffff` | 11071683 | 1781600628 | Observed | -| 36 | `0x7925f236..ffffff` | 11071684 | 1781600640 | Observed | -| 37 | `0x11d613bf..ffffff` | 11071687 | 1781600676 | Observed | -| 38 | `0xd42de36d..ffffff` | 11072025 | 1781604732 | Observed | -| 39 | `0xe81f3615..ffffff` | 11072165 | 1781606412 | Observed | -| 40 | `0xfe8b70cc..ffffff` | 11072663 | 1781612388 | Observed | -| 41 | `0xfb9ebfe2..ffffff` | 11072665 | 1781612412 | Observed | -| 42 | `0x76c0a5ea..ffffff` | 11072730 | 1781613192 | Observed | -| 43 | `0xb2e7c4b5..ffffff` | 11072738 | 1781613288 | Observed | -| 44 | `0x20484bb9..ffffff` | 11072742 | 1781613336 | Observed | -| 45 | `0x541fb237..ffffff` | 11072774 | 1781613720 | Observed | -| 46 | `0x2404f184..ffffff` | 11072774 | 1781613720 | Observed | -| 47 | `0x30e44c53..ffffff` | 11072791 | 1781613924 | Observed | -| 48 | `0x9a340499..ffffff` | 11072801 | 1781614044 | Observed | -| 49 | `0x7f7b151d..ffffff` | 11072849 | 1781614620 | Observed | -| 50 | `0xb68eeaf4..ffffff` | 11072850 | 1781614632 | Observed | -| 51 | `0x5395405d..ffffff` | 11072881 | 1781615004 | Observed | -| 52 | `0x45d5563b..ffffff` | 11072881 | 1781615004 | Observed | -| 53 | `0x15431ff4..ffffff` | 11072907 | 1781615316 | Observed | -| 54 | `0xab2d5a81..ffffff` | 11072907 | 1781615316 | Observed | -| 55 | `0x3918584c..ffffff` | 11072915 | 1781615412 | Observed | -| 56 | `0x433ded5d..ffffff` | 11072937 | 1781615676 | Observed | -| 57 | `0x38e4a8d5..ffffff` | 11072938 | 1781615688 | Observed | -| 58 | `0x2391bfae..ffffff` | 11073096 | 1781617584 | Observed | -| 59 | `0xf6cd036e..ffffff` | 11073102 | 1781617656 | Observed | -| 60 | `0x157fa6bc..ffffff` | 11073102 | 1781617656 | Observed | -| 61 | `0x70aeba19..ffffff` | 11073107 | 1781617716 | Observed | -| 62 | `0x8d4ca0b6..ffffff` | 11073145 | 1781618172 | Observed | -| 63 | `0x45902e3e..ffffff` | 11073145 | 1781618172 | Observed | -| 64 | `0x19d6b26a..ffffff` | 11073178 | 1781618568 | Observed | -| 65 | `0x8ecd3588..ffffff` | 11073179 | 1781618580 | Observed | -| 66 | `0x2baee5d9..ffffff` | 11073185 | 1781618652 | Observed | -| 67 | `0x0a684eb7..ffffff` | 11073186 | 1781618664 | Observed | -| 68 | `0xccf652d3..ffffff` | 11073220 | 1781619072 | Observed | -| 69 | `0x51bd84d8..ffffff` | 11073220 | 1781619072 | Observed | -| 70 | `0x3f2f050f..ffffff` | 11073251 | 1781619444 | Observed | -| 71 | `0x9b53383b..ffffff` | 11073251 | 1781619444 | Observed | -| 72 | `0xd55d2e7e..ffffff` | 11073259 | 1781619540 | Observed | -| 73 | `0x355e6c2e..ffffff` | 11073304 | 1781620080 | Observed | -| 74 | `0xb75a1e65..ffffff` | 11073309 | 1781620140 | Observed | -| 75 | `0x812f257a..ffffff` | 11073316 | 1781620224 | Observed | -| 76 | `0x25efa78a..ffffff` | 11073319 | 1781620260 | Observed | -| 77 | `0x72aee1ae..ffffff` | 11073340 | 1781620512 | Observed | -| 78 | `0xe10e143e..ffffff` | 11073345 | 1781620572 | Observed | -| 79 | `0x1cb540d1..ffffff` | 11073377 | 1781620956 | Observed | -| 80 | `0x4a27b3b7..ffffff` | 11073446 | 1781621784 | Observed | -| 81 | `0x6f60c9b7..ffffff` | 11073450 | 1781621832 | Observed | -| 82 | `0x2afac9af..ffffff` | 11073618 | 1781623884 | Observed | -| 83 | `0x09eed081..ffffff` | 11073639 | 1781624136 | Observed | -| 84 | `0x0f04118e..ffffff` | 11073639 | 1781624136 | Observed | -| 85 | `0xb1445f46..ffffff` | 11073661 | 1781624400 | Observed | -| 86 | `0x1b9f5ae8..ffffff` | 11073662 | 1781624412 | Observed | -| 87 | `0xbf218ce6..ffffff` | 11073684 | 1781624676 | Observed | -| 88 | `0xcd000d8e..ffffff` | 11073684 | 1781624676 | Observed | -| 89 | `0x41e05a80..ffffff` | 11073706 | 1781624940 | Observed | -| 90 | `0x46728c28..ffffff` | 11073706 | 1781624940 | Observed | -| 91 | `0x7d517f6b..ffffff` | 11073716 | 1781625060 | Observed | -| 92 | `0xcd0993d3..ffffff` | 11073738 | 1781625324 | Observed | -| 93 | `0xd41c9e0b..ffffff` | 11073738 | 1781625324 | Observed | -| 94 | `0x728367f6..ffffff` | 11073789 | 1781625936 | Observed | -| 95 | `0xa78cabeb..ffffff` | 11074351 | 1781632692 | Observed | -| 96 | `0x211dd498..ffffff` | 11074351 | 1781632692 | Observed | -| 97 | `0x5f686fb7..ffffff` | 11074387 | 1781633124 | Observed | -| 98 | `0x3b7b5aaa..ffffff` | 11074387 | 1781633124 | Observed | -| 99 | `0x8cb5b94f..ffffff` | 11074421 | 1781633532 | Observed | -| 100 | `0x7545eda0..ffffff` | 11074421 | 1781633532 | Observed | -| 101 | `0x6c4472c1..ffffff` | 11074463 | 1781634048 | Observed | -| 102 | `0x134a3f32..ffffff` | 11074464 | 1781634060 | Observed | -| 103 | `0x625b15a3..ffffff` | 11074482 | 1781634276 | Observed | -| 104 | `0x8b981bae..ffffff` | 11074491 | 1781634408 | Observed | -| 105 | `0x2316cffb..ffffff` | 11074491 | 1781634408 | Observed | -| 106 | `0x019e8adf..ffffff` | 11076610 | 1781659896 | Observed | -| 107 | `0xe0ccf0ed..ffffff` | 11076616 | 1781659968 | Observed | -| 108 | `0xfcfd0fa6..ffffff` | 11076620 | 1781660016 | Observed | -| 109 | `0x9fabbf08..ffffff` | 11077214 | 1781667204 | Observed | -| 110 | `0x84fd9342..ffffff` | 11077272 | 1781667900 | Observed | -| 111 | `0xa1b4b41b..ffffff` | 11078078 | 1781677608 | Observed | -| 112 | `0x9e4ceb19..ffffff` | 11078078 | 1781677608 | Observed | -| 113 | `0xd6eaa1a9..ffffff` | 11078093 | 1781677788 | Observed | -| 114 | `0x819ff1ec..ffffff` | 11078093 | 1781677788 | Observed | -| 115 | `0xa06aafbb..ffffff` | 11078113 | 1781678028 | Observed | -| 116 | `0xd6f7b83b..ffffff` | 11078114 | 1781678040 | Observed | -| 117 | `0x1ac7b661..ffffff` | 11078214 | 1781679240 | Observed | -| 118 | `0x9fc72d42..ffffff` | 11078215 | 1781679252 | Observed | -| 119 | `0x642b7890..ffffff` | 11078225 | 1781679372 | Observed | -| 120 | `0x7cf68a7b..ffffff` | 11078246 | 1781679624 | Observed | -| 121 | `0xb6e10751..ffffff` | 11078248 | 1781679648 | Observed | -| 122 | `0xd3c38d90..ffffff` | 11078475 | 1781682372 | Observed | -| 123 | `0x22e93a3a..ffffff` | 11078475 | 1781682372 | Observed | -| 124 | `0x5e60eb4e..ffffff` | 11078500 | 1781682672 | Observed | -| 125 | `0xaf138bc2..ffffff` | 11078501 | 1781682684 | Observed | -| 126 | `0xcbb6226f..ffffff` | 11078504 | 1781682720 | Observed | -| 127 | `0x7bc92f46..ffffff` | 11078513 | 1781682828 | Observed | -| 128 | `0x7bf72b31..ffffff` | 11078520 | 1781682912 | Observed | -| 129 | `0x9dd59d5b..ffffff` | 11078520 | 1781682912 | Observed | -| 130 | `0x8f8bed50..ffffff` | 11078539 | 1781683140 | Observed | -| 131 | `0x4e04244d..ffffff` | 11078539 | 1781683140 | Observed | -| 132 | `0xe16973fa..ffffff` | 11078557 | 1781683356 | Observed | -| 133 | `0xfb362d58..ffffff` | 11078558 | 1781683368 | Observed | -| 134 | `0x6068b9d9..ffffff` | 11080029 | 1781701044 | Observed | -| 135 | `0x299fe688..ffffff` | 11080365 | 1781705076 | Observed | -| 136 | `0xbdbb9773..ffffff` | 11082360 | 1781729064 | Observed | -| 137 | `0xbaedfe1c..ffffff` | 11083644 | 1781744496 | Observed | -| 138 | `0x8194545d..ffffff` | 11083764 | 1781745936 | Observed | -| 139 | `0x9c92f5f3..ffffff` | 11083770 | 1781746008 | Observed | -| 140 | `0x98906b97..ffffff` | 11083812 | 1781746512 | Observed | -| 141 | `0x627afacc..ffffff` | 11083859 | 1781747076 | Observed | -| 142 | `0x240bcbde..ffffff` | 11083866 | 1781747160 | Observed | -| 143 | `0x2559867e..ffffff` | 11084049 | 1781749380 | Observed | -| 144 | `0x7ce8d855..ffffff` | 11084079 | 1781749740 | Observed | -| 145 | `0xffc3686e..ffffff` | 11084150 | 1781750592 | Observed | -| 146 | `0x7b51bb4a..ffffff` | 11084744 | 1781757792 | Observed | -| 147 | `0x8b4e73ec..ffffff` | 11085093 | 1781762016 | Observed | -| 148 | `0x8bd2dbf8..ffffff` | 11085093 | 1781762016 | Observed | -| 149 | `0xd3d9da38..ffffff` | 11085123 | 1781762376 | Observed | -| 150 | `0x518e19aa..ffffff` | 11085123 | 1781762376 | Observed | -| 151 | `0x8bb95de2..ffffff` | 11085229 | 1781763648 | Observed | -| 152 | `0x3f80482c..ffffff` | 11085229 | 1781763648 | Observed | -| 153 | `0x6217df15..ffffff` | 11085285 | 1781764320 | Observed | -| 154 | `0xb8b7945e..ffffff` | 11085290 | 1781764380 | Observed | -| 155 | `0xbeaa3ed3..ffffff` | 11085296 | 1781764452 | Observed | -| 156 | `0xac9baa9a..ffffff` | 11085311 | 1781764632 | Observed | -| 157 | `0x5471a5aa..ffffff` | 11085316 | 1781764692 | Observed | -| 158 | `0xb9af72ee..ffffff` | 11085768 | 1781770116 | Observed | -| 159 | `0x577b183c..ffffff` | 11086236 | 1781775732 | Observed | -| 160 | `0xafd52f06..ffffff` | 11086284 | 1781776308 | Observed | -| 161 | `0x4ee00443..ffffff` | 11086290 | 1781776380 | Observed | -| 162 | `0x64427828..ffffff` | 11086493 | 1781778816 | Observed | -| 163 | `0x31cce049..ffffff` | 11087438 | 1781790168 | Observed | -| 164 | `0x927561c1..ffffff` | 11087442 | 1781790216 | Observed | -| 165 | `0x33e40575..ffffff` | 11087462 | 1781790456 | Observed | -| 166 | `0x5fe03b4e..ffffff` | 11087468 | 1781790528 | Observed | -| 167 | `0x5e7f3fe1..ffffff` | 11087473 | 1781790588 | Observed | -| 168 | `0x0cfa5720..ffffff` | 11087748 | 1781793888 | Observed | -| 169 | `0x88e25f26..ffffff` | 11087749 | 1781793900 | Observed | -| 170 | `0x3d47b55b..ffffff` | 11089218 | 1781811528 | Observed | -| 171 | `0x91b7bb98..ffffff` | 11089257 | 1781811996 | Observed | -| 172 | `0x47931578..ffffff` | 11089274 | 1781812200 | Observed | -| 173 | `0x006b940d..ffffff` | 11089296 | 1781812464 | Observed | -| 174 | `0x104f25a0..ffffff` | 11089394 | 1781813640 | Observed | -| 175 | `0x6d296984..ffffff` | 11089725 | 1781817624 | Observed | -| 176 | `0xf5788a8b..ffffff` | 11089920 | 1781819964 | Observed | -| 177 | `0xdd4a43c4..ffffff` | 11090323 | 1781824800 | Observed | -| 178 | `0x3d1098b8..ffffff` | 11091082 | 1781833920 | Observed | -| 179 | `0xe11c2022..ffffff` | 11091089 | 1781834004 | Observed | -| 180 | `0x1b7ecce8..ffffff` | 11091095 | 1781834076 | Observed | -| 181 | `0x17413c36..ffffff` | 11091102 | 1781834160 | Observed | -| 182 | `0xe18203b8..ffffff` | 11091111 | 1781834268 | Observed | -| 183 | `0x362dcbfb..ffffff` | 11091361 | 1781837316 | Observed | -| 184 | `0x7fe88a51..ffffff` | 11093487 | 1781863032 | Observed | -| 185 | `0x0c37aa67..ffffff` | 11094315 | 1781872980 | Observed | -| 186 | `0x2170ca89..ffffff` | 11094609 | 1781876508 | Observed | -| 187 | `0xcc734808..ffffff` | 11094664 | 1781877168 | Observed | -| 188 | `0x30fbd136..ffffff` | 11095162 | 1781883144 | Observed | -| 189 | `0x35e2b54d..ffffff` | 11095647 | 1781888964 | Observed | -| 190 | `0x2300a9f6..ffffff` | 11096098 | 1781894376 | Observed | -| 191 | `0xe2e97306..ffffff` | 11097901 | 1781916048 | Observed | -| 192 | `0x15d6d916..ffffff` | 11098198 | 1781919612 | Observed | -| 193 | `0xd0186dc0..ffffff` | 11098367 | 1781921652 | Observed | -| 194 | `0xe188b861..ffffff` | 11098372 | 1781921712 | Observed | -| 195 | `0xd13cad5b..ffffff` | 11098766 | 1781926452 | Observed | -| 196 | `0xbd8cc161..ffffff` | 11099348 | 1781933460 | Observed | -| 197 | `0x77446407..ffffff` | 11099353 | 1781933520 | Observed | -| 198 | `0xfec45a42..ffffff` | 11100012 | 1781941428 | Observed | -| 199 | `0xd97a9fa0..ffffff` | 11100016 | 1781941476 | Observed | -| 200 | `0xb1c28fdb..ffffff` | 11102012 | 1781965476 | Observed | -| 201 | `0x62ac8489..ffffff` | 11102181 | 1781967528 | Observed | -| 202 | `0x64fc616d..ffffff` | 11103512 | 1781983536 | Observed | -| 203 | `0x809b0200..ffffff` | 11105542 | 1782007956 | Observed | -| 204 | `0x2efe5755..ffffff` | 11105545 | 1782008004 | Observed | -| 205 | `0xa801952a..ffffff` | 11105551 | 1782008076 | Observed | -| 206 | `0xe51fcd2e..ffffff` | 11105566 | 1782008256 | Observed | -| 207 | `0x87e7ed80..ffffff` | 11105602 | 1782008688 | Observed | -| 208 | `0xa249ff22..ffffff` | 11105613 | 1782008820 | Observed | -| 209 | `0xfbe5e123..ffffff` | 11105618 | 1782008880 | Observed | -| 210 | `0x72bf47dc..ffffff` | 11105715 | 1782010044 | Observed | -| 211 | `0x1293c734..ffffff` | 11106911 | 1782024444 | Observed | -| 212 | `0xeeb9580b..ffffff` | 11106932 | 1782024696 | Observed | -| 213 | `0x42e1019e..ffffff` | 11107190 | 1782027792 | Observed | -| 214 | `0x863285b8..ffffff` | 11108038 | 1782037968 | Observed | -| 215 | `0x3545ede8..ffffff` | 11109421 | 1782054600 | Observed | -| 216 | `0x98d90da1..ffffff` | 11112811 | 1782095304 | Observed | -| 217 | `0xf60dc92a..ffffff` | 11112823 | 1782095448 | Observed | -| 218 | `0xbad0af58..ffffff` | 11112886 | 1782096204 | Observed | -| 219 | `0xda12aeee..ffffff` | 11112894 | 1782096300 | Observed | -| 220 | `0x271f933d..ffffff` | 11113205 | 1782100044 | Observed | -| 221 | `0x354f7970..ffffff` | 11114331 | 1782113580 | Observed | -| 222 | `0x52d511d9..ffffff` | 11114353 | 1782113844 | Observed | -| 223 | `0x39ba5342..ffffff` | 11115217 | 1782124248 | Observed | -| 224 | `0x8f627309..ffffff` | 11115224 | 1782124332 | Observed | -| 225 | `0x4d0b40ff..ffffff` | 11115229 | 1782124392 | Observed | -| 226 | `0xdc658bc7..ffffff` | 11115320 | 1782125508 | Observed | -| 227 | `0x3ad5be48..ffffff` | 11115412 | 1782126624 | Observed | -| 228 | `0xa412cc0c..ffffff` | 11115417 | 1782126684 | Observed | -| 229 | `0x6cce9b62..ffffff` | 11115424 | 1782126768 | Observed | -| 230 | `0x45902f9e..ffffff` | 11115429 | 1782126828 | Observed | -| 231 | `0xfac5eea4..ffffff` | 11115499 | 1782127668 | Observed | -| 232 | `0x3f581643..ffffff` | 11115786 | 1782131112 | Observed | -| 233 | `0x303a3415..ffffff` | 11115796 | 1782131232 | Observed | -| 234 | `0xc6bf93cb..ffffff` | 11115816 | 1782131472 | Observed | -| 235 | `0xc70930be..ffffff` | 11116414 | 1782138732 | Observed | -| 236 | `0xbdc6f0ae..ffffff` | 11116631 | 1782141408 | Observed | -| 237 | `0x0bab3b08..ffffff` | 11116635 | 1782141456 | Observed | -| 238 | `0x1916db8f..ffffff` | 11116641 | 1782141528 | Observed | -| 239 | `0xda1c4056..ffffff` | 11116645 | 1782141576 | Observed | -| 240 | `0xa2d2d863..ffffff` | 11116660 | 1782141756 | Observed | - diff --git a/docs/operations/baselines/baseline-latency-2026-06-19.md b/docs/operations/baselines/baseline-latency-2026-06-19.md deleted file mode 100644 index 4faa5ccb..00000000 --- a/docs/operations/baselines/baseline-latency-2026-06-19.md +++ /dev/null @@ -1,60 +0,0 @@ -# CoW orderbook EthFlow indexer baseline (2026-06-22T14:03:22Z) - -Per-chain pairing of every on-chain `EthFlow.OrderPlacement` event in the trailing window with the orderbook's record for the same UID, plus the `(creationDate - block.timestamp)` delta. Each pair is rigorous — the script ABI-decodes the event's GPv2OrderData and derives the OrderUid via EIP-712 before looking it up — so the data is ground-truth, not a temporal-FIFO approximation. - -## Headline finding - -**For EthFlow orders the orderbook indexer sets `creationDate := block.timestamp`** (not the indexer's ingest time), so the historical delta is structurally 0s on every chain. This is the orderbook's intentional behaviour for back-fill-style flows; it is **not** a measurement bug. The implication for the M4 / M5 KPIs is that EthFlow indexer latency cannot be derived from historical orderbook data — the meaningful relayer-latency baseline lives on the TWAP lane (where the orderbook records the indexer's `now()` per child order PUT). TWAP child-latency is tracked as a follow-up since it requires per-part UID derivation from each parent `ConditionalOrderCreated` static input. - -What the run below **is** useful for: confirming the orderbook's `creationDate` semantics across every supported chain, and yielding ground-truth UID ↔ block pairings the M4 e2e harness can cross-check against. - -## Method - -- Window: trailing **7 days** from the run. -- Event source: `eth_getLogs` against the chain's ETH_FLOW_PRODUCTION (ETH_FLOW_SEPOLIA on Sepolia) for the `OrderPlacement` topic. -- Order source: `GET /account/{ETH_FLOW_ADDRESS}/orders` from the chain's cow.fi orderbook, paginated. -- Pairing: per-event EIP-712 UID derivation. For each event the script ABI-decodes the GPv2OrderData payload, computes the order digest against the chain's GPv2Settlement domain, and assembles UID = digest || ethflow_owner || validTo. Each UID is then looked up against the bulk `/account/.../orders` fetch, falling back to `GET /api/v1/orders/{uid}` if the bulk page missed it. No temporal-FIFO approximation. -- Sanity filters: negative deltas dropped (clock skew between block and indexer); deltas > 1 hour dropped (stale/re-indexed order). -- Event cap per chain: **200** (most recent). - -## EthFlow latency, per chain - -| Chain | Events scanned | Orders fetched | Pairs | Median (s) | p95 (s) | -|---|---:|---:|---:|---:|---:| -| Mainnet | 0 | 0 | 0 | n/a | n/a | -| Gnosis | 0 | 0 | 0 | n/a | n/a | -| Arbitrum One | 0 | 0 | 0 | n/a | n/a | -| Base | 0 | 0 | 0 | n/a | n/a | -| Sepolia | 256 | 5000 | 200 | 0.00 | 0.00 | - -## TWAP latency, per chain - -*Not measured in v1 of this baseline.* TWAP requires reconstructing `(t0, n, t)` from each parent `ConditionalOrderCreated` static input and deriving each child order's UID per part, then matching to the orderbook's child orders. Tracked as a follow-up; **EthFlow alone is sufficient anchor for the M4 KPI bar** since both modules share the same dispatch path in shepherd. - -## Notes per chain - -- **Mainnet**: - - RPC-LIMITED: public endpoint (https://eth.drpc.org) refused the log scan even at 50-block chunks (endpoint refused 3 consecutive calls at chunk=31: 408 Client Error: Request Timeout for url: https://eth.drpc.org/). Re-run with a paid endpoint via RPC_URL_* env to get real data; this baseline cell stays blank. Matches the paid-endpoint requirement. -- **Gnosis**: - - RPC-LIMITED: public endpoint (https://gnosis.drpc.org) refused the log scan even at 50-block chunks (endpoint refused 3 consecutive calls at chunk=31: 500 Server Error: Internal Server Error for url: https://gnosis.drpc.org/). Re-run with a paid endpoint via RPC_URL_* env to get real data; this baseline cell stays blank. Matches the paid-endpoint requirement. -- **Arbitrum One**: - - RPC-LIMITED: public endpoint (https://arbitrum.drpc.org) refused the log scan even at 50-block chunks (endpoint refused 3 consecutive calls at chunk=31: 500 Server Error: Internal Server Error for url: https://arbitrum.drpc.org/). Re-run with a paid endpoint via RPC_URL_* env to get real data; this baseline cell stays blank. Matches the paid-endpoint requirement. -- **Base**: - - RPC-LIMITED: public endpoint (https://base.drpc.org) refused the log scan even at 50-block chunks (endpoint refused 3 consecutive calls at chunk=31: 500 Server Error: Internal Server Error for url: https://base.drpc.org/). Re-run with a paid endpoint via RPC_URL_* env to get real data; this baseline cell stays blank. Matches the paid-endpoint requirement. -- **Sepolia**: - - capped to last 200 events of 256 - - match diagnostics: bulk_hit=200 - -## Reproducing - -```bash -python3 tools/baseline-latency/baseline_latency.py \ - --window-days 7 --max-events-per-chain 200 \ - --out docs/operations/baselines/baseline-latency-$(date -u +%Y-%m-%d).md -``` - -Override individual RPCs via env: `RPC_URL_MAINNET`, `RPC_URL_GNOSIS`, `RPC_URL_ARBITRUM`, `RPC_URL_BASE`, `RPC_URL_SEPOLIA_HTTP`. - -## Provenance - -Script: `tools/baseline-latency/baseline_latency.py`. Raw data dump per chain: `tools/baseline-latency/data/`. diff --git a/docs/operations/e2e-prep.md b/docs/operations/e2e-prep.md deleted file mode 100644 index 795dd890..00000000 --- a/docs/operations/e2e-prep.md +++ /dev/null @@ -1,334 +0,0 @@ -# E2E run-prep punch list - -Companion to `docs/operations/e2e-testnet-runbook.md`. This file -captures every **pinned value** for the 2026-06-18 dry run so the operator can copy-paste through the on-chain -actions without re-deriving any UID, address, or calldata. - -If you are running a *later* E2E run (different EOA, different -Safe, different config), do not reuse the UIDs / calldatas — they -are a function of all the pinned config below. Either re-derive -via the Python recipes in this doc, or re-run -`cargo test -p stop-loss --lib cow_1064` to lock the new UID. - ---- - -## 0. Pinned identities (2026-06-18 run) - -| Role | Address | Network | Notes | -|---|---|---|---| -| Test EOA | `0x7bF140727D27ea64b607E042f1225680B40ECa6A` | Sepolia | Bruno-controlled. Funds itself via faucet. | -| Test Safe (single-sig, threshold 1) | `0x14995a1118Caf95833e923faf8Dd155721cd53c2` | Sepolia | EOA is the sole owner. Submits TWAP order. | -| ComposableCoW | `0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74` | Sepolia | Where `create((address,bytes32,bytes),bool)` lands. | -| TWAP handler | `0x6cF1e9cA41f7611dEf408122793c358a3d11E5a5` | Sepolia | `ConditionalOrderParams.handler`. | -| CoWSwapEthFlow | `0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC` | Sepolia | EthFlow's production deployment; emits `OrderPlacement`. | -| GPv2Settlement | `0x9008D19f58AAbD9eD0D60971565AA8510560ab41` | Sepolia | `setPreSignature(orderUid, signed)` lives here. | -| GPv2VaultRelayer | `0xc92e8bdf79f0507f65a392b0ab4667716bfe0110` | Sepolia | Spender for sell-token ERC-20 approvals. | -| WETH9 | `0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14` | Sepolia | `deposit()` payable wraps ETH; `balanceOf(EOA)` is the sell-side balance. | -| COW Token | `0x0625aFB445C3B6B7B929342a04A22599fd5dBB59` | Sepolia | name="CoW Protocol Token", symbol="COW", decimals=18. | -| GPv2 domain separator | `0xdaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230b` | Sepolia | EIP-712 domain digest queried from chain. | - -All addresses verified via `eth_getCode > 0` on -`https://ethereum-sepolia-rpc.publicnode.com` as of run prep. - ---- - -## 1. Per-module config pinning - -### stop-loss - -`modules/examples/stop-loss/module.toml` is checked in on the -`feat/e2e-run-config-cow-1064` branch with the production-ready -config for this run. Effective values: - -| Field | Value | Notes | -|---|---|---| -| `oracle_address` | `0x694AA1769357215DE4FAC081bf1f309aDC325306` | Chainlink ETH/USD Sepolia. | -| `decimals` | `8` | Chainlink USD-pair convention. | -| `trigger_price` | `2000.00` | Above the live Sepolia mocked answer (~$1681), `direction=below` → triggers on first block. | -| `owner` | `0x7bF1...Ca6A` | Test EOA. | -| `sell_token` | `0xfFf9...6B14` | WETH9 Sepolia. | -| `buy_token` | `0x0625...BB59` | COW Sepolia. | -| `sell_amount_wei` | `5000000000000000` | 0.005 WETH. | -| `buy_amount_wei` | `20000000000000000000` | 20 COW. Conservative quote at run-prep time. | -| `valid_to_seconds` | `4294967295` | uint32::MAX. | - -### Resulting OrderUid - -The strategy's `build_creation` is pinned by the -`cow_1064_e2e_settings_yield_expected_uid` regression test -(`crates/.../stop-loss/src/strategy.rs`). The canonical UID: - -``` -0xc2b9cb4ea1ee5a86d8049ac09d8f494bf04cca0a68407285f31e2e6379800be87bf140727d27ea64b607e042f1225680b40eca6affffffff -``` - -Decomposition (per `packOrderUidParams`): - -| Offset | Bytes | Field | Value | -|---|---|---|---| -| 0..32 | 32 | `orderDigest` (EIP-712) | `0xc2b9cb4ea1ee5a86d8049ac09d8f494bf04cca0a68407285f31e2e6379800be8` | -| 32..52 | 20 | `owner` | `0x7bf140727d27ea64b607e042f1225680b40eca6a` | -| 52..56 | 4 | `validTo` (uint32) | `0xffffffff` | - -### balance-tracker - -Pinned to the EOA + Safe so the run sees ETH-balance diffs: - -| Field | Value | -|---|---| -| `addresses` | `0x7bF1...Ca6A,0x1499...53c2` | -| `change_threshold` | `1000000000000000` (0.001 ETH) | - ---- - -## 2. On-chain actions for the run window - -> Order: action 1 can be done at any time before/during the run. -> Actions 2-4 should fire **after** the engine prints -> `INFO supervisor ready modules=5 chains=1` so the modules -> observe the events. They are independent; do them in any order. - -### Action 1 (optional, pre-run): wrap 0.01 ETH → 0.01 WETH - -Without WETH, stop-loss will hit `TransferSimulationFailed` -> -`backoff:` write (which is itself a valid terminal-marker per -the acceptance bar). To get the **`submitted:`** path, -wrap first then do action 2. - -- Etherscan: https://sepolia.etherscan.io/address/0xfff9976782d46cc05630d1f6ebab18b2324d6b14#writeContract -- Connect Web3 from the EOA in Metamask -- Function `deposit` → payable value `0.01` ETH → Write - -Verify: `balanceOf(EOA)` returns `10000000000000000` post-tx. - -### Action 2 (optional, only if action 1 done): pre-sign stop-loss order - -- Etherscan: https://sepolia.etherscan.io/address/0x9008d19f58aabd9ed0d60971565aa8510560ab41#writeProxyContract -- Connect Web3 from the EOA -- Function `setPreSignature(bytes orderUid, bool signed)`: - - `orderUid`: - ``` - 0xc2b9cb4ea1ee5a86d8049ac09d8f494bf04cca0a68407285f31e2e6379800be87bf140727d27ea64b607e042f1225680b40eca6affffffff - ``` - - `signed`: `true` -- Write - -Also approve WETH → GPv2VaultRelayer so the settle path is real: - -- Etherscan: https://sepolia.etherscan.io/address/0xfff9976782d46cc05630d1f6ebab18b2324d6b14#writeContract -- Function `approve(address guy, uint256 wad)`: - - `guy`: `0xc92e8bdf79f0507f65a392b0ab4667716bfe0110` - - `wad`: `5000000000000000` (0.005 WETH — matches the order's sell_amount) -- Write - -### Action 3: TWAP conditional order via Safe TX Builder - -Triggers `ConditionalOrderCreated` → twap-monitor writes -`watch:{orderHash}`. The Safe pays the gas (~0.003 ETH); the -order will TRY to settle later but the Safe holds no WETH so -settlement will fail. **That's fine** — only the `create()` -event is required for the acceptance marker. - -- Safe app: https://app.safe.global/transactions/queue?safe=sep:0x14995a1118Caf95833e923faf8Dd155721cd53c2 -- New transaction → Transaction Builder -- Enter contract address: `0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74` -- Toggle "Use custom data (hex encoded)" ON -- Generate the calldata locally (do NOT paste a pinned blob): - -```bash -python3 scripts/_twap_calldata.py -``` - -The helper backdates `t0` by 60 s on every invocation so part 0 is -Ready immediately. The constants (sell/buy tokens, amounts, n, t, -salt) mirror section 4.2; edit there + in the helper in lockstep -if the TWAP shape changes. - -Copy the helper's stdout into the Transaction Builder's custom-data -field. The blob is ~516 bytes - the `create(ConditionalOrderParams, -bool dispatch)` call with a 2-part TWAP from WETH → COW, 0.001 WETH -per part, 600 s between parts, salt pinned to `0x...6670f000`. - -> Historical note: a previously-pinned variant of this calldata -> hardcoded `t0 = 0`, which silently produced an -> `AFTER_TWAP_FINISHED` revert on every poll because -> `calculateValidTo` divided `block.timestamp` by `t` and exceeded -> `n`. Surfaced in the 2026-06-18 dry run. Always derive -> via the helper. - -- ETH value: `0` -- Create batch → Send batch → sign with the EOA - -Expected log within 1-2 Sepolia blocks: - -``` -INFO twap-monitor watch:0x chain_id=11155111 -``` - -### Action 4: EthFlow swap via cow-swap UI - -Triggers `OrderPlacement` → ethflow-watcher writes -`submitted:{uid}` (or `dropped:{uid}` if the orderbook rejects; -both are valid terminal markers). - -Easiest path is the cow-swap UI: - -1. https://swap.cow.fi/#/11155111/swap/ETH/COW (Sepolia) -2. Connect Metamask, EOA selected, network=Sepolia -3. Sell amount: `0.005` ETH -4. Click "Swap" → it builds the EthFlow `createOrder` tx -5. Approve in Metamask - -The UI handles `quoteId` resolution + `appData` IPFS pinning + -EthFlow contract call. Sell amount is small enough to fit in the -~0.05 ETH budget plus gas. - -Expected log within 1-2 Sepolia blocks: - -``` -INFO ethflow-watcher submitted:0x -``` - -If the UI errors out (Sepolia orderbook can be flaky), fallback -to calling EthFlow directly via Etherscan: - -- https://sepolia.etherscan.io/address/0xba3cb449bd2b4adddbc894d8697f5170800eadec#writeContract -- Function `createOrder((address,address,uint256,uint256,bytes32,uint256,uint32,bool,int64))` -- The shape of the tuple needs the orderbook quote endpoint hit - first to get `feeAmount` + `quoteId` — easier to defer to the - UI for the run. - ---- - -## 3. Validation snippets for the operator - -Run these in a separate shell while the engine is up: - -```bash -RPC="wss://eth-sepolia.g.alchemy.com/v2/" # replace -EOA="0x7bF140727D27ea64b607E042f1225680B40ECa6A" -SAFE="0x14995a1118Caf95833e923faf8Dd155721cd53c2" -WETH="0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14" - -# EOA + Safe balances -cast balance $EOA --rpc-url $RPC -cast balance $SAFE --rpc-url $RPC - -# EOA WETH balance + GPv2VaultRelayer allowance -cast call $WETH "balanceOf(address)(uint256)" $EOA --rpc-url $RPC -cast call $WETH "allowance(address,address)(uint256)" \ - $EOA 0xc92e8bdf79f0507f65a392b0ab4667716bfe0110 --rpc-url $RPC - -# Did setPreSignature land? -cast call 0x9008D19f58AAbD9eD0D60971565AA8510560ab41 \ - "preSignature(bytes)(uint256)" \ - 0xc2b9cb4ea1ee5a86d8049ac09d8f494bf04cca0a68407285f31e2e6379800be87bf140727d27ea64b607e042f1225680b40eca6affffffff \ - --rpc-url $RPC -# Returns 1 if pre-signed, 0 otherwise. - -# Mine the supervisor log for terminal markers in real time -journalctl -u shepherd -f --output=json \ - | jq -r '.MESSAGE | fromjson? | select(.fields.message | test("watch:|submitted:|dropped:|backoff:|TRIGGERED")) | "\(.fields.module): \(.fields.message)"' -``` - -(If you don't have `cast` installed: `curl -L https://foundry.paradigm.xyz | bash && foundryup`.) - ---- - -## 4. Recipes for re-deriving the pinned values - -If anything in section 0 drifts, regenerate from these recipes. - -### 4.1 OrderUid - -Either: - -```bash -cargo test -p stop-loss --lib cow_1064 -- --nocapture -``` - -(asserts against the same constants pinned in `module.toml`, -fails loudly if the EIP-712 type-hash or domain separator -shifts). - -Or with raw Python: - -```python -from eth_utils import keccak - -# Replace these 8 values to re-derive -DOMAIN_SEP = bytes.fromhex("daee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230b") -SELL_TOKEN = bytes.fromhex("fFf9976782d46CC05630D1f6eBAb18b2324d6B14") -BUY_TOKEN = bytes.fromhex("0625aFB445C3B6B7B929342a04A22599fd5dBB59") -OWNER = bytes.fromhex("7bF140727D27ea64b607E042f1225680B40ECa6A") -RECEIVER = OWNER -SELL_AMOUNT = 5_000_000_000_000_000 -BUY_AMOUNT = 20_000_000_000_000_000_000 -VALID_TO = 4_294_967_295 - -APP_DATA = bytes.fromhex("b48d38f93eaa084033fc5970bf96e559c33c4cdc07d889ab00b4d63f9590739d") # keccak("{}") -KIND_SELL = keccak(b"sell") -ERC20 = keccak(b"erc20") -TYPE_HASH = keccak(b"Order(address sellToken,address buyToken,address receiver,uint256 sellAmount,uint256 buyAmount,uint32 validTo,bytes32 appData,uint256 feeAmount,string kind,bool partiallyFillable,string sellTokenBalance,string buyTokenBalance)") -pad32 = lambda b: bytes(32-len(b)) + b -uint = lambda v: v.to_bytes(32, "big") -struct_hash = keccak( - TYPE_HASH + pad32(SELL_TOKEN) + pad32(BUY_TOKEN) + pad32(RECEIVER) - + uint(SELL_AMOUNT) + uint(BUY_AMOUNT) + uint(VALID_TO) - + APP_DATA + uint(0) + KIND_SELL - + b"\x00"*32 + ERC20 + ERC20 # partiallyFillable=false -) -order_digest = keccak(b"\x19\x01" + DOMAIN_SEP + struct_hash) -uid = order_digest + OWNER + VALID_TO.to_bytes(4, "big") -print("0x" + uid.hex()) -``` - -### 4.2 ComposableCoW.create() calldata - -```python -import time -from eth_utils import keccak -from eth_abi import encode - -selector = keccak(b"create((address,bytes32,bytes),bool)")[:4] -# Edit these 10 fields to retarget the TWAP -static = encode( - ["(address,address,address,uint256,uint256,uint256,uint256,uint256,uint256,bytes32)"], - [( - "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14", # sellToken - "0x0625aFB445C3B6B7B929342a04A22599fd5dBB59", # buyToken - "0x14995a1118Caf95833e923faf8Dd155721cd53c2", # receiver - 1_000_000_000_000_000, 500_000_000_000_000_000, # partSellAmount, minPartLimit - int(time.time()) - 60, 2, 600, 0, # t0 (NEVER 0 - see note above), n, t, span - b"\x00" * 32, # appData - )] -) -calldata = selector + encode( - ["(address,bytes32,bytes)", "bool"], - [( - "0x6cF1e9cA41f7611dEf408122793c358a3d11E5a5", # TWAP handler - bytes.fromhex("000000000000000000000000000000000000000000000000000000006670f000"), # salt - static, - ), True] -) -print("0x" + calldata.hex()) -``` - ---- - -## 5. Acceptance checklist for THIS run - -Hand-check at the end of the run (also goes in -`e2e-report-YYYY-MM-DD.md` section 7): - -- [ ] EOA at `0x7bF1...Ca6A` still has ≥ 0.03 ETH remaining -- [ ] twap-monitor logged `watch:0x...` after action 3 -- [ ] ethflow-watcher logged `submitted:0x...` after action 4 -- [ ] stop-loss logged `backoff:` or `TRIGGERED + submitted:` (depending on whether action 1+2 ran) -- [ ] price-alert logged `TRIGGERED` on first block -- [ ] balance-tracker logged a `last:0x7bf1...` write on first block + at least one Warn diff log over the run window -- [ ] `shepherd_module_poisoned{...} == 0` for all 5 modules at end -- [ ] `shepherd_module_errors_total{error_kind="trap"} == 0` for all modules -- [ ] ≥ 1500 Sepolia blocks dispatched (`block delta` in report section 2) - -If all green the run is complete and the 7-day soak can start. diff --git a/docs/operations/e2e-reports/e2e-report-2026-06-18.md b/docs/operations/e2e-reports/e2e-report-2026-06-18.md deleted file mode 100644 index 9cc93774..00000000 --- a/docs/operations/e2e-reports/e2e-report-2026-06-18.md +++ /dev/null @@ -1,242 +0,0 @@ -# E2E testnet integration report — 2026-06-18 - -> Auto-generated by `scripts/e2e-report-gen.sh`. Operator -> review each section + flesh out anomalies + sign off in -> section 8 before committing. - -## 1. Run metadata - -| Field | Value | -|---|---| -| Start (UTC) | 2026-06-18T20:01:58Z | -| End (UTC) | 2026-06-18T21:25:36Z | -| Wall clock | 1h 23m | -| Engine commit | `cd68de0b4764b6836fe06ceb396e771cb7771468` | -| Engine config | `engine.e2e.local.toml` (rendered from `engine.e2e.toml`) | -| RPC provider | drpc.live (Sepolia WS) | -| Engine restarts | 2 (mid-run, to validate PR #47 — see §6.5) | -| Engine commits exercised | `5bcd47b` (pre-PR-47), `acc9654` (PR #47 twap-monitor), `cd68de0` (PR #47 ethflow-watcher) | - -## 2. Chain coverage - -| Chain | First block | Last block | Block delta | -|---|---|---|---| -| Sepolia (11155111) | 11089335 | 11089749 | 415 | - -Acceptance: block delta ≥ 1500 → **FAIL** - -## 3. On-chain actions submitted - -| Action | Tx | -|---|---| -| TWAP ComposableCoW.create() — script (t0=0 bug) | [0xa3d8a36f...4d02d](https://sepolia.etherscan.io/tx/0xa3d8a36f8a7dd8b097635ac59249b908d3f634bf5ede87c9336619e319e4d02d) | -| TWAP ComposableCoW.create() — cow-swap UI | [via UI; observed at block 11089497, indexed at 20:35:49Z, orderHash `0xc4bc4296...`](https://sepolia.etherscan.io/address/0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74) | -| EthFlow.createOrder() — script (empty appData) | [0x622375d8...5731](https://sepolia.etherscan.io/tx/0x622375d89119df6419324ad4e5603688261fb01a4d47d717d686b6dd426b5731) | -| EthFlow.createOrder() — cow-swap UI (rich appData) | [0x82da5ced...b878](https://sepolia.etherscan.io/tx/0x82da5ceda6e28337625a991d4fc7db6b82a1695012b58a6b660ec92b8a88b878) | -| WETH-to-Safe transfer + GPv2VaultRelayer approve | manual via Safe UI (see §6.5) | -| WETH9.deposit() / setPreSignature for stop-loss | _(not run — stop-loss `submitted:` produced via PreSign-orderbook-accept path, see §6.3)_ | - -## 4. Per-module terminal-state markers - -| Module | First marker | Sample line | -|---|---|---| -| twap-monitor | 2026-06-18T20:07:36.495145Z | `indexed watch:0x7bf140727d27ea64b607e042f1225680b40eca6a:0x2ef7e76456176904e518b068744aad0e97a0d6...` | -| ethflow-watcher | 2026-06-18T20:14:00.841145Z | `ethflow backoff 0x104f25a0d633f9f39840723fc7e72a87d327829c9bc541a08ad9c8a62b9ecc9eba3cb449bd2b4ad...` | -| price-alert | 2026-06-18T20:02:10.605669Z | `price-alert: TRIGGERED answer=169974867813 threshold=250000000000 (Below)` | -| balance-tracker | 2026-06-18T20:02:10.772149Z | `balance-tracker 0x7bf140727d27ea64b607e042f1225680b40eca6a changed +50581434977874097 wei (prior=...` | -| stop-loss | 2026-06-18T20:02:12.874405Z | `stop-loss retry on next block (0): orderbook error (DuplicatedOrder): order already exists` | - -## 5. Error counts (Prometheus delta) - -| Metric | Start | End | Delta | -|---|---|---|---| -| `shepherd_event_latency_seconds_count{module="balance-tracker",event_kind="block"}` | 17 | 33 | 16 | -| `shepherd_event_latency_seconds_count{module="ethflow-watcher",event_kind="log"}` | 0 | 1 | 1 | -| `shepherd_event_latency_seconds_count{module="price-alert",event_kind="block"}` | 17 | 33 | 16 | -| `shepherd_event_latency_seconds_count{module="stop-loss",event_kind="block"}` | 17 | 33 | 16 | -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="block"}` | 17 | 33 | 16 | -| `shepherd_event_latency_seconds_sum{module="balance-tracker",event_kind="block"}` | 5.38369 | 9.72033 | 4.33664 | -| `shepherd_event_latency_seconds_sum{module="ethflow-watcher",event_kind="log"}` | 0 | 0.442872 | 0.442872 | -| `shepherd_event_latency_seconds_sum{module="price-alert",event_kind="block"}` | 2.86219 | 5.03446 | 2.17227 | -| `shepherd_event_latency_seconds_sum{module="stop-loss",event_kind="block"}` | 18.835 | 27.4352 | 8.60022 | -| `shepherd_event_latency_seconds_sum{module="twap-monitor",event_kind="block"}` | 0.0018655 | 56.1652 | 56.1633 | -| `shepherd_event_latency_seconds{module="balance-tracker",event_kind="block",quantile="0"}` | 0.310814 | 0.271721 | -0.0390927 | -| `shepherd_event_latency_seconds{module="balance-tracker",event_kind="block",quantile="0.5"}` | 0.334306 | 0.272832 | -0.0614738 | -| `shepherd_event_latency_seconds{module="balance-tracker",event_kind="block",quantile="0.9"}` | 0.334306 | 0.282889 | -0.0514163 | -| `shepherd_event_latency_seconds{module="balance-tracker",event_kind="block",quantile="0.95"}` | 0.334306 | 0.282889 | -0.0514163 | -| `shepherd_event_latency_seconds{module="balance-tracker",event_kind="block",quantile="0.99"}` | 0.334306 | 0.282889 | -0.0514163 | -| `shepherd_event_latency_seconds{module="balance-tracker",event_kind="block",quantile="0.999"}` | 0.334306 | 0.282889 | -0.0514163 | -| `shepherd_event_latency_seconds{module="balance-tracker",event_kind="block",quantile="1"}` | 0.347925 | 0.322888 | -0.0250366 | -| `shepherd_event_latency_seconds{module="price-alert",event_kind="block",quantile="0"}` | 0.141162 | 0.130526 | -0.0106367 | -| `shepherd_event_latency_seconds{module="price-alert",event_kind="block",quantile="0.5"}` | 0.165117 | 0.152575 | -0.0125423 | -| `shepherd_event_latency_seconds{module="price-alert",event_kind="block",quantile="0.9"}` | 0.165117 | 0.152727 | -0.0123897 | -| `shepherd_event_latency_seconds{module="price-alert",event_kind="block",quantile="0.95"}` | 0.165117 | 0.152727 | -0.0123897 | -| `shepherd_event_latency_seconds{module="price-alert",event_kind="block",quantile="0.99"}` | 0.165117 | 0.152727 | -0.0123897 | -| `shepherd_event_latency_seconds{module="price-alert",event_kind="block",quantile="0.999"}` | 0.165117 | 0.152727 | -0.0123897 | -| `shepherd_event_latency_seconds{module="price-alert",event_kind="block",quantile="1"}` | 0.199031 | 0.170941 | -0.0280894 | -| `shepherd_event_latency_seconds{module="stop-loss",event_kind="block",quantile="0"}` | 0.731767 | 0.680018 | -0.051749 | -| `shepherd_event_latency_seconds{module="stop-loss",event_kind="block",quantile="0.5"}` | 0.899515 | 0.719139 | -0.180375 | -| `shepherd_event_latency_seconds{module="stop-loss",event_kind="block",quantile="0.9"}` | 1.3033 | 0.719139 | -0.584161 | -| `shepherd_event_latency_seconds{module="stop-loss",event_kind="block",quantile="0.95"}` | 1.3033 | 0.719139 | -0.584161 | -| `shepherd_event_latency_seconds{module="stop-loss",event_kind="block",quantile="0.99"}` | 1.3033 | 0.719139 | -0.584161 | -| `shepherd_event_latency_seconds{module="stop-loss",event_kind="block",quantile="0.999"}` | 1.3033 | 0.719139 | -0.584161 | -| `shepherd_event_latency_seconds{module="stop-loss",event_kind="block",quantile="1"}` | 1.56857 | 0.740204 | -0.828361 | -| `shepherd_event_latency_seconds{module="twap-monitor",event_kind="block",quantile="0"}` | 8.2e-05 | 0.86952 | 0.869438 | -| `shepherd_event_latency_seconds{module="twap-monitor",event_kind="block",quantile="0.5"}` | 0.000110411 | 1.35921 | 1.35909 | -| `shepherd_event_latency_seconds{module="twap-monitor",event_kind="block",quantile="0.9"}` | 0.000110411 | 1.49466 | 1.49455 | -| `shepherd_event_latency_seconds{module="twap-monitor",event_kind="block",quantile="0.95"}` | 0.000110411 | 1.49466 | 1.49455 | -| `shepherd_event_latency_seconds{module="twap-monitor",event_kind="block",quantile="0.99"}` | 0.000110411 | 1.49466 | 1.49455 | -| `shepherd_event_latency_seconds{module="twap-monitor",event_kind="block",quantile="0.999"}` | 0.000110411 | 1.49466 | 1.49455 | -| `shepherd_event_latency_seconds{module="twap-monitor",event_kind="block",quantile="1"}` | 0.000132833 | 1.94945 | 1.94932 | -| `shepherd_chain_request_total{chain_id="11155111",method="eth_call",outcome="err"}` | 0 | 33 | 33 | -| `shepherd_chain_request_total{chain_id="11155111",method="eth_call",outcome="ok"}` | 34 | 100 | 66 | -| `shepherd_chain_request_total{chain_id="11155111",method="eth_getBalance",outcome="ok"}` | 34 | 66 | 32 | -| `shepherd_cow_api_submit_total{chain_id="11155111",outcome="err"}` | 17 | 67 | 50 | - -## 6. Anomalies + defects - -Four anomalies surfaced by this run. Each was filed as a separate issue. - -### 6.1 SDK + modules: non-empty `appData` hash rejected client-side - -**Status: fixed in this run via PR #47, live-validated in §6.5.** - -`twap-monitor` and `ethflow-watcher` strategies hard-coded -`EMPTY_APP_DATA_JSON` when assembling `OrderCreation`. Any -order with a richer `appData` (cow-swap UI orders carry -partner-id + slippage + quote-id metadata) hit -"app_data JSON digest does not match signed app_data hash" -client-side and was silently skipped. - -Pre-PR-47 evidence (block 11089387, before mid-run restart): -``` -INFO twap-monitor poll watch:0x14995a...:0xc4bc4296... -> Ready -INFO twap-monitor twap submit skipped for 0x14995a1118caf95833e923faf8dd155721cd53c2: - invalid OrderCreation: app_data JSON digest does not match signed app_data hash -``` - -Post-PR-47 (validated in §6.5): the submit body builds with -the matching JSON resolved from `GET /api/v1/app_data/{hash}`, -reaches the orderbook server, and rejects only on -server-side reasons (`DuplicatedOrder` for TWAP, since the UI -already submitted; `ExcessiveValidTo` for EthFlow — see §6.2). - -### 6.2 ethflow-watcher: `ExcessiveValidTo` from Sepolia orderbook - -**Status: open.** - -EthFlow on-chain orders carry `validTo = type(uint32).max` so -cancellation is operator-controlled via the EthFlow contract, -not orderbook-time-bounded. The Sepolia orderbook has a -max-validTo cap that rejects this shape. - -Evidence: -``` -WARN ethflow backoff 0x6d296984...ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff - (0): orderbook error (ExcessiveValidTo): validTo is too far into the future -``` - -Last 4 bytes of UID = `ffffffff` = uint32::MAX. Pending -upstream investigation (Sepolia config drift vs mainnet -behaviour; needs cross-check before filing in -cowprotocol/services). - -### 6.3 stop-loss: `DuplicatedOrder` not classified as `Drop` - -**Status: open.** - -The stop-loss order from the E2E prep smoke (run earlier -on 2026-06-18) is still in the Sepolia orderbook (valid until -2106). The run-1 + run-2 stop-loss strategy re-submits the -same `OrderUid` on every block; orderbook responds -`DuplicatedOrder` (400); `shepherd_sdk::cow::classify_api_error` -maps to `TryNextBlock` and the retry loops forever (76 occurrences -in the first 170 blocks). - -Correct classification: `Drop` (the order is logically already -submitted; nothing to retry). PR sketch: -`crates/shepherd-sdk/src/cow/error.rs` `errorType` arm for -`DuplicatedOrder` → `RetryAction::Drop` + write -`submitted:{uid}` (or new `already-on-server:{uid}` marker). - -This run's stop-loss `submitted:` marker (via the PreSign- -upfront-accept path) was logged during the E2E prep smoke; -the marker persists in the orderbook and was observed -as `DuplicatedOrder` in this run. - -### 6.4 scripts/e2e-onchain.sh: TWAP `t0=0` produces permanently-finished order - -**Status: open.** - -`scripts/e2e-onchain.sh` hardcoded `t0=0` in the TWAP -`create()` calldata. TWAP `validateData` does NOT reject -t0=0 (only checks `t0 >= type(uint32).max`), so the create() -succeeds. But `TWAPOrderMathLib.calculateValidTo` computes -`part = (block.timestamp - 0) / t = ~3M`, which is `>= n=2`, -triggering `AFTER_TWAP_FINISHED` reverts on every -`getTradeableOrderWithSignature` poll. - -Evidence (custom error selector `0xc8fc2725` decoded): -``` -WARN twap-monitor eth_call failed (server returned an error response: - error code 3: execution reverted, data: "0xc8fc272500...616674657220747761702066696e6973686564" - [= ASCII "after twap finished"]) -``` - -Caller-side bug introduced by an AI-drafted helper. Fix is a -2-line edit to the encoder + a new comment; tracked separately. - -### 6.5 Live validation of PR #47 (this run's key methodology note) - -Mid-run, after observing §6.1, three engine binaries were -exercised back-to-back on the same `data/e2e` local-store -(restart preserved watches; no replay of past on-chain events -was needed — the indexed `watch:` keys in the redb survive -process restarts by design): - -| Engine commit | What it validates | -|---|---| -| `5bcd47b` (pre-PR-47) | Surfaces §6.1: twap-monitor + ethflow-watcher both log `submit skipped: digest does not match` for non-empty appData orders | -| `acc9654` (PR #47 twap-monitor) | After restart, the existing `watch:0x14995a...:0xc4bc4296...` (cow-swap UI TWAP) polled to Ready → resolve_app_data succeeded → submit reached orderbook → DuplicatedOrder (the order is already in the orderbook from the UI's original submission). **Client-side digest check was bypassed.** | -| `cd68de0` (PR #47 ethflow-watcher) | New cow-swap UI EthFlow swap submitted (tx `0x82da5ced...`); ethflow-watcher observes the OrderPlacement event with `order.appData = 0xe46e7d0c...` (NON-empty). resolve_app_data calls `GET /api/v1/app_data/0xe46e7d0c...` against the orderbook; orderbook returns `{"fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{...,\"quote\":{\"slippageBips\":857,\"smartSlippage\":true}},...}"}`. The SDK extracts `fullAppData`; build_eth_flow_creation produces a body with matching digest; submit reaches orderbook; rejects only on ExcessiveValidTo (§6.2). **Client-side digest check was bypassed for ethflow-watcher too.** | - -The PR #47 fix is therefore live-validated end-to-end against -the real Sepolia orderbook in **both** affected modules. -Section 7's `block delta ≥ 1500` row is the only acceptance -row that does not clear; the engine was restarted twice for -this validation, totalling 415 blocks across the three -generations. A continuous 5h run with PR #47 included from -boot is the natural validation for the 7-day soak -rather than re-running the E2E. - -## 7. Acceptance checklist - -- [ ] block delta ≥ 1500 (got 415) -- [x] all 5 modules emitted ≥ 1 terminal-state marker -- [x] shepherd_module_errors_total{error_kind="trap"} == 0 (offenders: none) -- [x] no module poisoned at end (offenders: none) -- [x] 0 ERROR lines from nexum_engine::* (got 0) -- [x] TWAP + EthFlow on-chain txs submitted - -## 8. Sign-off (operator) - -> Auto-generated report. Operator: in 1-2 sentences confirm whether this run is clean enough to unblock the 7-day soak. If any acceptance row above is `[ ]`, file the defect before signing off. - -**Bruno (operator)** — _pending sign-off_ - -Recommended sign-off text (delete + replace as appropriate): - -> "Run validated the engine + 5-module dispatch path end-to-end against -> live Sepolia. Surfaced 4 anomalies (described in §6); the appData -> issue was fixed in-run via PR #47 and live-validated for both -> twap-monitor and ethflow-watcher (§6.5). Block delta short (415/1500) -> only because the run included two intentional restarts to validate -> the in-flight PR. **The 7-day soak is unblocked** to start on -> PR #47 merged + `feat/e2e-run-config` branch state; the -> other three follow-ups do not block the soak." - -## 9. Attachments - -- Engine log: `engine-combined-20260618.log` -- Metrics start: `metrics-start-20260618T200158Z.txt` -- Metrics end: `metrics-end-20260618T212514Z.txt` diff --git a/docs/operations/e2e-reports/e2e-report.template.md b/docs/operations/e2e-reports/e2e-report.template.md index 632fdac1..3a63cd71 100644 --- a/docs/operations/e2e-reports/e2e-report.template.md +++ b/docs/operations/e2e-reports/e2e-report.template.md @@ -1,9 +1,6 @@ -# E2E testnet integration report — YYYY-MM-DD +# E2E testnet integration report: YYYY-MM-DD -> Copy this file to `e2e-report-YYYY-MM-DD.md` in the same directory -> at the start of the run and fill it in as the run progresses. -> Sections marked **(operator)** must be filled in manually; the rest -> are derived from logs and `/metrics` snapshots. +> Copy to `e2e-report-YYYY-MM-DD.md` in this directory at the start of the run and fill in as it progresses. Sections marked **(operator)** are manual; the rest derive from logs and `/metrics` snapshots. ## 1. Run metadata @@ -15,8 +12,8 @@ | Wall clock | Hh Mm | | Engine commit | (`git rev-parse HEAD`) | | Engine config | `engine.e2e.toml` | -| Run host | (e.g. `bruno@bleu-mbp-m1`, `ec2-...`) | -| RPC provider | (alchemy / infura / publicnode / ...) | +| Run host | | +| RPC provider | | ## 2. Chain coverage @@ -24,8 +21,7 @@ |---|---|---|---|---| | Sepolia (11155111) | | | | | -Target: `block delta >= 1500` to clear the acceptance bar -(>= 1500 Sepolia blocks ≈ 5 h at 12 s block time). +Target: `block delta >= 1500` (>= 5 h at 12 s block time). ## 3. On-chain actions submitted by operator @@ -57,13 +53,11 @@ Target: `block delta >= 1500` to clear the acceptance bar | `sell_token` allowance tx hash | 0x... | | Owner EOA | 0x... | | Expected UID | 0x... | -| Expected detection | stop-loss logs `submitted:{uid}` once oracle trips | +| Expected detection | stop-loss logs `submitted:{uid}` once the oracle trips | ## 4. Per-module terminal-state markers -> Pull from the engine log with the JSON filter -> `jq 'select(.fields.message | test("submitted:|dropped:|backoff:|TRIGGERED|trapped"))'`. -> Each module must show at least ONE marker for the acceptance bar. +> Pull from the log with `jq 'select(.fields.message | test("submitted:|dropped:|backoff:|TRIGGERED|trapped"))'`. Each module must show at least one marker. | Module | First marker timestamp | Marker | Sample line | |---|---|---|---| @@ -75,15 +69,13 @@ Target: `block delta >= 1500` to clear the acceptance bar ## 5. Error counts (from `/metrics` delta) -> Capture two snapshots: at boot (`/metrics > metrics-start.txt`) and -> immediately before shutdown (`/metrics > metrics-end.txt`). Fill in -> the delta column. +> Snapshot at boot and immediately before shutdown; fill the delta column. | Metric | Start | End | Delta | |---|---|---|---| | `shepherd_module_errors_total{module="...",error_kind="trap"}` (per module) | | | | | `shepherd_module_restarts_total{module="..."}` (per module) | | | | -| `shepherd_module_poisoned{module="..."}` (gauge, end-state per module) | n/a | | n/a | +| `shepherd_module_poisoned{module="..."}` (gauge, end-state) | n/a | | n/a | | `shepherd_cow_api_submit_total{outcome="ok"}` | | | | | `shepherd_cow_api_submit_total{outcome="err"}` | | | | | `shepherd_chain_request_total{outcome="ok"}` | | | | @@ -94,9 +86,7 @@ Target: `block delta >= 1500` to clear the acceptance bar ## 6. Anomalies + defects -> Anything outside the expected log shape. Each anomaly that is -> reproducible OR has an unclear root cause must be filed as a -> separate issue and linked here. +> Each reproducible or unexplained anomaly is filed as a separate issue and linked here. | # | Time (UTC) | Module | Summary | |---|---|---|---| @@ -104,29 +94,21 @@ Target: `block delta >= 1500` to clear the acceptance bar ## 7. Acceptance checklist -- [ ] `block delta >= 1500` (≥ 5 h coverage) -- [ ] All 5 modules have ≥ 1 terminal-state marker in section 4 +- [ ] `block delta >= 1500` +- [ ] All 5 modules have >= 1 terminal-state marker in section 4 - [ ] `shepherd_module_errors_total{error_kind="trap"}` for well-behaved modules == 0 - [ ] No `[[modules]]`-listed module is `shepherd_module_poisoned == 1` at end -- [ ] No `ERROR` lines from `nexum_engine` in the supervisor log -- [ ] At least one orderbook submit attempt landed (`ok` or typed - `err` with retry/drop classification) on twap-monitor, - ethflow-watcher, AND stop-loss +- [ ] No `ERROR` lines from `nexum_runtime` in the supervisor log +- [ ] At least one orderbook submit attempt landed on twap-monitor, ethflow-watcher, and stop-loss - [ ] Report committed in this directory - [ ] Defects filed and linked in section 6 ## 8. Sign-off (operator) -> Brief paragraph: ran clean / found N defects / blocking issues for -> soak Y/N. The soak MUST NOT start until this -> section says "no blocking issues". - -… +> Ran clean / found N defects / blocking issues for the soak Y/N. The soak must not start until this says "no blocking issues". ## 9. Attachments -- `engine.log` (full supervisor JSON log; ≥ 4 h) +- `engine.log` (full supervisor JSON log) - `metrics-start.txt` - `metrics-end.txt` -- (optional) `metrics-snapshots/` — every 60 s scrape if a soak-style - Prometheus pull was not running diff --git a/docs/operations/e2e-testnet-runbook.md b/docs/operations/e2e-testnet-runbook.md index 3054b372..a78b6b53 100644 --- a/docs/operations/e2e-testnet-runbook.md +++ b/docs/operations/e2e-testnet-runbook.md @@ -1,52 +1,24 @@ # E2E testnet runbook -How to exercise **all 5 modules** — twap-monitor, ethflow-watcher, -price-alert, balance-tracker, stop-loss — on a real Sepolia host -**simultaneously for 4-6 hours**. Same shape as the M2 + M3 -runbooks, but this one runs the full production module suite and -captures a structured report (`docs/operations/e2e-reports/`). - -The E2E run is the integration step between unit-test coverage -(MockHost, per-module strategy tests) and the 7-day soak. -The soak validates *stability*; this validates *correctness in a -live dispatch context* and surfaces cross-module bugs the soak -should not be discovering. - -The acceptance bar is: - -- ≥ 1500 Sepolia blocks (≈ 5 h at 12 s block time). -- Each of the 5 modules writes at least one terminal-state marker - (`submitted:` / `dropped:` / `backoff:` / `TRIGGERED` / `last:`). +Runs all 5 production modules (twap-monitor, ethflow-watcher, price-alert, balance-tracker, stop-loss) on a live Sepolia host simultaneously for 4-6 h and captures a structured report under `docs/operations/e2e-reports/`. This is the correctness step between unit-test coverage and the 7-day soak. + +Acceptance bar: + +- >= 1500 Sepolia blocks (~5 h at 12 s block time). +- Each of the 5 modules writes at least one terminal-state marker (`submitted:` / `dropped:` / `backoff:` / `TRIGGERED` / `last:`). - 0 unexpected errors in the supervisor log. - 0 well-behaved modules trapped or poisoned at end of run. -- A committed report + filed defects. - ---- +- A committed report. ## 0. Prerequisites ### Toolchain -Same as the M2 + M3 runbooks (`rustup target add wasm32-wasip2`, -optionally `just`, a Sepolia WS RPC). +Same as the M2 + M3 runbooks (`rustup target add wasm32-wasip2`, `just`, a Sepolia WS RPC). ### RPC -The public Sepolia node (`wss://ethereum-sepolia-rpc.publicnode.com`) -throttles `eth_subscribe` and `eth_call` under sustained load. The -E2E run does at minimum: - -- 1 block subscription (shared across 4 modules — price-alert, - balance-tracker, stop-loss, twap-monitor block-tick). -- 2 log subscriptions (twap-monitor's - `ComposableCoW.ConditionalOrderCreated` + ethflow-watcher's - `CoWSwapEthFlow.OrderPlacement`). -- ≥ 4 `eth_call` per block from price-alert + balance-tracker - (×2 addresses) + stop-loss, + 1 per registered TWAP order - per block. - -Override the `[chains.11155111] rpc_url` in `engine.e2e.toml` -with an Alchemy / Infura WS for the run: +The public Sepolia node throttles `eth_subscribe` and `eth_call` under sustained load. The run holds 1 block subscription (shared across 4 modules), 2 log subscriptions (twap-monitor `ConditionalOrderCreated`, ethflow-watcher `OrderPlacement`), and >= 4 `eth_call` per block. Override `rpc_url` in `engine.e2e.toml` with an Alchemy / Infura WS: ```toml [chains.11155111] @@ -55,277 +27,190 @@ rpc_url = "wss://eth-sepolia.g.alchemy.com/v2/" ### On-chain prep (operator) -The acceptance bar requires real on-chain submissions. Before -launching the run, prepare: - -1. **A funded test EOA on Sepolia** (≥ 0.05 ETH for gas; the same - EOA can satisfy the EthFlow swap + stop-loss `setPreSignature` - sub-tasks). -2. **A Safe (or direct caller) that can call ComposableCoW** on - Sepolia — for the TWAP conditional-order submission. -3. **stop-loss config aligned with that EOA**: update - `modules/examples/stop-loss/module.toml::[config].owner` to the - EOA address you control, and pick a `sell_token` / `buy_token` - pair the EOA holds + has approved to the GPv2VaultRelayer. - See `docs/operations/m3-testnet-runbook.md` section 2 for the - full pre-sign + allowance recipe. +The acceptance bar requires real on-chain submissions. Prepare: -The E2E run will start cleanly without (1)/(2)/(3), but the -acceptance bar requires at least one `submitted:` marker on each -of twap-monitor / ethflow-watcher / stop-loss, and you only get -those by triggering each path on-chain. +1. A funded test EOA on Sepolia (>= 0.05 ETH for gas; also covers the EthFlow swap + stop-loss `setPreSignature`). +2. A Safe (or direct caller) that can call ComposableCoW, for the TWAP conditional-order submission. +3. stop-loss config aligned with that EOA: set `[config].owner` in `modules/examples/stop-loss/module.toml` to the EOA, and pick a `sell_token` / `buy_token` pair the EOA holds and has approved to the GPv2VaultRelayer (M3 runbook section 2 has the pre-sign + allowance recipe). ---- +The run boots without (1)/(2)/(3), but the acceptance bar needs one `submitted:` marker on each of twap-monitor / ethflow-watcher / stop-loss, which only on-chain triggers produce. ## 1. Boot -The engine + all 5 modules + Prometheus `/metrics` endpoint: - ```bash just run-e2e ``` -Equivalent long form: +Long form: ```bash just build-e2e # builds the 5 module .wasm artefacts -cargo build -p nexum-cli -cargo run -p nexum-cli -- --engine-config engine.e2e.toml +cargo run -p shepherd -- --engine-config engine.e2e.toml ``` -### Expected boot sequence (~5 s) +Expected boot (~5 s) ends with: ``` -INFO nexum starting -INFO opening chain RPC provider chain_id=11155111 url="wss://..." INFO metrics exporter listening at /metrics addr=127.0.0.1:9100 -INFO loading module manifest manifest=modules/twap-monitor/module.toml -INFO compiling component component=...twap_monitor.wasm INFO init succeeded module=twap-monitor -INFO loading module manifest manifest=modules/ethflow-watcher/module.toml INFO init succeeded module=ethflow-watcher -INFO loading module manifest manifest=modules/examples/price-alert/module.toml INFO init succeeded module=price-alert -INFO loading module manifest manifest=modules/examples/balance-tracker/module.toml INFO init succeeded module=balance-tracker -INFO loading module manifest manifest=modules/examples/stop-loss/module.toml INFO init succeeded module=stop-loss -INFO supervisor up count=5 INFO supervisor ready modules=5 chains=1 -INFO block subscription open chain_id=11155111 INFO log subscription open chain_id=11155111 module=twap-monitor INFO log subscription open chain_id=11155111 module=ethflow-watcher ``` -If any of `count=5`, `modules=5`, or both log subscriptions are -missing, **stop the run and triage** — running 4-6 h on a -degraded engine wastes time the operator does not get back. - -### Smoke at first block (~12 s after boot) - -Within the first Sepolia block dispatched: +If `modules=5` or either log subscription is missing, stop the run and triage before committing to 4-6 h. -``` -DEBUG dispatch block chain_id=11155111 number=N -DEBUG chain::request method=eth_call # price-alert oracle read -DEBUG chain::request method=eth_getBalance # balance-tracker addr 1 -DEBUG chain::request method=eth_getBalance # balance-tracker addr 2 -DEBUG chain::request method=eth_call # stop-loss oracle read -WARN price-alert: TRIGGERED answer=... threshold=... -``` +## 2. The run -(See `docs/operations/m3-testnet-runbook.md` for the per-module -single-block expectations — the E2E run reproduces those plus -twap-monitor's empty poll loop until a `watch:` is registered.) - ---- - -## 2. The 4-6 h run - -### 2.1 Start the clock - -Pipe the engine output to a JSON log file the operator can mine -with `jq` after the run: +### 2.1 Start the clock and baseline ```bash just run-e2e 2>&1 | tee -a docs/operations/e2e-reports/engine-$(date -u +%Y%m%dT%H%M%SZ).log +curl -s http://127.0.0.1:9100/metrics > docs/operations/e2e-reports/metrics-start.txt ``` -Record `date -u --iso-8601=seconds` and `git rev-parse HEAD` in -section 1 of the report template. +Record `date -u --iso-8601=seconds` and `git rev-parse HEAD` in section 1 of the report. -### 2.2 Capture the metrics baseline +### 2.2 Trigger each on-chain action -```bash -curl -s http://127.0.0.1:9100/metrics > docs/operations/e2e-reports/metrics-start.txt -``` +Run as soon as the supervisor is `ready`: + +1. **TWAP order**: call ComposableCoW from the Safe. Within 1-2 blocks: `INFO twap-monitor watch:{orderHash}`. +2. **EthFlow swap**: execute a small ETH-flow swap from the EOA via the CoW Swap front-end on Sepolia. Within 1-2 blocks: `INFO ethflow-watcher submitted:{uid}` (or a typed `dropped:{uid}`, both terminal markers). +3. **stop-loss trigger**: once the owner EOA has called `setPreSignature` and approved the sell token, lower `trigger_price` in `modules/examples/stop-loss/module.toml` to <= the current Chainlink ETH/USD answer and reload. Within 1 block: `INFO stop-loss TRIGGERED` then `submitted:{uid}`. + +### 2.3 Idle until end of run -### 2.3 Trigger each on-chain action - -Run these as soon as the supervisor is `ready`: - -1. **TWAP order** — call ComposableCoW from your Safe (or directly - if you control the user). Within 1-2 blocks, twap-monitor logs: - ``` - INFO twap-monitor watch:{orderHash} chain_id=11155111 - ``` -2. **EthFlow swap** — execute a small ETH-flow swap from your EOA - via the cow-swap front-end pointed at Sepolia. Within 1-2 blocks - ethflow-watcher logs: - ``` - INFO ethflow-watcher submitted:{uid} - ``` - (or a typed `dropped:{uid}` if the orderbook rejected — both - count as a terminal-state marker for section 4.) -3. **stop-loss trigger** — once your owner EOA has called - `setPreSignature` and approved the sell token, lower - `trigger_price` in `modules/examples/stop-loss/module.toml` to - ≤ the current Sepolia Chainlink ETH/USD answer and reload the - engine (or set it pre-boot if you already know the feed value). - Within 1 block stop-loss logs: - ``` - INFO stop-loss TRIGGERED price=... trigger=... - INFO stop-loss submitted:{uid} - ``` - -### 2.4 Idle until end of run - -Once all three terminal markers are observed and the report's -section 4 has at least one entry per module, leave the engine -running undisturbed for the remainder of the 4-6 h window. - -The operator should watch for these red flags (if any appears, -the run is a defect and section 6 must capture it): +Once all three markers are observed, leave the engine undisturbed for the remainder of the window. Red flags (each is a defect for report section 6): | Red flag | Why it matters | |---|---| -| `ERROR` from `nexum_runtime::*` | Acceptance #5: zero ERROR lines. | -| `module ... trapped:` for a non-fixture module | Trapping production-side modules is a defect. | -| `module ... poisoned` | Quarantine of a real module is a defect. | -| `stream reconnect attempt=N` with N rising | The WS is flapping (RPC issue or bug). One reconnect per chain is fine. | -| `chain::request` `err` rate > 5% | The RPC is degraded. Switch keys / providers. | +| `ERROR` from `nexum_runtime::*` | Acceptance: zero ERROR lines | +| `module ... trapped:` for a non-fixture module | Trapping a production module is a defect | +| `module ... poisoned` | Quarantine of a real module is a defect | +| `stream reconnect attempt=N` with N rising | WS flapping. One reconnect per chain is fine | +| `chain::request` `err` rate > 5% | RPC degraded. Switch keys / providers | -### 2.5 Capture metrics deltas + shutdown - -At the end of the run window: +### 2.4 Capture deltas and shut down ```bash curl -s http://127.0.0.1:9100/metrics > docs/operations/e2e-reports/metrics-end.txt -# Ctrl-C the engine — graceful shutdown writes last_dispatched_block: -# > INFO graceful shutdown complete dispatched_blocks=N dispatched_logs=M uptime_secs=K -``` - -Diff the two snapshots to fill in the report's section 5: - -```bash +# Ctrl-C: graceful shutdown logs `dispatched_blocks=N dispatched_logs=M uptime_secs=K`. diff <(grep '^shepherd_' docs/operations/e2e-reports/metrics-start.txt) \ <(grep '^shepherd_' docs/operations/e2e-reports/metrics-end.txt) ``` ---- +## 3. Report -## 3. Filling in the report - -Copy the template at the start of the run: +Copy the template at the start of the run and fill it as the run progresses: ```bash DATE=$(date -u +%Y-%m-%d) -cp docs/operations/e2e-reports/e2e-report.template.md \ - docs/operations/e2e-reports/e2e-report-${DATE}.md -$EDITOR docs/operations/e2e-reports/e2e-report-${DATE}.md -``` - -Fill sections in this order: - -1. **Section 1 (run metadata)** at boot. -2. **Section 3 (on-chain actions)** as you submit each one. -3. **Section 4 (terminal markers)** as each first marker fires. -4. **Section 5 (metrics)** once `metrics-end.txt` is captured. -5. **Section 6 (anomalies)** continuously — anything unexpected - gets a row + an issue. -6. **Section 7 (acceptance checklist)** at the end — every box - must be `[x]` for the run to pass. -7. **Section 8 (sign-off)** is the gating decision for the - 7-day soak. - -Commit the filled-in report on the same branch as this runbook: - -```bash -git add docs/operations/e2e-reports/e2e-report-${DATE}.md -git commit -m "ops(e2e): report from ${DATE} run" -git push +cp docs/operations/e2e-reports/e2e-report.template.md docs/operations/e2e-reports/e2e-report-${DATE}.md ``` ---- - -## 4. What this does NOT prove - -- **Stability beyond ~5 h** → the 7-day soak (Sepolia + Arb Sepolia). -- **Adversarial resource exhaustion** → a fuel/memory adversarial fixtures run (M4 territory). -- **Security review** → tracked separately. -- **Production deployment story** → `docs/production.md`. -- **Multi-chain isolation under live WS drops** → partially - proven by integration tests; full validation - requires Arb Sepolia + Sepolia simultaneously, which the soak - exercises. - ---- +Every acceptance box in the template's section 7 must be `[x]` for the run to pass. Commit the filled report on this branch. -## 5. Troubleshooting - -Inherits the M2 + M3 runbook tables. E2E-specific: +## 4. Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| -| `supervisor ready modules=4 chains=1` (or less) at boot | One of the 5 module manifests failed to load — likely a missing wasm artefact under `target/wasm32-wasip2/release/` | Re-run `just build-e2e` and verify all 5 `.wasm` files are present. | -| `INFO log subscription open chain_id=11155111` appears only once | One of the two log-subscribing modules failed init | Check the immediately preceding `init failed module=...` line; the failing module's `[capabilities]` or subscription `address` is the usual culprit. | -| RPC drops every ~30 min on `publicnode.com` | Public node rate limits | Switch to Alchemy / Infura per section 0. | -| `stop-loss TRIGGERED` fires immediately on default config | Default `trigger_price = 2500.00` is above Sepolia Chainlink ETH/USD (~$1745) and `direction = "below"`. See M3 runbook §1. | Tune `trigger_price` lower to test the "silent until trigger" path. | -| `twap-monitor` never logs `watch:` | No `ConditionalOrderCreated` event observed on Sepolia during the window | Submit the TWAP order from section 2.3 step 1. | -| `ethflow-watcher` never logs `submitted:` | No `OrderPlacement` event observed on Sepolia during the window | Execute the EthFlow swap from section 2.3 step 2. | +| `supervisor ready modules=4 chains=1` at boot | A module manifest failed to load, likely a missing wasm artefact | Re-run `just build-e2e`; verify all 5 `.wasm` present | +| Only one `log subscription open` | One log-subscribing module failed init | Check the preceding `init failed module=...`; usual culprit is `[capabilities]` or the subscription `address` | +| RPC drops every ~30 min on `publicnode.com` | Public node rate limits | Switch to Alchemy / Infura | +| `stop-loss TRIGGERED` fires immediately | Default `trigger_price` above the feed with `direction = below` | Tune `trigger_price` lower | +| `twap-monitor` never logs `watch:` | No `ConditionalOrderCreated` observed | Submit the TWAP order (2.2 step 1) | +| `ethflow-watcher` never logs `submitted:` | No `OrderPlacement` observed | Execute the EthFlow swap (2.2 step 2) | ---- +## 5. Known Sepolia constraint: EthFlow `validTo = u32::MAX` -## 5.5. Known upstream constraints on Sepolia +EthFlow on-chain orders carry `validTo = type(uint32).max` by design (cancellation is operator-controlled via the EthFlow contract). The Sepolia orderbook's max-validTo cap rejects this shape with `errorType = "ExcessiveValidTo"`, so every EthFlow placement on Sepolia terminates as `Drop`. The keeper recognises this and degrades gracefully: -These are not bugs in shepherd; they are documented gaps between -the on-chain protocol and the Sepolia orderbook's validation -config. The strategy code recognises each and degrades gracefully -(Drop, not retry storm). The soak report should call them out so -the reader does not file them as anomalies. +- `ethflow dropped (400): orderbook error (ExcessiveValidTo)...` at Info level. +- `dropped:{uid}` written once per placement. +- `shepherd_cow_api_submit_total{outcome="err"}` grows by exactly the EthFlow placement count, then stops. -### EthFlow `validTo = u32::MAX` → `ExcessiveValidTo` +This is a testnet orderbook constraint, not a bug; the report should note it so it is not filed as an anomaly. -EthFlow on-chain orders carry `validTo = type(uint32).max` by -design: cancellation is operator-controlled via the EthFlow -contract, not orderbook-time-bounded. `cowprotocol::eth_flow` -documents this as the canonical CoW-side shape on every chain. +## 6. Re-deriving pinned values -The Sepolia orderbook's max-validTo cap rejects this shape with -`errorType = "ExcessiveValidTo"`. Every `POST /api/v1/orders` -ethflow-watcher forwards on Sepolia therefore terminates as -`Drop` (since the host fix; before that fix the same case -manifested as an infinite `backoff:` loop). +If the pinned identities in a run config drift, regenerate. -Operator-visible behaviour after the strategy refinement: +### OrderUid -- `ethflow dropped (400): orderbook error (ExcessiveValidTo)...` -- Log level: **Info** (not Warn). -- `dropped:{uid}` marker written exactly once per placement. -- The soak's Prometheus - `shepherd_cow_api_submit_total{outcome="err"}` curve grows by - exactly the EthFlow placement count, then stops. +```bash +cargo test -p stop-loss --lib cow_1064 -- --nocapture +``` -Upstream confirmation with the cowprotocol/services team is -pending; if mainnet also rejects this shape the design needs -revisiting at the contract level (which is out of scope for -shepherd). +Asserts against the constants in `module.toml`; fails loudly if the EIP-712 type-hash or domain separator shifts. Raw Python equivalent: + +```python +from eth_utils import keccak + +DOMAIN_SEP = bytes.fromhex("daee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230b") +SELL_TOKEN = bytes.fromhex("fFf9976782d46CC05630D1f6eBAb18b2324d6B14") +BUY_TOKEN = bytes.fromhex("0625aFB445C3B6B7B929342a04A22599fd5dBB59") +OWNER = bytes.fromhex("7bF140727D27ea64b607E042f1225680B40ECa6A") +RECEIVER = OWNER +SELL_AMOUNT = 5_000_000_000_000_000 +BUY_AMOUNT = 20_000_000_000_000_000_000 +VALID_TO = 4_294_967_295 + +APP_DATA = bytes.fromhex("b48d38f93eaa084033fc5970bf96e559c33c4cdc07d889ab00b4d63f9590739d") # keccak("{}") +KIND_SELL = keccak(b"sell") +ERC20 = keccak(b"erc20") +TYPE_HASH = keccak(b"Order(address sellToken,address buyToken,address receiver,uint256 sellAmount,uint256 buyAmount,uint32 validTo,bytes32 appData,uint256 feeAmount,string kind,bool partiallyFillable,string sellTokenBalance,string buyTokenBalance)") +pad32 = lambda b: bytes(32-len(b)) + b +uint = lambda v: v.to_bytes(32, "big") +struct_hash = keccak( + TYPE_HASH + pad32(SELL_TOKEN) + pad32(BUY_TOKEN) + pad32(RECEIVER) + + uint(SELL_AMOUNT) + uint(BUY_AMOUNT) + uint(VALID_TO) + + APP_DATA + uint(0) + KIND_SELL + + b"\x00"*32 + ERC20 + ERC20 # partiallyFillable=false +) +order_digest = keccak(b"\x19\x01" + DOMAIN_SEP + struct_hash) +uid = order_digest + OWNER + VALID_TO.to_bytes(4, "big") +print("0x" + uid.hex()) +``` ---- +### ComposableCoW.create() calldata + +Generate locally with `python3 scripts/_twap_calldata.py` (never paste a pinned blob). The helper backdates `t0` by 60 s per invocation so part 0 is Ready immediately; `t0` must never be 0 or every poll reverts `AFTER_TWAP_FINISHED`. Constants: + +```python +import time +from eth_utils import keccak +from eth_abi import encode + +selector = keccak(b"create((address,bytes32,bytes),bool)")[:4] +static = encode( + ["(address,address,address,uint256,uint256,uint256,uint256,uint256,uint256,bytes32)"], + [( + "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14", # sellToken + "0x0625aFB445C3B6B7B929342a04A22599fd5dBB59", # buyToken + "0x14995a1118Caf95833e923faf8Dd155721cd53c2", # receiver + 1_000_000_000_000_000, 500_000_000_000_000_000, # partSellAmount, minPartLimit + int(time.time()) - 60, 2, 600, 0, # t0 (never 0), n, t, span + b"\x00" * 32, # appData + )] +) +calldata = selector + encode( + ["(address,bytes32,bytes)", "bool"], + [( + "0x6cF1e9cA41f7611dEf408122793c358a3d11E5a5", # TWAP handler + bytes.fromhex("000000000000000000000000000000000000000000000000000000006670f000"), # salt + static, + ), True] +) +print("0x" + calldata.hex()) +``` -## 6. References +## 7. References -- M2 runbook (sister doc): `docs/operations/m2-testnet-runbook.md` -- M3 runbook (sister doc): `docs/operations/m3-testnet-runbook.md` +- M2 + M3 runbooks (sister docs) - Engine config: `engine.e2e.toml` - Report template: `docs/operations/e2e-reports/e2e-report.template.md` diff --git a/docs/operations/load-reports/load-20x20-2026-06-19.md b/docs/operations/load-reports/load-20x20-2026-06-19.md deleted file mode 100644 index 9495fe36..00000000 --- a/docs/operations/load-reports/load-20x20-2026-06-19.md +++ /dev/null @@ -1,84 +0,0 @@ -# Load test report - medium 20x20 - -## 1. Run metadata - -| Field | Value | -|---|---| -| Stamp (UTC) | 2026-06-19T16:03:24Z | -| Wall clock | 120 s (2 min) | -| Engine commit | `feat/load-gen-calibration` head | -| Anvil command | `anvil --fork-url $RPC_URL_SEPOLIA_HTTP --port 8545 --block-time 1` | -| Mock orderbook | `tools/orderbook-mock --port 9999` | -| Modules | `twap-monitor`, `ethflow-watcher` | -| Scenario | medium (20 TWAP + 20 EthFlow per block, 2 min) | - -## 2. Load generator output - -``` -load-gen finished blocks_seen=14 - twap_attempted=280 twap_ok=280 - ethflow_attempted=280 ethflow_ok=280 -``` - -The `blocks_seen=14` is load-gen's perspective - it processes the next block only after finishing the previous burst of 40 submissions. Anvil itself mined **128 blocks** during the run (per `shepherd_event_latency_seconds_count{event_kind="block"}`), so shepherd's supervisor fired 128 block-dispatch cycles. - -## 3. Engine throughput - -| Metric | Delta | Notes | -|---|---|---| -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="block"}` | **128** | One per Anvil block. | -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="log"}` | **280** | 1:1 with load-gen. | -| `shepherd_event_latency_seconds_count{module="ethflow-watcher",event_kind="log"}` | **280** | 1:1 with load-gen. | -| `shepherd_cow_api_submit_total{outcome="ok"}` | **280** | All EthFlow submissions reached the mock orderbook successfully. | -| `shepherd_cow_api_submit_total{outcome="err"}` | **0** | Zero. | -| `shepherd_chain_request_total{method="eth_call",outcome="err"}` | **18 442** | Watch polls reverting (no settle-time allowance); strategy correctly classifies as TryNextBlock. | -| `shepherd_module_errors_total` | **0** | Zero. | - -### Latency - -**twap-monitor block (poll loop over all 280 active watches):** - -| Quantile | Value | -|---|---| -| p50 | 56 ms | -| p95 | 66 ms | -| p99 | 67 ms | -| max | 67 ms | - -**ethflow-watcher log:** p50/p95/p99 = 8 / 9.5 / 12 ms. - -Engine-log-derived dispatch_block max: 471 ms (cold-start outlier, same pattern as the baseline). - -## 4. Mock orderbook stats - -``` -submits_ok = 280 -submits_err = 0 -app_data_lookups = 0 -``` - -## 5. Acceptance vs. medium bar - -| Criterion | Observed | Pass? | -|---|---|---| -| 20 TWAP + 20 EthFlow events delivered per load-gen iteration | 280 + 280 across 14 iterations = exactly 20 per iteration | **PASS** | -| Graceful degradation (`backoff:` markers OK; `shepherd_module_errors_total = 0`) | zero module_errors_total | **PASS** | -| `cow_api_submit{outcome="err"}` stays 0 | zero | **PASS** | -| Zero traps | zero | **PASS** | -| p99 < 2 s (informal carry-over from baseline) | TWAP block p99 = 67 ms | **PASS** (30x margin) | - -**Medium: full PASS.** - -## 6. Scaling observation - -Compared to the baseline (130 watches → 49 ms p99) the medium run holds 280 watches → 67 ms p99 - **sub-linear growth** in dispatch latency, not the strict linear scaling extrapolated earlier. Encouraging signal for the saturation scenario. - -## 7. Attachments - -- Metrics start: `/tmp/shepherd-load/metrics-start-20260619T160324Z.txt` -- Metrics end: `/tmp/shepherd-load/metrics-end-20260619T160324Z.txt` -- Engine + load-gen logs under `/tmp/shepherd-load/`. - -## 8. Sign-off - -**Bruno (operator) - PASS, medium.** Engine handles 20+20 events per load-gen iteration with the same 30x latency margin as baseline, zero errors. Saturation scenario unblocked. diff --git a/docs/operations/load-reports/load-50x50-2026-06-19.md b/docs/operations/load-reports/load-50x50-2026-06-19.md deleted file mode 100644 index 3a2266ff..00000000 --- a/docs/operations/load-reports/load-50x50-2026-06-19.md +++ /dev/null @@ -1,113 +0,0 @@ -# Load test report - saturation 50x50 - -## 1. Run metadata - -| Field | Value | -|---|---| -| Stamp (UTC) | 2026-06-19T16:08:51Z | -| Wall clock | 120 s (2 min) | -| Engine commit | `feat/load-gen-calibration` head | -| Anvil command | `anvil --fork-url $RPC_URL_SEPOLIA_HTTP --port 8545 --block-time 1` | -| Mock orderbook | `tools/orderbook-mock --port 9999` | -| Modules | `twap-monitor`, `ethflow-watcher` | -| Scenario | saturation (50 TWAP + 50 EthFlow per block, 2 min) | - -## 2. Load generator output - -``` -load-gen finished blocks_seen=6 - twap_attempted=300 twap_ok=300 - ethflow_attempted=300 ethflow_ok=300 -``` - -300 + 300 events delivered. Anvil mined **138 blocks** during the -run (per `shepherd_event_latency_seconds_count{event_kind="block"}`) -- the load-gen's `blocks_seen=6` is its own perspective (the burst of -100 sequential tx submissions per iteration takes ~20 s per round, -so it only processes 6 block-tick events from the WS subscription -during the 120 s window). - -## 3. Engine throughput - -| Metric | Delta | Notes | -|---|---|---| -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="block"}` | **138** | One per Anvil block. | -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="log"}` | **300** | 1:1 with load-gen. | -| `shepherd_event_latency_seconds_count{module="ethflow-watcher",event_kind="log"}` | **300** | 1:1 with load-gen. | -| `shepherd_cow_api_submit_total{outcome="ok"}` | **300** | All EthFlow submissions reached the mock orderbook successfully. | -| `shepherd_cow_api_submit_total{outcome="err"}` | **0** | Zero. | -| `shepherd_chain_request_total{method="eth_call",outcome="err"}` | **22 137** | Watch polls (300 watches × ~74 blocks). | -| `shepherd_module_errors_total` | **0** | Zero. | - -### Latency - -**twap-monitor block (poll loop over all 300 active watches):** - -| Quantile | Value | -|---|---| -| p50 | 67 ms | -| p95 | 76 ms | -| p99 | 78 ms | -| max | 88 ms | - -**ethflow-watcher log:** p50/p95/p99 = 8.0 / 8.1 / 8.9 ms - basically flat vs. baseline. - -**twap-monitor log:** p50/p95/p99 = 4.0 / 5.9 / 7.0 ms. - -Engine-log-derived dispatch_block max: 497 ms (cold-start outlier, same pattern). - -## 4. Mock orderbook stats - -``` -submits_ok = 300 -submits_err = 0 -app_data_lookups = 0 -``` - -## 5. Acceptance vs. saturation bar - -| Criterion | Observed | Pass? | -|---|---|---| -| 50 TWAP + 50 EthFlow events delivered per load-gen iteration | 300 + 300 across 6 iterations = exactly 50 per iteration | **PASS** | -| Identify the bottleneck | **Bottleneck is on the load-gen side, not the engine** - see §6 | (informative) | -| `shepherd_module_errors_total = 0` | zero | **PASS** | -| Zero traps | zero | **PASS** | - -**Saturation: PASS - and the test did NOT saturate the engine.** - -## 6. The unexpected finding: engine did not saturate - -The hypothesis going in (informed by lgahdl's PR #9 thread on sequential per-module dispatch) was that 50x50 would push the supervisor past its single-module dispatch budget and surface a per-block latency outlier or a backlog. None of that happened: - -- TWAP block p99 grew from 49 ms (130 watches, baseline) to **78 ms (300 watches, saturation)** - **sub-linear growth.** -- EthFlow log p99 held at **8.9 ms** across all three scenarios - the submit-to-mock round-trip is dominated by the network hop, not engine bookkeeping. -- Zero `shepherd_module_errors_total`, zero traps, zero backoff: markers. -- The cold-start outlier (~500 ms on the first watch-heavy block) is consistent across runs and does not scale with the watch count - it's a one-shot first-block redb / eth_call warmup cost. - -**Actual bottleneck:** load-gen's sequential `eth_sendTransaction` submission. At 100 tx/iteration (50+50) and ~200 ms per submission roundtrip, each iteration takes ~20 s, vs. Anvil's 1 s block time. So the load-gen processes 6 block-events of its own but Anvil mines 138 blocks during the same window. The engine handles those 138 dispatch cycles cleanly. - -### Implications - -1. **lgahdl's sequential-dispatch concern**: not surfaced at this scale (300 watches, 138 dispatch cycles in 2 min). To genuinely test it would require an order of magnitude more watches (3 000 - 10 000) or parallel load generators. -2. **What this proves**: shepherd's M4 supervisor handles **at least 300 concurrent watches and 138 block-dispatch cycles in 2 min** with p99 < 80 ms and zero errors. -3. **What it does NOT prove**: behaviour at 3 000+ watches, behaviour under real-network RPC variability, behaviour over 7 days (the 7-day soak's actual job). - -## 7. Followups - -The bottleneck shifted from engine to load-gen; to actually saturate the engine, future iterations should: - -1. **Run multiple load-gens in parallel**, each impersonating a different EOA (Anvil supports arbitrary impersonation), so the per-EOA nonce serialisation does not gate the throughput. -2. **Use a smaller `--block-time`** (e.g. 100 ms) so blocks emit faster and the engine has to handle more dispatch cycles per second. -3. **Pre-seed thousands of watches via direct redb writes** before starting the dispatch loop, then run a small steady-state load - this isolates dispatch cost from indexing cost. - -These are not blocking for the acceptance sign-off; this is a saturation **target**, not a saturation **failure**. The acceptance bar ("identify the bottleneck") is met: bottleneck identified, on the test-tool side, not the engine side. - -## 8. Attachments - -- Metrics start: `/tmp/shepherd-load/metrics-start-20260619T160851Z.txt` -- Metrics end: `/tmp/shepherd-load/metrics-end-20260619T160851Z.txt` -- Engine + load-gen logs under `/tmp/shepherd-load/`. - -## 9. Sign-off - -**Bruno (operator) - PASS, saturation.** Engine handles 50+50 events per load-gen iteration without flinching. Bottleneck is the test tool, not the engine. The three-scenario acceptance sweep is complete. diff --git a/docs/operations/load-reports/load-50x50-parallel-2026-06-19.md b/docs/operations/load-reports/load-50x50-parallel-2026-06-19.md deleted file mode 100644 index afb3665a..00000000 --- a/docs/operations/load-reports/load-50x50-parallel-2026-06-19.md +++ /dev/null @@ -1,148 +0,0 @@ -# Load test report - aggressive saturation (10 workers, 0.5s blocks) - -> The saturation push the prior `load-50x50` report flagged as "engine -> did not saturate, the bottleneck is on the load-gen side". This run -> removes both load-gen-side limits and finds the engine's actual -> saturation knee. - -## 1. Run metadata - -| Field | Value | -|---|---| -| Stamp (UTC) | 2026-06-19T17:05:40Z | -| Wall clock | 120 s (2 min) | -| Engine commit | `feat/load-gen-calibration` head | -| Anvil command | `anvil --fork-url $RPC_URL_SEPOLIA_HTTP --port 8545 --block-time 0.5` | -| Mock orderbook | `tools/orderbook-mock --port 9999` | -| Modules | `twap-monitor`, `ethflow-watcher` | -| Scenario | saturation-parallel (10 workers × (5 TWAP + 5 EthFlow) per block, `--block-time 0.5`, 2 min) | -| load-gen flags | `--parallel 10 --twap-per-block 5 --ethflow-per-block 5 --block-time 0.5 --duration-min 2` | - -The parallel-mode flag is new: each worker impersonates its own synthetic EOA (`0x57...01` … `0x57...0a`), has its own WS connection + nonce stream, runs its own per-block submission loop. Removes the per-EOA nonce serialisation bottleneck the single-worker saturation report (`load-50x50-2026-06-19.md`) identified. - -## 2. Load generator output - -``` -load-gen finished workers_finished=10 blocks_seen=179 - twap_attempted=895 twap_ok=895 - ethflow_attempted=895 ethflow_ok=895 -``` - -895 TWAP + 895 EthFlow `eth_sendTransaction` acks across 10 workers; zero load-gen-side errors (the first attempt at this run had a sellAmount-overflow bug that blew past the EOA's 1M ETH balance; fixed by namespacing `ethflow_seq` to a 10 000-wide per-worker window). - -## 3. Engine throughput - the saturation signal - -| Metric | Delta | Notes | -|---|---|---| -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="block"}` | **110** | Block events dispatched. With `--block-time 0.5` we expected ~240; the engine saw 110 - **the block stream itself dropped under load**, see §4. | -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="log"}` | **381** | `ConditionalOrderCreated` events delivered. load-gen submitted 895 → only **43%** reached the engine. | -| `shepherd_event_latency_seconds_count{module="ethflow-watcher",event_kind="log"}` | **343** | load-gen submitted 895 → **38%** reached the engine. | -| `shepherd_cow_api_submit_total{outcome="ok"}` | **343** | Matches EthFlow events 1:1 - the engine submitted every event it saw. | -| `shepherd_cow_api_submit_total{outcome="err"}` | **0** | Zero submit errors. | -| `shepherd_chain_request_total{method="eth_call",outcome="err"}` | **31 097** | Watch polls (381 watches × ~80 effective dispatch cycles). | -| `shepherd_module_errors_total` | **0** | Engine never traps. | - -### Latency (Prometheus histogram) - -**twap-monitor block dispatch:** - -| Quantile | Value | -|---|---| -| p50 | **145 ms** | -| p95 | 145 ms | -| p99 | 145 ms | -| **max** | **101 593 ms** ≈ 101 s | - -(The histogram bucketing collapses p50-p99 to the same value because the sample is sparse + bucket-bounded; the `max` is the meaningful upper tail.) - -Engine-log-derived dispatch_block (more granular): -- n = 586 dispatches -- p50 = 4 ms -- p95 = 46 ms -- p99 = 74 ms -- **max = 101 593 ms** (the same 101-second outlier the histogram caught) - -**twap-monitor log + ethflow-watcher log:** histogram-buckets to 0 across all quantiles - per-event indexing + submit completed in < 1 ms even at the peak. The slow path is the watch-polling loop, NOT the indexing or submit. - -## 4. Saturation knee identified - -Two distinct signals - both new vs. the earlier 50×50 run: - -### 4.1 Engine dispatch outlier: 101 s on a single block - -In the prior runs (130 / 280 / 300 watches), the dispatch_block max was bounded between 50 ms and 88 ms steady-state (plus a ~500 ms cold-start outlier on the first watch-heavy block). This run, with 381 active watches and a 0.5 s block time, hit **a 101-second dispatch on at least one block**. That is 200× the prior worst case. - -The likely chain: a 0.5 s block cadence + 381 watches × per-watch `eth_call` against the TWAP handler + 10 parallel WS connections producing log events concurrently → either Anvil's serialised JSON-RPC handling backs up (most likely), the engine's redb writes block, or the per-module dispatch hits a worst-case queue contention. - -Distinguishing among these is the natural follow-up. For the saturation sign-off the headline matters: **the engine has a saturation knee**, it reaches it at ~380 active watches + 10 parallel submitters + 0.5 s block-time on a M-class laptop, and even at that knee it sustains 343 EthFlow round-trips end-to-end + 31 097 `eth_call` polls without producing a single `shepherd_module_errors_total`, `trap`, or `poison`. - -### 4.2 Event-delivery loss: 38-43% of load-gen events never reached the engine - -- 895 TWAP txs → 381 `ConditionalOrderCreated` events delivered. -- 895 EthFlow txs → 343 `OrderPlacement` events delivered. - -That is **a 57-62% drop rate** between the load-gen's `eth_sendTransaction` ack and shepherd's WS subscription. Three plausible causes: - -1. **Anvil's WS subscription buffer overflows** under 10 concurrent connections × 0.5 s block × 10+ log events per block. Anvil is not built for this kind of subscriber load. -2. **Alloy's pubsub client drops events** when its internal channel fills (we DID see "Pubsub service request channel closed" lines in the load-gen output - some workers' WS connections dropped before the 2-min deadline). -3. **Anvil includes only a subset of mempool txs in each block** when the mempool grows faster than the miner can drain (gas-limit-bound or mempool-eviction). - -The block-event drop signal (engine saw 110 of an expected ~240 blocks) is consistent with #1 + #2. - -### 4.3 Engine health under saturation - -Despite the 101 s dispatch outlier and the event-drop ratio: - -- ✓ Zero `shepherd_module_errors_total`. -- ✓ Zero traps. Zero poisoned modules. -- ✓ Every event the engine **did** see was dispatched and submitted: 343 EthFlow → 343 mock orderbook hits, 1:1. -- ✓ One log-side ERROR line, which is the post-teardown WS reset (same as every prior run). - -Shepherd's failure mode under saturation is **graceful degradation, not breakage**. It processes events more slowly when the surrounding system (Anvil + WS transport) cannot keep up; it does not corrupt state, drop events on its own, or kill modules. - -## 5. Comparison across the four saturation runs - -| Scenario | Workers | Block-time | Watches | TWAP block p99 | Engine errors | -|---|---|---|---|---|---| -| baseline 5×5 | 1 | 1 s | 130 | 49 ms | 0 | -| medium 20×20 | 1 | 1 s | 280 | 67 ms | 0 | -| saturation 50×50 | 1 | 1 s | 300 | 78 ms | 0 | -| **saturation-parallel** | **10** | **0.5 s** | **381** | **74 ms (log) / 101 s (max)** | **0** | - -The watch-count grew only modestly (300 → 381), but the surrounding stress (10 connections, 2× block rate) is where the new pressure came from. **The engine itself still scales sub-linearly with watch count - the 101 s outlier is correlated with Anvil + WS, not with watch count.** - -## 6. Bottleneck identified - -In order of severity: - -1. **Anvil + alloy WS subscription** chokes under 10 concurrent subscribers × 0.5 s block cadence. Event-drop ratio 57-62%. -2. **Engine dispatch** has rare worst-case 100-second outliers when polling 380+ watches against a stressed JSON-RPC backend. The dispatch itself is fine; it is waiting on synchronous `eth_call` responses that Anvil cannot serve fast enough. -3. **load-gen** is no longer the bottleneck (was in the prior run). 10 workers in parallel sustain 895 + 895 acks per 2 min. - -For the 7-day soak: this matters because Sepolia's public RPC is closer in shape to Anvil-under-pressure than to a dedicated archive node. The soak should use Alchemy/drpc/QuickNode paid endpoints, not publicnode, OR accept that some event drops will happen and rely on the `eth_getLogs` re-indexing on reconnect. - -## 7. Acceptance - -The saturation scenario's acceptance bar is "identify the bottleneck". Identified: - -1. Engine survives 380+ concurrent watches with zero errors. -2. The dispatch p99 outlier (101 s) at peak load is a **surrounding-system** symptom (Anvil + WS), not an engine bug. -3. 57-62% of upstream events are dropped before they reach the engine under this configuration - **operator must use a faster RPC than publicnode for the 7-day soak**. - -**Saturation-parallel: PASS with caveats** - engine acceptance criteria met; the test surfaces the surrounding infrastructure as the next limiting factor. - -## 8. Followups - -1. **Re-run with a paid Sepolia archive endpoint** (Alchemy / drpc / QuickNode) and confirm the event-drop ratio falls below 5%. This is mostly a one-liner in `scripts/.env`. -2. **Re-run with `anvil --no-mining` + explicit `evm_mine` calls** to remove the timing race entirely. Each block can be packed with N+M txs deterministically. -3. **redb pre-seed** (option 3 from the load-test follow-up list) - bypass `create()` entirely, write 3 000+ watch entries directly to the local-store before engine boot. Isolates "watch-count → dispatch cost" scaling perfectly. Not blocking for this acceptance sign-off. - -## 9. Attachments - -- Metrics start: `/tmp/shepherd-load/metrics-start-20260619T170540Z.txt` -- Metrics end: `/tmp/shepherd-load/metrics-end-20260619T170540Z.txt` -- Engine + load-gen logs under `/tmp/shepherd-load/`. - -## 10. Sign-off - -**Bruno (operator) - PASS, saturation-parallel.** Engine survives the heaviest load we could synthesise without breaking. The saturation knee is real (101 s dispatch outlier, 38-43% event delivery) but the symptoms point at Anvil + WS, not at shepherd. Engine continues to scale sub-linearly with watch count and never produces a `module_errors_total`, trap, or panic. diff --git a/docs/operations/load-reports/load-5x5-2026-06-19.md b/docs/operations/load-reports/load-5x5-2026-06-19.md deleted file mode 100644 index 5e4f9095..00000000 --- a/docs/operations/load-reports/load-5x5-2026-06-19.md +++ /dev/null @@ -1,161 +0,0 @@ -# Load test report — baseline 5×5 - -> Second baseline run on 2026-06-19 after the load-gen calibration landed. -> Supersedes the conditional-pass first run recorded earlier today. - -## 1. Run metadata - -| Field | Value | -|---|---| -| Stamp (UTC) | 2026-06-19T14:48:46Z | -| Wall clock | 60 s | -| Engine commit | `feat/load-gen-calibration` head | -| Engine config | `engine.load.toml` (state_dir=./data/load wiped per run) | -| Anvil command | `anvil --fork-url $RPC_URL_SEPOLIA_HTTP --port 8545 --block-time 1` | -| Sepolia archive | `https://ethereum-sepolia-rpc.publicnode.com` | -| Mock orderbook | `tools/orderbook-mock --port 9999` (no latency, no errors) | -| Modules | `twap-monitor`, `ethflow-watcher` | -| Scenario | baseline (5 TWAP + 5 EthFlow per block, 1 min) | - -## 2. Load generator output - -``` -load-gen finished blocks_seen=26 - twap_attempted=130 twap_ok=130 - ethflow_attempted=130 ethflow_ok=130 -``` - -130 `ComposableCoW.create(...)` + 130 `CoWSwapEthFlow.createOrder(...)` -delivered across 26 Anvil blocks. Counters now reflect *delivered -events* because the load-gen calibration removed the nonce-race + -EthFlow OrderUid dedup that suppressed the first run. - -## 3. Engine throughput (the answer to "how does shepherd do under 5+5/block?") - -### Counts (Prometheus delta) - -| Metric | Delta | Notes | -|---|---|---| -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="block"}` | **64** | One per Anvil block (60 s / ~1 s block; some pre-load-gen + post-load-gen blocks captured). | -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="log"}` | **130** | `ConditionalOrderCreated` indexings; matches load-gen 1:1. | -| `shepherd_event_latency_seconds_count{module="ethflow-watcher",event_kind="log"}` | **130** | `OrderPlacement` dispatches; matches load-gen 1:1. | -| `shepherd_cow_api_submit_total{outcome="ok"}` | **130** | Every EthFlow strategy submit reached the mock orderbook successfully. | -| `shepherd_chain_request_total{method="eth_call",outcome="err"}` | **4 157** | `getTradeableOrderWithSignature` reverts (no settle-time allowance); strategy correctly classifies as `TryNextBlock`. 130 watches × ~32 blocks ≈ 4 160 - matches. | -| `shepherd_module_errors_total` | **0** | Zero traps, zero panics, zero poisoned modules. | - -### Latency - -**twap-monitor block (poll loop over all 130 watches):** - -| Quantile | Value | -|---|---| -| p50 | 34 ms | -| p95 | 45 ms | -| p99 | 49 ms | -| max | 50 ms | - -**twap-monitor log (`ConditionalOrderCreated` decode + persist):** - -| Quantile | Value | -|---|---| -| p50 | 4 ms | -| p95 | 5 ms | -| p99 | 6 ms | -| max | 11 ms | - -**ethflow-watcher log (decode → resolve_app_data → build OrderCreation → mock submit → marker):** - -| Quantile | Value | -|---|---| -| p50 | 8 ms | -| p95 | 10 ms | -| p99 | 11 ms | -| max | 11 ms | - -Engine-log-derived dispatch_block max: 474 ms (one cold-start outlier -on the first block where all 130 watches were freshly indexed and -the eth_call cache was cold). Subsequent blocks 34-50 ms steady. - -## 4. Mock orderbook - -``` -submits_ok = 130 -submits_err = 0 -app_data_lookups = 0 -``` - -The empty appData hash matches the `EMPTY_APP_DATA_HASH` short-circuit -in `shepherd_sdk::cow::resolve_app_data`, so the mock's app-data -endpoint sees zero traffic in this scenario - that's expected -behaviour, not a load-gen miss. - -## 5. Acceptance vs. baseline bar - -| Criterion | Observed | Pass? | -|---|---|---| -| 5 TWAP + 5 EthFlow events delivered per block | 130 TWAP + 130 EthFlow in 26 blocks = exactly 5+5/block | **✓** | -| 100% terminal markers within 3 blocks of event | Every EthFlow dispatch reaches mock + writes marker in 8-11 ms (single Anvil block) | **✓** | -| p99 latency < 2 s | TWAP block p99 = 49 ms; EthFlow log p99 = 11 ms | **✓** (40× margin) | -| Zero fuel exhaust | zero | **✓** | -| Zero traps | zero | **✓** | -| `shepherd_module_errors_total = 0` | zero | **✓** | - -**Baseline: full PASS.** Engine sustains the 5+5/block scenario with -40× margin on the latency bar. - -## 6. Observed bottleneck signal - -The twap-monitor *block* dispatch grows linearly with the watch count: -each block re-polls every watch (`eth_call` of -`getTradeableOrderWithSignature`). At 130 watches the p99 is 49 ms; -extrapolating naively, ~3 000 watches would put us at ~1 s which is -still under the 2 s bar but visible. - -The saturation scenario (50 × 50 = 3 000 events in a 60 s window) is -explicitly designed to test that extrapolation. **Medium 20 × 20 and -saturation 50 × 50 are unblocked - run them in follow-up sessions.** - -## 7. Engine health summary - -- ✓ Zero `shepherd_module_errors_total`. -- ✓ Zero traps, zero `init failed`, zero poisoned modules. -- ✓ All 130 EthFlow submissions reached the mock orderbook (0 errors). -- ✓ All 130 TWAP indexings persisted to the local store. -- ✓ `ConditionalOrderCreated` and `OrderPlacement` event streams - delivered 1:1 from Anvil to the modules with no drops. -- The one `WARN reconnect failed` line late in the engine log is the - expected post-teardown WS reset when `scripts/load-run.sh`'s trap - killed Anvil. Not an anomaly. - -## 8. Followups surfaced by this run - -1. **`scripts/load-bootstrap.sh` PID-file truncation** - on a fresh - run, the bootstrap wipes `/tmp/shepherd-load.pids`, so a previous - run's leaked engine process (port 9100) is invisible to teardown. - We hit this between the calibration smoke and this run; - manual `pkill nexum-engine` was required. Fix: pid-by-port - teardown, or move PID files into per-run timestamps. Not a load - test finding; just operational hygiene. -2. **Cold-start outlier on first watch-heavy block** (474 ms vs. - 34-50 ms steady-state). Probably redb's first-write barrier plus - the cold `eth_call` provider connection. Re-confirm under medium - scenario; if the outlier scales with watch count, worth a - supervisor-side investigation. - -## 9. Attachments - -- Engine log: `/tmp/shepherd-load/engine.log` -- Load-gen log: `/tmp/shepherd-load/load-gen.log` -- Anvil log: `/tmp/shepherd-load/anvil.log` -- Mock log: `/tmp/shepherd-load/orderbook-mock.log` -- Metrics start: `/tmp/shepherd-load/metrics-start-20260619T144846Z.txt` -- Metrics end: `/tmp/shepherd-load/metrics-end-20260619T144846Z.txt` - -(Local-only; auto-archiving into `docs/operations/load-reports/` -remains a follow-up.) - -## 10. Sign-off - -**Bruno (operator) — PASS, baseline.** Engine handles 5+5/block -with 40× margin on latency, zero errors, every event delivered -end-to-end. Medium 20×20 and saturation 50×50 are unblocked. diff --git a/docs/operations/load-testnet-runbook.md b/docs/operations/load-testnet-runbook.md index 717f74b4..8062ab99 100644 --- a/docs/operations/load-testnet-runbook.md +++ b/docs/operations/load-testnet-runbook.md @@ -1,197 +1,87 @@ # Load test runbook -How to stress shepherd's `twap-monitor` + `ethflow-watcher` modules -under synthetic load using a local Anvil fork of Sepolia and a mock -orderbook. +Stresses the `twap-monitor` + `ethflow-watcher` modules under synthetic load using a local Anvil fork of Sepolia and a mock orderbook. Answers one question: how many TWAP+EthFlow events per block the engine dispatches before something breaks. -The acceptance bar is: +Acceptance bar: | Scenario | Per-block load | Expected outcome | |---|---|---| -| Baseline | 5 TWAP + 5 EthFlow | 100% terminal markers within 3 blocks; p99 latency < 2s; zero fuel exhaust; zero traps | -| Medium | 20 TWAP + 20 EthFlow | Graceful degradation - `backoff:` markers OK, `shepherd_module_errors_total` stays 0 | +| Baseline | 5 TWAP + 5 EthFlow | 100% terminal markers within 3 blocks; p99 latency < 2 s; zero fuel exhaust; zero traps | +| Medium | 20 TWAP + 20 EthFlow | Graceful degradation: `backoff:` markers OK, `shepherd_module_errors_total` stays 0 | | Saturation | 50 TWAP + 50 EthFlow | Expected to saturate; report identifies the bottleneck | -This runbook is distinct from -`docs/operations/e2e-testnet-runbook.md` (correctness on live Sepolia) -and the 7-day soak (wall-clock stability). - ---- - ## 0. Prerequisites -### Toolchain - ``` rustup target add wasm32-wasip2 -brew install foundry # for `anvil` + `cast` +brew install foundry # anvil + cast cargo --version >= 1.87 ``` -### Sepolia archive endpoint - -`anvil --fork-url` needs an HTTP archive endpoint to seed the fork. -Add to `scripts/.env`: +`anvil --fork-url` needs an HTTP archive endpoint. Add to `scripts/.env`: ``` RPC_URL_SEPOLIA_HTTP=https://eth-sepolia.g.alchemy.com/v2/ ``` -(Public nodes throttle the initial fork warmup; use Alchemy / drpc / -similar.) - ---- +(Public nodes throttle the fork warmup; use Alchemy / drpc / similar.) ## 1. Boot -The three supporting processes (Anvil, orderbook-mock, engine) live in -the background; `scripts/load-run.sh` is the single entry point. +`scripts/load-run.sh` is the single entry point: ```bash -# baseline (default knobs: 5 TWAP + 5 EthFlow per block, 1 minute) +# baseline (5 TWAP + 5 EthFlow per block, 1 minute) ./scripts/load-run.sh -# medium load -./scripts/load-run.sh --twap-per-block 20 --ethflow-per-block 20 \ - --duration-min 2 --scenario medium +# medium +./scripts/load-run.sh --twap-per-block 20 --ethflow-per-block 20 --duration-min 2 --scenario medium -# saturation probe -./scripts/load-run.sh --twap-per-block 50 --ethflow-per-block 50 \ - --duration-min 2 --scenario saturation +# saturation +./scripts/load-run.sh --twap-per-block 50 --ethflow-per-block 50 --duration-min 2 --scenario saturation ``` The script: -1. Sources `scripts/load-bootstrap.sh` -> starts Anvil (`port 8545`) - and `tools/orderbook-mock` (`port 9999`). -2. Builds `twap-monitor` + `ethflow-watcher` `.wasm`, the - `nexum` binary, and `tools/load-gen`. -3. Starts the engine pointed at `engine.load.toml`. -4. Snapshots `/metrics` from the engine. -5. Runs `tools/load-gen` for the requested duration. +1. Sources `scripts/load-bootstrap.sh`: starts Anvil (port 8545) and `shepherd/tools/orderbook-mock` (port 9999). +2. Builds the two module `.wasm`, the `shepherd` binary, and `nexum/tools/load-gen`. +3. Starts the engine on `engine.load.toml`. +4. Snapshots `/metrics`. +5. Runs `nexum/tools/load-gen` for the requested duration. 6. Snapshots `/metrics` again. -7. Tears everything down. -8. Drops a report at `docs/operations/load-reports/load-NxM-YYYY-MM-DD.md`. - -If you Ctrl-C, the trap calls `load_teardown` and kills the children -before exit. If something escapes (bash trap missed), run -`./scripts/load-teardown.sh` explicitly. - ---- - -## 2. What each component does - -### Anvil (port 8545) - -``` -anvil --fork-url $RPC_URL_SEPOLIA_HTTP --port 8545 --block-time 1 -``` - -Forks Sepolia at the latest block. Inherits every contract the test -needs (ComposableCoW, CoWSwapEthFlow, TWAP handler, WETH9, COW token) -at their pinned Sepolia addresses, so the test EOA can call -`ComposableCoW.create(...)` and `CoWSwapEthFlow.createOrder(...)` -against real bytecode without any local deployment step. - -`--block-time 1` mines a block per second, matching Sepolia's -~12s cadence... loosely. The point of the load test is to push N+M -transactions into each block, not to mimic mainnet block times. - -### Mock orderbook (port 9999) - -`tools/orderbook-mock` serves the one endpoint shepherd's `cow-api` -host backend hits per submission: +7. Tears everything down and drops a report at `docs/operations/load-reports/load-NxM-YYYY-MM-DD.md`. -- `POST /api/v1/orders` - returns a synthetic 56-byte OrderUid. +Ctrl-C triggers `load_teardown`. If a child escapes, run `./scripts/load-teardown.sh`. -Knobs (set via env in `scripts/load-bootstrap.sh` if needed): +## 2. Components -- `--latency-ms` - inject artificial latency on every response. -- `--error-rate` - fraction of POST /orders responses that return a - recognised `ApiError` envelope. Alternates between - `InsufficientFee` (`TryNextBlock`) and `InvalidSignature` (`Drop`). - -For the saturation probe, leaving `latency_ms=0` and `error_rate=0` -isolates the engine-side bottleneck from orderbook-side variability. - -### Engine (engine.load.toml) - -- `[chains.11155111] rpc_url = "ws://localhost:8545"` -- `[extensions.cow.orderbook_urls] 11155111 = "http://localhost:9999"` -- Prometheus enabled on `127.0.0.1:9100` -- `state_dir = ./data/load` (wiped at the start of every run) -- Module list: `twap-monitor` + `ethflow-watcher` only - -### Load generator (tools/load-gen) - -Connects to the Anvil WebSocket, calls `anvil_impersonateAccount` + -`anvil_setBalance` on the pinned EOA -(`0x7bF140727D27ea64b607E042f1225680B40ECa6A`), then in a loop, every -new block, fires N `ComposableCoW.create(...)` calls plus M -`CoWSwapEthFlow.createOrder(...)` calls. Each create uses a fresh -salt (counter-derived) so the txs do not collide on the -ComposableCoW dedup check. - -`anvil_impersonateAccount` skips signing entirely - one fewer -overhead under load. - ---- +- **Anvil (port 8545)**: `anvil --fork-url $RPC_URL_SEPOLIA_HTTP --port 8545 --block-time 1`. Forks Sepolia at the latest block, inheriting ComposableCoW, CoWSwapEthFlow, the TWAP handler, WETH9, and COW at their pinned addresses, so the test EOA calls real bytecode with no local deployment. +- **Mock orderbook (port 9999)**: `shepherd/tools/orderbook-mock` serves `POST /api/v1/orders`, returning a synthetic 56-byte OrderUid. Knobs (env in `scripts/load-bootstrap.sh`): `--latency-ms` injects response latency; `--error-rate` returns a fraction as an `ApiError` envelope, alternating `InsufficientFee` (`TryNextBlock`) and `InvalidSignature` (`Drop`). Leave both 0 for the saturation probe to isolate the engine-side bottleneck. +- **Engine (`engine.load.toml`)**: RPC `ws://localhost:8545`; cow orderbook URL `http://localhost:9999`; Prometheus on `127.0.0.1:9100`; `state_dir = ./data/load` (wiped each run); modules `twap-monitor` + `ethflow-watcher`. +- **Load generator (`nexum/tools/load-gen`)**: connects to the Anvil WS, calls `anvil_impersonateAccount` + `anvil_setBalance` on the pinned EOA, then each new block fires N `ComposableCoW.create(...)` + M `CoWSwapEthFlow.createOrder(...)` calls, each with a fresh counter-derived salt. ## 3. Acceptance reading -After a run, the report at -`docs/operations/load-reports/load-NxM-YYYY-MM-DD.md` carries: - -- mock-orderbook stats (success vs. error count) - matches load-gen's - reported submit-attempt count, modulo `error_rate`. -- load-gen tail - submit success/failure breakdown per block. -- engine log tail - watch for `module trap`, `poisoned`, - `init failed`, `WS reconnect`. -- metrics delta filename pair (auto-delta lands in a follow-up). +The report at `docs/operations/load-reports/load-NxM-YYYY-MM-DD.md` carries mock-orderbook stats, the load-gen submit breakdown, the engine log tail, and the metrics snapshot pair. Look at: -Look at: +- `shepherd_event_latency_seconds{module="twap-monitor"}` quantiles: p99 < 2 s for baseline. +- `shepherd_cow_api_submit_total{outcome="ok"}`: tracks the load-gen success count. +- `shepherd_module_errors_total`: must stay 0 for baseline/medium; any non-zero count on saturation is the headline. +- `shepherd_chain_request_total{method="eth_call"}`: twap-monitor polls via `eth_call`; the count shows how hard the poll races the next block. -- `shepherd_event_latency_seconds{module="twap-monitor"}` quantiles - - p99 < 2s for the baseline scenario. -- `shepherd_cow_api_submit_total{outcome="ok"}` - should track the - load-gen success count. -- `shepherd_module_errors_total` - must stay 0 for baseline/medium; - any non-zero count on saturation is the headline. -- `shepherd_chain_request_total{method="eth_call"}` - twap-monitor - polls via `eth_call`; the count tells you how aggressively the - poll is racing the next block. - ---- - -## 4. What this does NOT prove - -- WS reconnect resilience (7-day soak). -- Diverse appData / order-shape correctness (the backtest). -- Multi-day memory drift (7-day soak). -- Real-orderbook 4xx variety (the backtest). -- Provider rate-limit handling on the live network. - -This test answers exactly one question: "How many TWAP+EthFlow events -per block can shepherd dispatch before something breaks?" Use it -alongside the soak, not instead of it. - ---- - -## 5. Troubleshooting +## 4. Troubleshooting | Symptom | Cause | Fix | |---|---|---| -| Anvil exits within 5s | Forking endpoint rejected | Check `RPC_URL_SEPOLIA_HTTP` is an archive endpoint, not a pruned node. Alchemy free tier works. | -| `cargo build --target wasm32-wasip2` fails on `wit-bindgen` | Toolchain stale | `rustup target add wasm32-wasip2` (re-run; may have rolled). | -| Engine never reaches `supervisor ready` | wasm artefacts not built | The script builds them, but a stale `target/wasm32-wasip2/release/*` from another branch can collide. `rm -rf target/wasm32-wasip2` and rerun. | -| `/metrics` never comes up | Port 9100 in use | Edit `engine.load.toml` `bind_addr` (and the curl URL in `scripts/load-run.sh`). | -| `load-gen` errors with "EOA not impersonated" | Anvil restarted mid-run | `scripts/load-teardown.sh && scripts/load-run.sh` from scratch. | - ---- +| Anvil exits within 5 s | Forking endpoint rejected | Ensure `RPC_URL_SEPOLIA_HTTP` is an archive endpoint | +| `wasm32-wasip2` build fails on `wit-bindgen` | Toolchain stale | `rustup target add wasm32-wasip2` | +| Engine never reaches `supervisor ready` | Stale wasm artefacts | `rm -rf target/wasm32-wasip2` and rerun | +| `/metrics` never comes up | Port 9100 in use | Edit `engine.load.toml` `bind_addr` and the curl URL in `scripts/load-run.sh` | +| `load-gen` errors with "EOA not impersonated" | Anvil restarted mid-run | `scripts/load-teardown.sh && scripts/load-run.sh` | -## 6. References +## 5. References - Sister doc (live Sepolia E2E): `docs/operations/e2e-testnet-runbook.md` - Engine config: `engine.load.toml` -- Tools: `tools/orderbook-mock/`, `tools/load-gen/` +- Tools: `shepherd/tools/orderbook-mock/`, `nexum/tools/load-gen/` - Scripts: `scripts/load-bootstrap.sh`, `scripts/load-run.sh`, `scripts/load-teardown.sh` diff --git a/docs/operations/m2-testnet-runbook.md b/docs/operations/m2-testnet-runbook.md index a8cc9782..79ecf38c 100644 --- a/docs/operations/m2-testnet-runbook.md +++ b/docs/operations/m2-testnet-runbook.md @@ -1,37 +1,18 @@ # M2 testnet runbook (Sepolia) -How to actually run the M2 modules - twap-monitor and ethflow-watcher - -on Sepolia and exercise the full path the unit tests cannot: real -`eth_subscribe` streams, real `eth_call` reverts, real orderbook -submissions. +Runs twap-monitor and ethflow-watcher on Sepolia against real `eth_subscribe` streams, `eth_call` reverts, and orderbook submissions. -Two flavours: +Two flavours share the same boot: -1. **Smoke run**: boot the engine, watch the supervisor pick up every - `ConditionalOrderCreated` / `OrderPlacement` log that lands on - Sepolia. Passive; you do not produce traffic. 15-30 min wall clock. -2. **Round-trip run**: smoke run plus you author a TWAP order via a - Sepolia Safe and an EthFlow swap via the public CoW Swap UI. The - engine indexes / decodes / submits. 1-2 h. - -Both share the same boot. The round-trip is the smoke run with a hand -on the wheel. - ---- +1. Smoke run: boot the engine and watch it pick up every `ConditionalOrderCreated` / `OrderPlacement` log that lands on Sepolia. Passive, 15-30 min. +2. Round-trip run: the smoke run plus you author a TWAP order via a Sepolia Safe and an EthFlow swap via the CoW Swap UI, 1-2 h. ## 0. Prerequisites -- Rust toolchain matching `rust-toolchain.toml` (nightly with - `wasm32-wasip2` target). `rustup target add wasm32-wasip2` once. -- `just` (`cargo install just` or `brew install just`). -- Sepolia RPC. Public endpoint in `engine.m2.toml` works for short - runs; switch to Alchemy/Infura with a key for anything past ~20 min. -- For the round-trip: - - A Sepolia EOA with some test ETH ([Alchemy faucet](https://sepoliafaucet.com)). - - A [Sepolia Safe](https://app.safe.global/?chain=sep) (only for the - TWAP half). - ---- +- Rust toolchain matching `rust-toolchain.toml` (nightly with `wasm32-wasip2`). `rustup target add wasm32-wasip2` once. +- `just`. +- Sepolia RPC. The public endpoint in `engine.m2.toml` works for short runs; switch to Alchemy/Infura for anything past ~20 min. +- Round-trip only: a Sepolia EOA with test ETH, and a Sepolia Safe for the TWAP half. ## 1. Smoke run @@ -39,28 +20,20 @@ on the wheel. just run-m2 ``` -Equivalent long form: +Long form: ```bash cargo build -p twap-monitor --target wasm32-wasip2 --release cargo build -p ethflow-watcher --target wasm32-wasip2 --release -cargo run -p nexum-cli -- --engine-config engine.m2.toml +cargo run -p shepherd -- --engine-config engine.m2.toml --pretty-logs ``` -### What you should see in the first ~5 seconds (observed) +Expected boot (~5 s): ``` INFO nexum_runtime nexum starting INFO nexum_runtime::host::provider_pool opening chain RPC provider chain_id=11155111 url="wss://..." -INFO nexum_runtime::supervisor loading module manifest manifest=modules/twap-monitor/module.toml -[manifest] required capabilities: logging, local-store, chain, cow-api -INFO nexum_runtime::supervisor compiling component component=target/wasm32-wasip2/release/twap_monitor.wasm -INFO nexum_runtime::host::impls::logging twap-monitor init module="twap-monitor" INFO nexum_runtime::supervisor init succeeded module=twap-monitor -INFO nexum_runtime::supervisor loading module manifest manifest=modules/ethflow-watcher/module.toml -[manifest] required capabilities: logging, local-store, chain, cow-api -INFO nexum_runtime::supervisor compiling component component=target/wasm32-wasip2/release/ethflow_watcher.wasm -INFO nexum_runtime::host::impls::logging ethflow-watcher init module="ethflow-watcher" INFO nexum_runtime::supervisor init succeeded module=ethflow-watcher INFO nexum_runtime::supervisor supervisor up count=2 INFO nexum_runtime supervisor ready modules=2 chains=1 @@ -69,172 +42,72 @@ INFO nexum_runtime::runtime::event_loop log subscription open module=twap-monit INFO nexum_runtime::runtime::event_loop log subscription open module=ethflow-watcher chain_id=11155111 ``` -Then every ~12s (Sepolia block time): - -``` -INFO nexum_runtime::runtime::event_loop dispatch block chain_id=11155111 number=N -``` +Then a `dispatch block` line every ~12 s (Sepolia block time). -### What to verify +Verify: | Check | How | |---|---| -| Both modules booted | `module_count: 2` + 2 `loaded module` lines | +| Both modules booted | `count=2` + 2 `init succeeded` lines | | Subscriptions wired | 2 log subs + 1 block sub | -| No traps in the first 10 blocks | `alive: 2` stays at 2; no `module ... trapped` lines | +| No traps in the first 10 blocks | no `module ... trapped` lines | | State persistence works | `ls data/m2/` shows `ls.redb` growing | -### Stopping cleanly +Ctrl-C to stop. Remove `./data/m2/` between runs for a fresh slate. -Ctrl-C. Tear down `./data/m2/` between runs if you want a fresh slate. - -### Common surprises +## 2. Round-trip run -- **Public RPC throttles after a few minutes.** Symptom: `eth_subscribe` - reconnects in a loop. Fix: switch to Alchemy/Infura. Edit the - `[chains.11155111]` block in `engine.m2.toml` (env-substitution is - not wired yet). -- **You see `eth_call failed (...); defaulting to TryNextBlock`.** This - is twap-monitor polling watches that are still empty (no - `ConditionalOrderCreated` indexed yet). Expected on a fresh `./data/m2`. -- **You see NO log dispatches for hours.** Sepolia has low ComposableCoW - / EthFlow traffic. The smoke run is mostly a "stay alive" test until - you produce events yourself (see round-trip below). +Same boot; you produce the events. ---- +### 2a. TWAP half (Safe + Compose) -## 2. Round-trip run +ComposableCoW expects the conditional-order owner to be an EIP-1271 verifier, so the TWAP flow runs behind a Safe, not an EOA. -Same boot as #1; you produce the events. - -### 2a. TWAP half (via Safe + Compose) - -The TWAP flow lives behind a Safe, not an EOA, because ComposableCoW -expects the conditional-order owner to be an EIP-1271 verifier. - -1. **Create a Sepolia Safe** at . - Single signer with your EOA is fine. Fund it with ~0.05 Sepolia - ETH (gas) and ~10 of a Sepolia ERC-20 you want to sell. -2. **Install the Compose app** in the Safe. CoW Protocol publishes the - ComposableCoW Watch Tower as a Safe app on Sepolia. - - In Safe -> Apps -> Add custom app: use the URL from - README ("Add to - Safe"). -3. **Author a TWAP order**. Compose UI -> "TWAP". Recommended for the - first run: - - Sell: 1 of your test ERC-20. - - Buy: any Sepolia stable. - - Split into 2 parts, 5-minute interval, validity 30 min. - - Confirm + sign the Safe tx. -4. **Watch the engine logs.** Within ~12s of the Safe tx confirming, - you should see: +1. Create a Sepolia Safe at (single signer with your EOA). Fund it with ~0.05 Sepolia ETH and ~10 of a Sepolia ERC-20 to sell. +2. Add the ComposableCoW Compose app (Safe -> Apps -> Add custom app, URL from the composable-cow README). +3. Author a TWAP order in the Compose UI: sell 1 test ERC-20, buy any Sepolia stable, 2 parts, 5-minute interval, 30-minute validity. Sign the Safe tx. +4. Within ~12 s of the tx confirming: ``` INFO twap-monitor indexed watch:0x:0x - ``` - Then on the next blocks where the tranche is ready: - ``` INFO twap-monitor poll watch:... -> Ready INFO twap-monitor submitted submitted:0x ``` - Sometimes you see `TryAtEpoch(t)` instead of `Ready` - that means - the tranche is gated until time `t`. Wait the configured interval. -5. **Confirm on the orderbook.** Get the UID from the log, then: +`TryAtEpoch(t)` instead of `Ready` means the tranche is gated until time `t`; wait the configured interval. +5. Confirm on the orderbook (settlement on Sepolia is spotty; reaching the orderbook is the goal): ```bash curl https://api.cow.fi/sepolia/api/v1/orders/0x ``` - You should see the order JSON back. Trade settlement on Sepolia is - spotty (solvers do not always pick up); the goal of this test is - that the order reached the orderbook, not that it filled. -### 2b. EthFlow half (via swap.cow.fi) +### 2b. EthFlow half (swap.cow.fi) -EthFlow does not need a Safe - any EOA works. +Any EOA works. -1. Go to (Sepolia native - ETH selector). -2. Connect your EOA, select a small swap (e.g. 0.001 SETH -> any - token), confirm. -3. The CoWSwapEthFlow contract on Sepolia - (`0xbA3cB4...EadeC`) emits `OrderPlacement`. -4. **Watch the engine logs:** +1. Go to . +2. Connect the EOA, select a small swap (e.g. 0.001 SETH -> any token), confirm. +3. CoWSwapEthFlow (`0xbA3cB4...EadeC`) emits `OrderPlacement`. Expected log: ``` INFO ethflow-watcher ethflow submitted 0x ``` - If you see `ethflow backoff 0x ...` instead: orderbook - classified the submit as retriable. Wait one block, the watcher - does not retry on its own today (planned for M4 supervisor - restart wiring). - - If you see `ethflow dropped 0x ...`: orderbook rejected - permanently (most likely `DuplicateOrder` - CoW Swap submits the - order itself first, ethflow-watcher races and loses). Expected; the - `dropped:{uid}` row is the regression guard, not the - failure signal here. +`ethflow backoff 0x` means the orderbook classified the submit as retriable; wait one block. `ethflow dropped 0x` means a permanent rejection (commonly `DuplicateOrder`, since CoW Swap submits the order first and the watcher races it); the `dropped:{uid}` row is the expected marker. -### What "passing M2 round-trip" looks like - -- At least one `submitted:{uid}` row in `data/m2/ls.redb` written by - each module. -- Both modules still alive (`alive: 2`) at the end of the run. -- Zero `module ... trapped` lines in the engine log. -- `curl api.cow.fi/sepolia/api/v1/orders/` returns the order JSON - for at least one submitted UID (`null` means the orderbook never - accepted; non-null means we round-tripped). - ---- +Passing round-trip: at least one `submitted:{uid}` row per module in `data/m2/ls.redb`, both modules alive at the end, zero `trapped` lines, and `curl api.cow.fi/sepolia/api/v1/orders/` returns the order JSON for at least one UID. ## 3. Inspecting state after a run -The local-store is a redb file. Quick inspection without writing a -tool: - -```bash -# Build the example mini-CLI the engine ships -cargo run -p nexum-cli --bin ls-dump -- data/m2/ls.redb 2>/dev/null \ - || echo "no ls-dump bin in 0.2 - read via the engine on next boot" -``` - -Today the canonical way to read the store is to boot the engine again -on the same `state_dir`: the supervisor logs every `watch:` / -`submitted:` / `dropped:` row it loads. A proper inspector is -production-hardening scope (M4). +The local store is a redb file with no standalone dump tool. Reboot the engine on the same `state_dir`: the supervisor logs every `watch:` / `submitted:` / `dropped:` row it loads. ---- - -## 4. What this run does NOT prove - -- **Throughput / soak stability**. That is the 7-day soak. -- **Cross-module isolation under load**. That is the 4-6h - multi-module E2E run. The local-store namespace test guarantees the - invariant in unit; the runbook above is a single-Safe / single-EOA - setup. -- **Resource-limit enforcement under adversarial guests**. Fuel + memory tests (M4 territory). -- **Security review**. Tracked separately (M4 territory). - -The M2 runbook covers: "does the engine actually boot the two M2 -modules end-to-end against Sepolia, route real subscription events -through the wit-bindgen + WitBindgenHost path, and round-trip orders -to the CoW orderbook". That is the deliverable M2 is responsible for. - ---- - -## 5. Troubleshooting +## 4. Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| -| `connection refused` / WS retries | Public node throttled | Switch RPC to Alchemy / Infura | -| `module twap-monitor trapped: OutOfFuel` | Dispatch path exceeded fuel budget | Almost certainly an upstream issue, file as a separate issue; raise `[engine.limits]` fuel temporarily | +| `connection refused` / WS retries | Public node throttled | Switch RPC to Alchemy / Infura in `engine.m2.toml` | +| `module twap-monitor trapped: OutOfFuel` | Dispatch exceeded fuel budget | File an issue; raise `[engine.limits]` fuel temporarily | | `eth_call failed (rate limited)` repeatedly | Public node | Same as above | -| `ParseManifestError: missing capability cow-api` | Engine version mismatch with module.toml | `cargo build -p nexum-cli --release` and use the fresh binary | -| `data/m2/ls.redb` not created | `state_dir` not writable | Check permissions, or change `state_dir` in `engine.m2.toml` | - ---- +| `ParseManifestError: missing capability cow-api` | Engine/module.toml version mismatch | `cargo build -p shepherd --release` and use the fresh binary | +| `data/m2/ls.redb` not created | `state_dir` not writable | Check permissions or change `state_dir` in `engine.m2.toml` | -## 6. References +## 5. References -- Engine config schema: `crates/nexum-runtime/src/engine_config.rs` -- M2 modules: `modules/twap-monitor/`, `modules/ethflow-watcher/` -- ADR-0005 (cow-api routing): `docs/adr/0005-cow-api-via-cached-orderbookapi.md` -- ADR-0006 (twap + ethflow helpers): `docs/adr/0006-cow-twap-ethflow-host-helpers.md` -- ADR-0009 (host trait surface): `docs/adr/0009-host-trait-surface.md` -- M2 PRs in `bleu/nullis-shepherd`: #2-#11 +- Engine config schema: `nexum/crates/nexum-runtime/src/engine_config.rs` +- M2 modules: `shepherd/modules/twap-monitor/`, `shepherd/modules/ethflow-watcher/` +- ADR-0005 (cow-api routing), ADR-0006 (twap + ethflow helpers), ADR-0009 (host trait surface) diff --git a/docs/operations/m3-edge-case-validation.md b/docs/operations/m3-edge-case-validation.md deleted file mode 100644 index c31cda38..00000000 --- a/docs/operations/m3-edge-case-validation.md +++ /dev/null @@ -1,230 +0,0 @@ -# M3 testnet edge-case validation (2026-06-18) - -Five edge cases run against the live `engine.m3.toml` boot on Sepolia. -Each takes ~10-15 s of wall clock; together they exercise the error -paths the runbook section 1 cannot cover passively. **All five -passed with one minor observation** (init-failed module stays -`alive=true`; safe in practice, worth a follow-up issue). - -Run on commit `feat/m3-edge-case-validation` tip; engine debug log -level. - ---- - -## 1.1 Bad RPC URL -> structured connect error, clean exit - -**Mutation**: `engine.m3.toml` `rpc_url = "wss://nonexistent.example.com"`. - -**Observed**: - -``` -INFO nexum starting -INFO opening chain RPC provider chain_id=11155111 url="wss://nonexistent.example.com" -Error: connect chain 11155111: IO error: failed to lookup address information: - nodename nor servname provided, or not known -``` - -**Verdict**: ✅ engine exits with structured `connect chain N: ...` -error chain. No panic, no retry loop, no silent hang. Operator -gets a clear cue to fix the URL. - -**Implication**: an operator misconfiguring an RPC URL fails fast and -loud. Combined with the supervisor restart loop (planned for M4), -this gives "kill engine, fix config, restart, no orphaned state". - ---- - -## 1.2 Bad oracle address -> module Warn + stays alive - -**Mutation**: `modules/examples/price-alert/module.toml::[config]` -`oracle_address = "0x0000000000000000000000000000000000000001"` (an -EOA with no code; `eth_call` returns empty bytes). - -**Observed**: boot clean; on the first block: - -``` -WARN price-alert: latestRoundData decode failed: - ABI decoding failed: buffer overrun while deserializing -``` - -Engine stays at `supervisor up count=3`; balance-tracker and -stop-loss continue to operate normally. - -**Verdict**: ✅ module gracefully handles upstream giving the wrong -shape. The decode error names the failing call (`latestRoundData`) -and the failure mode (buffer overrun), so an operator can correlate -to a misconfigured `oracle_address` without reading source. - -**Implication**: validates the SDK error model end-to-end: -`chain::request` returns Ok with empty bytes, `parse_eth_call_result` -returns `Some(vec![])`, `latestRoundDataCall::abi_decode_returns` -fails with `alloy_sol_types::Error::Buffer overrun`, the strategy's -`map_err` surfaces it as a `Warn` log via `LoggingHost::log`. All -four host traits + the `cow` helper path exercised. - ---- - -## 1.3 Capability mismatch -> boot rejects module - -**Mutation**: `modules/examples/stop-loss/module.toml::[capabilities]` -`required = ["logging"]` (dropped `chain`, `local-store`, `cow-api`). - -**Observed**: - -``` -INFO loading module manifest manifest=modules/examples/stop-loss/module.toml -[manifest] required capabilities: logging -INFO compiling component component=...stop_loss.wasm -Error: load module target/wasm32-wasip2/release/stop_loss.wasm - -Caused by: - 0: capability violation in target/wasm32-wasip2/release/stop_loss.wasm - 1: component imports `cow-api` (shepherd:cow/cow-api@0.2.0) but it - is not listed in [capabilities].required or [capabilities].optional -``` - -Engine exits with non-zero. The whole boot fails because the -supervisor cannot honour the (intentionally under-declared) manifest. - -**Verdict**: ✅ the capability security boundary is enforced at module -load, not deferred to first host call. Error chain identifies the -specific `cow-api` import that the manifest does not authorise. This -is the `enforce capability declarations at module -instantiation` invariant working in production. - -**Implication**: a malicious or buggy module cannot import a host -capability without explicitly declaring it. This is the M3 SDK -contract's core security guarantee. - ---- - -## 1.4 Malformed `[config]` -> init returns typed `InvalidInput` - -**Mutation**: `modules/examples/price-alert/module.toml::[config]` -`threshold = "not-a-number"`. - -**Observed**: - -``` -INFO loading module manifest manifest=modules/examples/price-alert/module.toml -WARN init failed - module=price-alert - kind=invalid_input - "invalid [config]: threshold: non-digit character in - \"not-a-number\"" -INFO balance-tracker init: 2 addresses, ... -INFO init succeeded module=balance-tracker -INFO stop-loss init: owner=..., trigger=..., ... -INFO init succeeded module=stop-loss -INFO supervisor up count=3 -``` - -**Verdict**: ✅ init failure isolated to the offending module. -Balance-tracker and stop-loss boot normally. The export returns -`fault.invalid-input`; the supervisor supplies the module name and -derives the log `kind` from the fault label, with a clear message -identifying the field + the invalid character. - -**Update (landed in this PR series)**: the supervisor now -flips `alive = false` when `init` returns `Err`, and the boot log -shows `supervisor up loaded=3 alive=2` so the discrepancy is -visible. Re-running scenario 1.4 against live Sepolia after the fix: - -``` -WARN init failed - module loaded but marked dead; dispatcher will skip it - module=price-alert kind=invalid_input - "invalid [config]: threshold: non-digit character in 'not-a-number'" -INFO supervisor up loaded=3 alive=2 -``` - -Subsequent block dispatches reach only the 2 alive modules; the -init-failed price-alert is now skipped by the dispatch fast-path -without surfacing the no-op fuel cost. Regression test: -`supervisor::tests::init_failure_marks_module_dead_and_excludes_from_dispatch`. - ---- - -## 1.5 Persistence cross-restart -> redb file preserved - -**Mutation**: boot 1 with `rm -rf data/m3` (fresh state), then boot 2 -without rm. - -**Observed**: - -``` -=== Boot 1 (fresh) === -INFO balance-tracker init: 2 addresses, ... -INFO init succeeded module=balance-tracker -(stopped after 14s) - -=== State after boot 1 === -total 7200 --rw-r--r-- brunotavaresdosanjos 3686400 data/m3/local-store.redb - -=== Boot 2 (state preserved) === -INFO balance-tracker init: 2 addresses, ... -INFO init succeeded module=balance-tracker -(stopped after 14s) -``` - -Both boots clean; `local-store.redb` file size stable (3.6 MB - redb -pre-allocates pages; actual key/value content is bytes, not MB). - -**Verdict**: ✅ the redb file survives `kill -TERM` cleanly, can be -re-opened on the next boot, and the supervisor reads from it -without corruption. This validates the 32-byte hash prefix -namespace in production: modules wrote -keys, the engine shut down, modules re-attached on restart, no -panic. - -**Implication**: the local-store invariant -(`namespaces_isolate_modules` unit test + cross-restart durability) -is now confirmed against a real Sepolia run. Combined with the -supervisor integration tests, this is sufficient -evidence that local-store persistence works at the production -boundary, not only in mocks. - -**Caveat**: there is no built-in CLI to dump the redb contents, so -visual confirmation of specific keys (`last:0x...`, etc.) requires -either re-booting the engine on the same state_dir or writing an -ad-hoc inspector. Filed as a future M4-territory nice-to-have. - ---- - -## Summary - -| # | Scenario | Verdict | New issue? | -|---|---|---|---| -| 1.1 | Bad RPC URL | ✅ structured error + clean exit | no | -| 1.2 | Bad oracle address | ✅ Warn + module alive + clear decode error | no | -| 1.3 | Capability mismatch | ✅ boot rejects with structured error chain | no | -| 1.4 | Malformed `[config]` | ✅ typed `InvalidInput`; init-failed module marked dead + excluded from dispatch | resolved in this PR series | -| 1.5 | Cross-restart persistence | ✅ redb file preserved + re-attaches cleanly | no (a state-dump CLI would help; M4 nice-to-have) | - -**One follow-up issue**: in `Supervisor::load`, when `init` returns -`Err(fault)`, set `alive=false` (or skip pushing the module into -`self.modules`). Subsequent dispatch wastes fuel on a no-op -short-circuit otherwise. Safe today; cleanup before M4. - -**Not in scope here** (M4 territory, already filed): -- Fuel exhaustion (M4 territory) -- Memory exhaustion (M4 territory) -- Module trap during `on_event` + restart with backoff (M4 territory) -- WS reconnect logic instead of bail → not filed (current behaviour - is documented in `runtime/event_loop.rs` as "0.3 fix") - ---- - -## How to reproduce - -Each scenario is a one-line config mutation + `just run-m3` (or the -equivalent `cargo run`). Mutations are listed inline above. Restore -config between runs: - -```bash -git checkout modules/examples/price-alert/module.toml \ - modules/examples/stop-loss/module.toml \ - engine.m3.toml -``` - -Tested on commit `` at 2026-06-18, Sepolia public WS. diff --git a/docs/operations/m3-testnet-runbook.md b/docs/operations/m3-testnet-runbook.md index 8f2f9335..ea655581 100644 --- a/docs/operations/m3-testnet-runbook.md +++ b/docs/operations/m3-testnet-runbook.md @@ -1,208 +1,99 @@ # 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. - ---- +Exercises the example modules price-alert, balance-tracker, and stop-loss on Sepolia: -## 0. Prerequisites +- price-alert: SDK `chain` helpers + Chainlink ABI decode. Read-only. +- balance-tracker: SDK `chain::request` (raw RPC) + `local-store` per-key diff persistence. Read-only. +- stop-loss: `chain::request` + `local-store` dedup + `cow-api::submit-order` with `Signature::PreSign`. Submits a real CoW order to the Sepolia orderbook when the oracle price crosses the trigger. -- 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. +All three subscribe to blocks only and start working immediately; a single Sepolia block (~12 s) drives each through its full logic. - 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. +## 0. Prerequisites ---- +- Same as the M2 runbook (Rust nightly + `wasm32-wasip2`, `just`, Sepolia RPC). +- For stop-loss to settle (not just submit and get rejected): + - 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 and has approved enough `sell_token` to settle. -## 1. Smoke + active run +Without those, stop-loss hits `TransferSimulationFailed` (or `InvalidSignature` / `InsufficientAllowance`) and logs it as a retriable error or drop. That outcome still validates the orderbook round-trip. -The M3 modules all subscribe to blocks only and start working -immediately - there is no `[[subscription]] kind = "chain-log"` to wait for. -A single Sepolia block (~12 s) drives all three through their full -strategy. +## 1. Smoke + active run ```bash just run-m3 ``` -Equivalent long form: +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-cli -- --engine-config engine.m3.toml +cargo run -p shepherd -- --engine-config engine.m3.toml --pretty-logs ``` -### What you should see in the first ~10 seconds (observed) +Expected boot (~10 s): ``` INFO nexum 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): +On the first Sepolia block dispatch (~5-15 s after boot): ``` -DEBUG chain::request chain_id=11155111 method=eth_call # price-alert reads oracle +DEBUG chain::request 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 +DEBUG chain::request method=eth_getBalance # balance-tracker addr 1 +DEBUG chain::request method=eth_getBalance # balance-tracker addr 2 +DEBUG chain::request method=eth_call # stop-loss reads oracle +DEBUG cow-api::submit-order 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 +That block proves the M3 module surface end-to-end: oracle read + ABI decode + multi-key local-store + cow-api submit + typed retry classification. -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 TRIGGERED fires immediately: the default `trigger_price` in `module.toml` is above the Sepolia Chainlink ETH/USD feed (a stale or mocked value), and `direction = below`, so the first poll trips. Raise `trigger_price` 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. - ---- +Why stop-loss logs TransferSimulationFailed: the default `owner` does not own or approve `sell_token` on Sepolia, so the orderbook simulates the settle and rejects with a typed error. The `classify_api_error` SDK helper tags it retriable (`TryNextBlock`) and leaves the watch for the next block. ## 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. +To see stop-loss submit and persist `submitted:{uid}`: + +1. Set `owner` in `modules/examples/stop-loss/module.toml` to a Sepolia EOA you control. +2. Choose a `sell_token` / `buy_token` pair the EOA holds. +3. Compute the OrderUid (see `build_creation` in `keeper.rs`). +4. Call `GPv2Settlement.setPreSignature(uid, true)` from that EOA. +5. Approve `sell_token` to the GPv2VaultRelayer for the sell amount. +6. Lower `trigger_price` 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: +`./data/m3/ls.redb` accumulates `last:{addr}` (balance-tracker) and `submitted:{uid}` / `dropped:{uid}` (stop-loss). No standalone dump tool: reboot the engine on the same `state_dir` and the supervisor logs every key it loads. `rm -rf ./data/m3` for a fresh slate. -- Throughput / 7-day soak. -- Cross-module isolation under load (the 4-6 h E2E run). -- Adversarial resource exhaustion (M4 territory). -- Security review (M4 territory). -- `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: +## 4. Troubleshooting | 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. | - ---- +| `module stop-loss trapped: TransferSimulationFailed` | Trap vs warn confusion | `sell token cannot be transferred` is a Warn, not a trap; the module stays alive. | +| `price-alert: TRIGGERED` does not fire | Undecodable oracle shape, or throttled `eth_call` | Check for `eth_call failed`; switch to Alchemy. | +| `balance-tracker` logs only 1 of 2 addresses | RPC dropped a request mid-block | Switch RPC. | -## 6. References +## 5. 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` +- SDK chain helpers: `nexum/crates/nexum-sdk/src/chain/` +- ADR-0009 (host trait surface) +- M2 runbook (sister doc): `docs/operations/m2-testnet-runbook.md` diff --git a/docs/operations/soak-runbook.md b/docs/operations/soak-runbook.md index 76e3feea..90acb573 100644 --- a/docs/operations/soak-runbook.md +++ b/docs/operations/soak-runbook.md @@ -1,146 +1,66 @@ -# 7-Day Soak Runbook +# 7-day soak runbook -How to run the **7-day unattended stability soak** — all 5 modules on -Sepolia, continuously, with hourly metrics snapshots. - -## Purpose - -Grant evidence. The milestones require: - -- **M1 (24h):** snapshots from hour 0-24 proving sustained operation. -- **M2 (48h):** snapshots from hour 0-48 proving 48-hour stability. -- **M4 (7-day):** full run artifact set (logs + snapshots + start/end metrics). - -The soak validates *stability* — it is the step after the E2E run -(`docs/operations/e2e-testnet-runbook.md`) which validates correctness. -Do not start the soak unless the E2E run has passed its acceptance bar. - ---- +Runs all 5 modules on Sepolia continuously and unattended, with hourly metrics snapshots, to validate stability. The soak follows the E2E run (`docs/operations/e2e-testnet-runbook.md`), which validates correctness; do not start it until the E2E run has passed its acceptance bar. ## How it works -Two Docker containers managed by `docker-compose.soak.yml`: - -- **engine** — the nexum binary with `restart: unless-stopped`. Docker - handles log rotation (json-file driver, 500 MB × 14 files ≈ 7 GB cap) - and automatic crash recovery. -- **snapshotter** — an Alpine container that runs `scripts/soak-snapshot.sh`: - captures a baseline on start, then scrapes `/metrics` every hour and - writes `metrics-snap-.txt` to `docs/operations/soak-reports/` - (bind-mounted from the host so files are immediately accessible). +Two containers managed by `docker-compose.soak.yml`: ---- +- **engine**: the shepherd binary with `restart: unless-stopped`. Docker handles log rotation (json-file driver, 500 MB x 14 files) and crash recovery. +- **snapshotter**: an Alpine container running `scripts/soak-snapshot.sh`: captures a baseline on start, then scrapes `/metrics` every hour to `metrics-snap-.txt` under `docs/operations/soak-reports/` (bind-mounted from the host). ## Pre-flight checklist - [ ] Docker installed and running. -- [ ] Paid RPC endpoint with WebSocket support. Public nodes (e.g. - `wss://ethereum-sepolia-rpc.publicnode.com`) throttle `eth_subscribe` - under sustained load. Alchemy or Infura growth tier recommended. +- [ ] Paid RPC endpoint with WebSocket support (public nodes throttle `eth_subscribe` under sustained load). - [ ] `SEPOLIA_RPC_URL` set in the repo-root `.env`: ```bash echo "SEPOLIA_RPC_URL=wss://eth-sepolia.g.alchemy.com/v2/YOUR_KEY" >> .env ``` - Docker Compose reads `.env` automatically and forwards the variable - into the engine container. -- [ ] ≥ 20 GB free disk (engine logs capped at ~7 GB by Docker log - rotation; snapshots are negligible). -- [ ] Machine will not sleep: - - **macOS:** System Settings → Battery → Prevent automatic sleep when power - adapter is connected. - - **Linux:** `systemd-inhibit --what=sleep` or configure Docker to start - on boot (`sudo systemctl enable docker`). -- [ ] E2E run has passed its acceptance bar (see `e2e-testnet-runbook.md`). -- [ ] **Set `SHEPHERD_IMAGE` to an image that exists.** The compose - default (`ghcr.io/nullislabs/shepherd:latest`) is only published on - pushes to `main`; until a release lands there, `pull` fails with - `denied`. Two working options: - - **(a) Build locally from the soak commit** (recommended — the image - digest in the evidence then provably matches the reviewed tree): +- [ ] >= 20 GB free disk (engine logs cap at ~7 GB via rotation). +- [ ] Machine will not sleep (macOS: prevent sleep on power adapter; Linux: `systemd-inhibit --what=sleep` or `sudo systemctl enable docker`). +- [ ] E2E run has passed its acceptance bar. +- [ ] `SHEPHERD_IMAGE` set to an image that exists. The compose default (`ghcr.io/nullislabs/shepherd:latest`) is only published on pushes to `main`; until then, `pull` fails with `denied`. Either build locally from the soak commit: ```bash docker build -t shepherd-soak:$(git rev-parse --short HEAD) . echo "SHEPHERD_IMAGE=shepherd-soak:$(git rev-parse --short HEAD)" >> .env ``` - - **(b) Publish via CI:** trigger the `docker` workflow with - `gh workflow run docker.yml --ref develop`, wait for it to push, then - pin the tag it printed: - ```bash - echo "SHEPHERD_IMAGE=ghcr.io/nullislabs/shepherd:sha-abc1234" >> .env - docker compose -f docker-compose.soak.yml pull - ``` - - Record the resolved image in the evidence set either way: +or publish via CI (`gh workflow run docker.yml --ref develop`), then pin the printed tag and `docker compose -f docker-compose.soak.yml pull`. Record the resolved image: ```bash docker inspect --format '{{.Config.Image}} {{.Image}}' soak-engine \ > docs/operations/soak-reports/image-pin.txt # after `up` ``` ---- - ## Starting the soak ```bash docker compose -f docker-compose.soak.yml up -d -``` - -Docker starts the engine, waits for it to become healthy (up to 90 s), then -starts the snapshotter which immediately captures `metrics-start-.txt`. - -Check it started cleanly: - -```bash docker compose -f docker-compose.soak.yml ps -# Both services should show "Up" and engine shows "(healthy)" +# Both services show "Up"; engine shows "(healthy)". ``` ---- - ## Monitoring -**Follow engine logs live:** +Follow engine logs: ```bash docker compose -f docker-compose.soak.yml logs -f engine ``` -**Filter per-module activity markers:** +Filter per-module markers (`docker logs`, not `docker compose logs`, so jq is not fed the service-name prefix; the JSON formatter flattens fields, message at `.message`): ```bash -# `docker logs` (not `docker compose logs`): compose prefixes every -# line with the service name, which breaks jq. The engine's JSON -# formatter flattens event fields to the top level, so the message -# lives at `.message`. docker logs -f soak-engine \ | jq -r 'select(.message // "" | test("watch:|submitted:|dropped:|backoff:|TRIGGERED")) | .message' ``` -**Check snapshot count:** +Snapshot count (expect one file per completed hour; >= 23 at 24 h): ```bash ls docs/operations/soak-reports/metrics-snap-*.txt | wc -l ``` -Expect one file per completed hour. At 24 h you should see ≥ 23 files. - -**Scrape live metrics:** - -```bash -curl http://127.0.0.1:9100/metrics -``` - -**Check both containers are alive:** - -```bash -docker compose -f docker-compose.soak.yml ps -``` - -**Memory evidence (start once, right after `up`):** - -The Prometheus exporter has no process collector, so `/metrics` carries -no RSS — without this loop there is no data to prove memory stayed flat -across 7 days. Run it on the host: +Memory evidence (start once, right after `up`). The exporter has no process collector, so `/metrics` carries no RSS; this host-side loop is the only memory record: ```bash nohup sh -c 'while true; do @@ -151,18 +71,14 @@ done' >/dev/null 2>&1 & echo $! > docs/operations/soak-reports/.memory-loop.pid ``` ---- - ## Stopping cleanly ```bash -# Capture final metrics before stopping. +# Final metrics before stopping. curl -sf http://127.0.0.1:9100/metrics \ > "docs/operations/soak-reports/metrics-end-$(date -u +%Y%m%dT%H%M%SZ).txt" -# Capture the restart record: RestartCount > 0 means Docker recovered a -# crash during the run — that must be visible in the evidence, not -# discovered by a reviewer noticing counters reset between snapshots. +# Restart record: RestartCount > 0 means Docker recovered a crash; that must be in the evidence. docker inspect soak-engine --format \ 'restarts={{.RestartCount}} started={{.State.StartedAt}} oom={{.State.OOMKilled}}' \ > "docs/operations/soak-reports/engine-state-$(date -u +%Y%m%dT%H%M%SZ).txt" @@ -170,97 +86,54 @@ docker inspect soak-engine --format \ # Stop the host-side memory loop. kill "$(cat docs/operations/soak-reports/.memory-loop.pid)" 2>/dev/null || true -# Bring everything down (SIGINT → graceful shutdown → SIGKILL after 30 s). +# Bring everything down (SIGINT -> graceful shutdown -> SIGKILL after 30 s). docker compose -f docker-compose.soak.yml down ``` ---- +## Evidence artefacts -## Evidence artifacts +All files in `docs/operations/soak-reports/`: -All files in `docs/operations/soak-reports/` constitute the grant evidence: - -| Artifact | Pattern | Purpose | +| Artefact | Pattern | Purpose | |---|---|---| -| Engine log | `engine.log.gz` (see below) | Full operation history | +| Engine log | `engine.log.gz` | Full operation history | | Image pin | `image-pin.txt` | Image + digest the run executed | | Baseline metrics | `metrics-start-.txt` | Counter values at t=0 | -| Hourly snapshots | `metrics-snap-.txt` × N | Hourly Prometheus scrapes | +| Hourly snapshots | `metrics-snap-.txt` x N | Hourly Prometheus scrapes | | Final metrics | `metrics-end-.txt` | Counter values at shutdown | -| Memory samples | `memory.log` | Hourly RSS/CPU — proves no leak | +| Memory samples | `memory.log` | Hourly RSS/CPU | | Restart record | `engine-state-.txt` | RestartCount / OOMKilled | +Save the engine log compressed (uncompressed it approaches the ~7 GB cap; attach the `.gz`, do not commit it): + ```bash -# Save the engine log compressed. Uncompressed it can approach the -# ~7 GB rotation cap — do NOT commit it to the repo; attach the .gz to -# the evidence bundle. Note rotation drops anything past the cap, so -# archive before the run ends if log volume runs high. -docker logs soak-engine 2>&1 | gzip \ - > docs/operations/soak-reports/engine.log.gz +docker logs soak-engine 2>&1 | gzip > docs/operations/soak-reports/engine.log.gz ``` -These satisfy: - -- **M1 (24h):** `metrics-snap-*.txt` files timestamped within 0-24 h of `metrics-start-*.txt`. -- **M2 (48h):** `metrics-snap-*.txt` files timestamped within 0-48 h. -- **M4 (7-day):** The full artifact set above covering ≥ 7 days of uptime. - -To extract the shutdown summary from the log: +Extract the shutdown summary: ```bash -docker compose -f docker-compose.soak.yml logs engine \ - | grep "graceful shutdown complete" | tail -1 +docker compose -f docker-compose.soak.yml logs engine | grep "graceful shutdown complete" | tail -1 ``` ---- - ## Troubleshooting -**Engine container exited early:** +Engine container exited early: ```bash docker compose -f docker-compose.soak.yml logs --tail=100 engine ``` -Common causes: +- OOM kill: `docker inspect soak-engine | jq '.[0].State'`, look for `OOMKilled: true`. Raise the `memory` limit in `docker-compose.soak.yml` or reduce module count. +- RPC errors: look for `connection refused` / `rate limit`. Switch to a paid endpoint. +- WASM trap: look for `module trapped` / `module poisoned`. File a bug. -- OOM kill: `docker inspect soak-engine | jq '.[0].State'` — look for - `OOMKilled: true`. Increase the `memory` limit in `docker-compose.soak.yml` - or reduce module count. -- RPC errors: look for `connection refused` or `rate limit` in the log. - Switch to a paid endpoint with higher rate limits. -- WASM trap: look for `module trapped` or `module poisoned`. File a bug. - -**Snapshotter not producing files:** - -Verify the engine is healthy and the metrics port is up: - -```bash -curl -v http://127.0.0.1:9100/metrics -docker compose -f docker-compose.soak.yml ps -``` - -**Restarting an interrupted run:** - -Both services carry `restart: unless-stopped`, so an engine crash is -recovered automatically — Docker restarts it, the snapshotter keeps -looping through failed scrapes until the engine is healthy again, and -no operator action is needed. Check whether this happened with: - -```bash -docker inspect soak-engine --format 'restarts={{.RestartCount}} oom={{.State.OOMKilled}}' -``` +Snapshotter not producing files: verify the engine is healthy and the metrics port is up (`curl -v http://127.0.0.1:9100/metrics`). -Manual intervention is only needed when the operator stopped the stack -(`down`) or the host rebooted without Docker auto-start: +Restarting an interrupted run: both services carry `restart: unless-stopped`, so an engine crash recovers automatically. Manual intervention is only needed after an operator `down` or a host reboot without Docker auto-start: ```bash docker compose -f docker-compose.soak.yml up -d ``` -The engine's local store is in the `soak-state` Docker volume and -persists across restarts — no state is lost. A new -`metrics-start-.txt` appears only when the snapshotter *container* -restarts (crash or manual `up`), not on engine-only recoveries. -Preserve all files from every run — reviewers can see the combined -coverage, and the `engine-state` snapshot explains any counter resets. +The local store lives in the `soak-state` Docker volume and persists across restarts. A new `metrics-start-.txt` appears only when the snapshotter container restarts, not on engine-only recoveries. Preserve files from every run; the `engine-state` snapshot explains any counter resets. diff --git a/docs/production.md b/docs/production.md index 1392dde5..ab2d73d7 100644 --- a/docs/production.md +++ b/docs/production.md @@ -1,62 +1,24 @@ -# Production deployment guide - -Operator handbook for running `nexum` (Shepherd) in -production. Focused on **concrete artefacts** — unit files, -backup recipes, alert rules — not the design rationale, which -lives in `docs/06-production-hardening.md` (resource enforcement, -restart policy, RPC resilience, logging + metrics design). - -Audience: someone deploying Shepherd onto a Linux host or a -container orchestrator for the first time, with the assumption -that the runtime, modules, and module manifests are already -known-good (M3 + M4 milestones complete; module developer's -handbook is `docs/tutorial-first-module.md`). - ---- - -## 1. Pre-flight checklist - -Before launching: - -- [ ] **Engine binary built in `--release`** mode. - `cargo build -p nexum-cli --release` → `target/release/nexum`. -- [ ] **All module artefacts present** under - `target/wasm32-wasip2/release/` and content-addressable - (the operator pins the sha256 in each module's manifest - `[module] component = "sha256:..."` once 0.3 verification - lands; for 0.2 the field exists but is not enforced). -- [ ] **`engine.toml`** (the production-shape config) exists with: - - `[engine] state_dir = "/var/lib/shepherd"` (or equivalent - persistent path; never `/tmp`). - - `[engine] log_level = "info"` (NOT debug — see §5). - - `[engine.metrics] enabled = true` and `bind_addr` on - `127.0.0.1:9100` (NOT `0.0.0.0` — see §7). - - One `[chains.]` entry per chain you intend to - subscribe to, with a **paid** WS URL (Alchemy / Infura / - QuickNode — public nodes will throttle under sustained - load, see §6). - - One `[[modules]]` entry per module to load. -- [ ] **`/var/lib/shepherd`** exists, writable by the engine's - service user, and on a volume large enough for the local-store - growth budget (§4). -- [ ] **A Prometheus instance** scraping the engine's `/metrics` - endpoint (§7) and an alert pipeline pointed at the rules in §9. -- [ ] **A log aggregator** ingesting the engine's JSON stdout - (§5) — stdout, not a file written by the engine. -- [ ] **An on-call runbook reference** — link to this document - and to `docs/operations/m3-testnet-runbook.md` (testnet - validation, useful for staging deploys). - ---- - -## 2. Process-level deploy: systemd unit +# Production deployment + +Operator handbook for running the `shepherd` binary in production: systemd unit, state backup, observability wiring. The hardening design (resource enforcement, restart policy, RPC resilience, error model) is in [`docs/06-production-hardening.md`](./06-production-hardening.md); the `engine.toml` reference is in [`docs/deployment.md`](./deployment.md); containers in [`docs/deployment/docker.md`](./deployment/docker.md). + +## 1. Pre-flight + +- Engine built in release: `cargo build -p shepherd --release` gives `target/release/shepherd`. +- Module and adapter `.wasm` artefacts present under `target/wasm32-wasip2/release/`. +- `engine.toml` with `state_dir` on a persistent path (never `/tmp`), `log_level = "info"`, `[engine.metrics] enabled = true` and `bind_addr = "127.0.0.1:9100"`, one `[chains.]` per subscribed chain with a paid RPC URL, one `[[modules]]` per module, and the `[[adapters]]` cow entry. +- The `state_dir` exists and is writable by the service user. +- A Prometheus instance scraping `/metrics` (§6) with the alert rules in §7. +- A log aggregator ingesting the engine's JSON stdout (§5). + +## 2. systemd unit `/etc/systemd/system/shepherd.service`: ```ini [Unit] -Description=Shepherd (nexum) - CoW Protocol off-chain automation runtime -Documentation=https://github.com/bleu/nullis-shepherd +Description=Shepherd CoW Protocol automation runtime +Documentation=https://github.com/nullislabs/shepherd After=network-online.target Wants=network-online.target @@ -64,52 +26,39 @@ Wants=network-online.target Type=simple User=shepherd Group=shepherd - -# Working directory + binary. WorkingDirectory=/opt/shepherd -ExecStart=/opt/shepherd/bin/nexum \ - --engine-config /etc/shepherd/engine.toml - -# Graceful shutdown — engine handles SIGINT/SIGTERM by: -# 1. closing chain subscription tasks, -# 2. finishing the in-flight dispatch, -# 3. writing `last_dispatched_block:{chain_id}` to local-store, -# 4. logging `graceful shutdown complete ...` and exiting 0. -# Give it 30 s — production runs can have ~5 s of in-flight RPC. +ExecStart=/opt/shepherd/bin/shepherd --engine-config /etc/shepherd/engine.toml + +# SIGINT/SIGTERM ends the event loop between dispatches: it drains the +# in-flight dispatch, commits the last_dispatched_block cursor, and exits 0. +# 30s covers in-flight RPC. KillSignal=SIGINT TimeoutStopSec=30s -# Hardening +# Hardening. NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true PrivateDevices=true ReadWritePaths=/var/lib/shepherd -# Engine binds 127.0.0.1:9100 for metrics. No other listeners. RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX LockPersonality=true -MemoryDenyWriteExecute=false # wasmtime JIT requires writable+executable memory pages +MemoryDenyWriteExecute=false # wasmtime JIT needs writable-executable pages -# Restart policy — supervisor handles per-module poison/restart -# itself, but if the host process exits non-zero (panic, OOM, -# etc.) restart after 5 s. RestartSec=0 would loop fast on -# config errors. +# The supervisor restarts poisoned modules itself; this restarts the host +# process on a non-zero exit. RestartSec avoids a fast loop on config errors. Restart=on-failure RestartSec=5s -# Resource caps (defence in depth — wasmtime is already capping -# per-module memory at 64 MiB and fuel at ~1B inst/event). +# Defence in depth on top of the per-module wasmtime caps. LimitNOFILE=65536 MemoryMax=2G CPUQuota=200% -# Environment Environment=RUST_BACKTRACE=1 -# RUST_LOG overrides engine.toml::log_level if set. Leave unset -# in production; tune via the config file so the change is -# auditable. -# Environment=RUST_LOG=info,nexum_runtime=debug +# RUST_LOG overrides engine.toml log_level; leave unset so the config is +# the single auditable source. [Install] WantedBy=multi-user.target @@ -121,13 +70,11 @@ Bring up: sudo useradd -r -s /usr/sbin/nologin -d /var/lib/shepherd shepherd sudo install -d -o shepherd -g shepherd /var/lib/shepherd sudo install -d -o shepherd -g shepherd /opt/shepherd/bin -sudo install -m 0755 -o shepherd -g shepherd \ - target/release/nexum /opt/shepherd/bin/ +sudo install -m 0755 -o shepherd -g shepherd target/release/shepherd /opt/shepherd/bin/ sudo install -d /etc/shepherd -sudo install -m 0644 -o root -g root engine.toml /etc/shepherd/ +sudo install -m 0644 engine.toml /etc/shepherd/ sudo systemctl daemon-reload sudo systemctl enable --now shepherd -sudo systemctl status shepherd ``` Tail the logs: @@ -136,334 +83,60 @@ Tail the logs: journalctl -u shepherd -f --output=json | jq '.MESSAGE | fromjson?' ``` ---- - -## 3. Container deploy: Docker Compose - -> **Status note:** the official Dockerfile is tracked as a -> separate issue. Until it lands, build the image locally with -> the multi-stage recipe below; the Compose file is forward- -> compatible with the eventual published image. - -### 3.1 Dockerfile (interim) - -```dockerfile -# syntax=docker/dockerfile:1.6 -FROM rust:1.86-slim-bookworm AS build -WORKDIR /src -RUN apt-get update && apt-get install -y --no-install-recommends \ - pkg-config libssl-dev cmake clang \ - && rm -rf /var/lib/apt/lists/* -RUN rustup target add wasm32-wasip2 -COPY . . -RUN cargo build -p nexum-cli --release -# Build all 5 modules. Add yours here. -RUN cargo build -p twap-monitor --target wasm32-wasip2 --release \ - && cargo build -p ethflow-watcher --target wasm32-wasip2 --release \ - && 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 - -FROM debian:bookworm-slim AS runtime -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates tini \ - && rm -rf /var/lib/apt/lists/* \ - && useradd -r -s /usr/sbin/nologin -d /var/lib/shepherd shepherd \ - && install -d -o shepherd -g shepherd /var/lib/shepherd -COPY --from=build /src/target/release/nexum /usr/local/bin/ -COPY --from=build /src/target/wasm32-wasip2/release/*.wasm /opt/shepherd/modules/ -COPY --from=build /src/modules /opt/shepherd/manifests -USER shepherd -WORKDIR /var/lib/shepherd -EXPOSE 9100 -ENTRYPOINT ["/usr/bin/tini", "--", "nexum"] -CMD ["--engine-config", "/etc/shepherd/engine.toml"] -``` - -### 3.2 docker-compose.yml - -```yaml -version: "3.9" -services: - shepherd: - build: . - image: shepherd:latest - restart: unless-stopped - volumes: - - shepherd-state:/var/lib/shepherd - - ./engine.toml:/etc/shepherd/engine.toml:ro - ports: - # Bind metrics endpoint to the host loopback only — - # Prometheus scrapes it via docker network, no public - # exposure. - - "127.0.0.1:9100:9100" - stop_signal: SIGINT - stop_grace_period: 30s - healthcheck: - # Metrics endpoint serves a Prometheus exposition page; - # treating a successful GET as liveness is good enough - # until a dedicated /health endpoint lands. - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9100/metrics > /dev/null"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 15s - deploy: - resources: - limits: - memory: 2G - cpus: "2.0" - - prometheus: - image: prom/prometheus:latest - volumes: - - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - - ./prometheus-rules.yml:/etc/prometheus/rules.yml:ro - - prometheus-data:/prometheus - ports: - - "127.0.0.1:9090:9090" - -volumes: - shepherd-state: - prometheus-data: -``` - -`prometheus.yml`: - -```yaml -scrape_configs: - - job_name: shepherd - scrape_interval: 15s - static_configs: - - targets: ["shepherd:9100"] -rule_files: - - /etc/prometheus/rules.yml -``` +For the container path (Docker Compose, image tags, `.env` wiring) see [`docs/deployment/docker.md`](./deployment/docker.md). ---- +## 3. State backup (`redb`) -## 4. State store backup (`redb`) +The local-store is a single redb file at `/local-store.redb`. It holds per-module keys (`watch:`, `submitted:`, `dropped:`, `backoff:`, `last_dispatched_block:{chain_id}`); losing it forces a from-scratch resync as modules re-discover state from chain logs. -The local-store is a single redb file at -`/ls.redb`. It accumulates per-module -`watch:`, `submitted:`, `dropped:`, `backoff:`, `last:`, and -`last_dispatched_block:{chain_id}` keys; losing it on a -production module forces a from-scratch resync (twap-monitor -re-discovers `watch:` from the next `ConditionalOrderCreated` -log; stop-loss re-issues a `submitted:` write if the trigger -fires again). - -### 4.1 Cold backup (recommended for first deploy + before upgrades) - -The engine writes to redb only during dispatch. On a SIGINT the -graceful shutdown path drains in-flight dispatches and the file -becomes quiescent within ≤ 5 s. +Cold backup (recommended before upgrades). The engine writes to redb only during dispatch, and the graceful shutdown drains in-flight dispatches, so the stopped file is quiescent: ```bash -sudo systemctl stop shepherd # or: docker compose stop shepherd -sudo cp /var/lib/shepherd/ls.redb /backup/shepherd-ls-$(date -u +%Y%m%dT%H%M%SZ).redb +sudo systemctl stop shepherd +sudo cp /var/lib/shepherd/local-store.redb \ + /backup/shepherd-$(date -u +%Y%m%dT%H%M%SZ).redb sudo systemctl start shepherd ``` -Cold copies are byte-identical to a fresh database and need no -verification. - -### 4.2 Hot backup (live process) - -redb 2.x is single-file MVCC + a commit-on-disk log; an `cp` -under a live writer can capture an in-flight commit and produce -a database that fails `Database::check_integrity` on restore. -For the M4 release the supported path is: - -1. Send SIGSTOP to the engine PID (`kill -STOP `). -2. `cp` the file (redb's on-disk format is consistent at any - commit boundary, and SIGSTOP guarantees no writer is mid- - commit). -3. Send SIGCONT (`kill -CONT `). - -The pause-and-copy window is ≤ 1 s on a ~100 MiB local-store -(typical 30-day production size). Subscribers won't drop because -the alloy WS connection survives a brief process stop. - -A `redb::Database::backup`-style API (snapshot from within a -read transaction) is on the roadmap — track in upstream redb -releases > 2.6. - -### 4.3 Restore + integrity check +Live copy. A plain `cp` under a live writer can capture an in-flight commit; pause the process first: ```bash -sudo systemctl stop shepherd -sudo cp /backup/shepherd-ls-.redb /var/lib/shepherd/ls.redb -sudo -u shepherd /opt/shepherd/bin/nexum \ - --engine-config /etc/shepherd/engine.toml \ - --check-integrity-only # planned 0.3 flag; manual call today: -# rust: redb::Database::open(path)?.check_integrity()? -> bool -sudo systemctl start shepherd +kill -STOP +cp /var/lib/shepherd/local-store.redb /backup/... +kill -CONT ``` -If the integrity check returns `false`, do **not** start the -engine on the restored file. Roll forward from the previous -known-good snapshot; in the worst case start with an empty -state directory and accept the resync cost above. - -### 4.4 Retention policy (suggested) +The pause window is sub-second on a small store, and the WS connections survive it. Restore by stopping the engine, copying the snapshot back, and restarting. If a restored file does not open, roll forward from the previous snapshot or start with an empty `state_dir` and accept the resync. -- 7 daily cold backups. -- 4 weekly cold backups (rotated every Sunday). -- 12 monthly cold backups. +## 4. Chain-log cursor -Total cost on a 100 MiB store ≈ 23 × 100 MiB = 2.3 GiB. - ---- +A `resume` subscription persists its progress under `last_dispatched_block:{chain_id}`, written after each successful dispatch, so a restart resumes from the last committed block. The engine backfills the gap on reconnect (see `docs/06-production-hardening.md`). ## 5. Logs -### 5.1 Format - -The engine emits JSON-formatted `tracing` events on stdout -(unless `--pretty-logs` is passed; only the runbook docs use -that flag). Sample event: - -```json -{ - "timestamp": "2026-06-18T15:30:00.000Z", - "level": "INFO", - "target": "nexum_runtime::supervisor", - "fields": { - "message": "init succeeded", - "module": "twap-monitor" - } -} -``` - -Important fields on every event: - -| Field | Meaning | -|---|---| -| `target` | Crate + module path. Useful filters: `nexum_runtime`, `nexum_runtime::supervisor`, `nexum_runtime::host::impls::cow_api`. | -| `level` | `TRACE` < `DEBUG` < `INFO` < `WARN` < `ERROR`. **Production should never see `ERROR`** from `nexum_runtime::*` (only from third-party crates the supervisor wraps as warnings). | -| `fields.message` | Human-readable summary. Greppable. | -| `fields.module` | Set on every per-module event — supervisor, host calls, guest log emissions. Use this for per-module dashboards. | - -### 5.2 Retention + aggregation - -Two-tier model: - -1. **Hot (last 7 days)** — full INFO + DEBUG. Lives in your - log aggregator (Loki / CloudWatch Logs / Datadog). Used for - incident investigation. -2. **Cold (90 days)** — INFO only, drop DEBUG at ingest time. - S3 / GCS with lifecycle rule to Glacier at 90 days. Used for - audit + post-mortem. - -INFO-level retention sizing: each dispatch produces ~1 KB of -INFO/DEBUG output combined. 5 modules × 1 block / 12 s × 7 -days ≈ 200 MiB/week. DEBUG roughly doubles this; the cold tier -dropping DEBUG keeps the long-term cost trivial. - -### 5.3 Aggregation pattern: Vector → Loki - -`vector.toml`: - -```toml -[sources.shepherd] -type = "journald" -include_units = ["shepherd.service"] - -[transforms.parse_json] -type = "remap" -inputs = ["shepherd"] -source = ''' - . = parse_json!(.message) -''' - -[transforms.drop_debug_cold] -type = "filter" -inputs = ["parse_json"] -condition = '.level != "DEBUG"' - -[sinks.loki_hot] -type = "loki" -inputs = ["parse_json"] -endpoint = "http://loki:3100" -labels = { app = "shepherd", level = "{{ .level }}", module = "{{ .fields.module }}" } - -[sinks.s3_cold] -type = "aws_s3" -inputs = ["drop_debug_cold"] -bucket = "shepherd-logs-cold" -key_prefix = "year=%Y/month=%m/day=%d/" -compression = "gzip" -``` - ---- - -## 6. RPC selection +The engine emits JSON `tracing` events on stdout (`--pretty-logs` switches to the human format used in the runbooks). Every event carries `target` (crate + module path), `level`, `fields.message`, and `fields.module` on per-module events. Production should not see `ERROR` from `nexum_runtime::*`. -The engine talks to chains exclusively through alloy providers -configured at boot. Public nodes throttle `eth_subscribe` and -`eth_call` aggressively; production deployments **must** use a -paid endpoint. +Aggregate stdout into your log stack (Loki, CloudWatch, Datadog). A Vector journald source parsing the JSON `message` field and routing by `level` is the typical pattern. -> **Prefer `wss://` over `https://` where the provider offers it.** -> A WebSocket URL pushes new blocks via `eth_subscribe(newHeads)`; an -> HTTP URL polls `eth_getBlockByNumber` at the chain's block time -> instead (logs poll `eth_getLogs` on either transport). Both work, so -> HTTP-only endpoints are supported, but push is lower-latency and -> cheaper than polling. Every paid provider exposes both schemes per -> endpoint. +## 6. Metrics -| Provider | Plan recommendation | Notes | -|---|---|---| -| Alchemy | Growth tier (≥ 660M CU/mo) | First-class WS pubsub; SLA-backed. | -| Infura | Developer Plus (≥ 6M req/day) | Solid WS; rate-limits per project key. | -| QuickNode | Discover tier (≥ 25 req/s) | Dedicated endpoints; recommended for multi-chain swarms. | - -`engine.toml`: - -```toml -[chains.11155111] -rpc_url = "wss://eth-sepolia.g.alchemy.com/v2/" - -[chains.42161] -rpc_url = "wss://arb-mainnet.g.alchemy.com/v2/" -``` - -Capacity sizing (per chain): - -- `1` block subscription, always-on. WS. -- `N` chain-log subscriptions, where `N` = number of modules with - `[[subscription]] kind = "chain-log"`. -- `M` `eth_call` per block, where `M` ≈ sum of polling modules' - active orders. The TWAP module's load grows linearly with the - number of registered orders; budget accordingly. - -`shepherd_chain_request_total{outcome="err"}` rate is the -canonical "the RPC is degraded" signal — see §9 alerts. - ---- - -## 7. Metrics + scraping - -`/metrics` is exposed when `[engine.metrics] enabled = true` in -`engine.toml`. **Always** bind to a loopback address; never -`0.0.0.0`. Prometheus scrapes via the loopback / container -network. - -### 7.1 Metric surface +`/metrics` binds when `[engine.metrics] enabled = true`. Always bind loopback, never `0.0.0.0`; Prometheus scrapes over the loopback or container network. The bare `nexum` binary does not register the exporter; run `shepherd`. | Metric | Type | Labels | Meaning | |---|---|---|---| -| `shepherd_event_latency_seconds` | histogram | `module`, `event_kind` | Per-module dispatch latency. p95 > 1 s on a non-RPC-heavy module is suspicious. | -| `shepherd_module_errors_total` | counter | `module`, `error_kind` | All host errors + traps. `error_kind="trap"` = wasmtime trap (fuel / memory / panic); other kinds are the `fault` case labels. | -| `shepherd_module_restarts_total` | counter | `module` | Increments on every `reinstantiate_one` attempt (per-module restart backoff). | -| `shepherd_module_poisoned` | gauge | `module` | `1` if the module has been quarantined per `POISON_MAX_FAILURES=5` / `POISON_WINDOW=10m`. Stays `1` until process restart. | -| `shepherd_dispatch_dropped_total` | counter | `module`, `event_kind` | Events dropped at the dispatch boundary by the per-module rate limit (`[limits.dispatch]`, default `burst=256` / `refill_per_sec=128`). A sustained rate = a source flooding one module; the drop protects the host and never starves other modules. | -| `shepherd_chain_request_total` | counter | `chain_id`, `method`, `outcome` | Every `chain::request` host call. `outcome="err"` rate > 5% = RPC degraded. | -| `shepherd_cow_api_submit_total` | counter | `chain_id`, `outcome` | Every orderbook submit. `outcome="err"` covers both retriable and dropped — drill into supervisor logs to discriminate. | -| `shepherd_stream_reconnects_total` | counter | `kind`, `chain_id`, `module?` | WS reconnect attempts. `kind="block"` is per-chain; `kind="chain-log"` carries the `module` label too. | - -### 7.2 Prometheus config snippet +| `shepherd_event_latency_seconds` | histogram | `module`, `event_kind` | Per-module dispatch latency. | +| `shepherd_dispatch_dropped_total` | counter | `module`, `event_kind` | Events dropped by the per-module dispatch rate limit (`[limits.dispatch]`, default `burst=256` / `refill_per_sec=128`). | +| `shepherd_module_errors_total` | counter | `module`, `error_kind` | Host faults and traps. `error_kind="trap"` is a wasmtime trap; other kinds are fault labels. | +| `shepherd_module_restarts_total` | counter | `module` | Per-module restart attempts. | +| `shepherd_module_poisoned` | gauge | `module` | `1` once a module crosses `[limits.poison]` (default 5 failures / 600 s). Stays `1` until process restart. | +| `shepherd_adapter_errors_total` | counter | `adapter`, `error_kind` | Venue-adapter faults and traps. | +| `shepherd_adapter_restarts_total` | counter | `adapter` | Venue-adapter restart attempts. | +| `shepherd_adapter_poisoned` | gauge | `adapter` | `1` once an adapter is quarantined. | +| `shepherd_chain_request_total` | counter | `chain_id`, `method`, `outcome` | Every `chain::request`. `outcome="err"` rate is the RPC-degraded signal. | +| `shepherd_chain_response_capped_total` | counter | `chain_id`, `method` | Responses rejected for exceeding `[limits.chain] response_body_max_bytes`. | +| `shepherd_stream_reconnects_total` | counter | `kind`, `chain_id`, `module?` | WS/poller reconnects. `kind="block"` is per-chain; `kind="chain-log"` also carries `module`. | + +Prometheus scrape: ```yaml scrape_configs: @@ -473,237 +146,91 @@ scrape_configs: - targets: ["127.0.0.1:9100"] ``` -15 s is conservative; the metrics cardinality is bounded by -modules × chains, which on a 5-module / 2-chain deploy is ~15 -series for the gauges + ~30 for the counters. - ---- - -## 8. Workload-class tuning - -Resource limits today are compile-time constants. Per-module -overrides via `[engine.limits]` are tracked as a 0.3 follow-up -(referenced from `crates/nexum-runtime/src/runtime/limits.rs`). -The tuning advice below is therefore advisory — adjust by -changing the constants in `runtime/limits.rs` and rebuilding, -or by ensuring per-module loads fit within the current -defaults. - -| Class | Modules typical | Fuel/event | Memory cap | Notes | -|---|---|---|---|---| -| **Light indexer** | price-alert, balance-tracker | 200M | 16 MiB | Block-tick poll + 1-2 RPC reads. Defaults are 5× headroom. | -| **TWAP-style polling** | twap-monitor, stop-loss | 1B (default) | 64 MiB (default) | Per-block `getTradeableOrderWithSignature` calls per registered order; long ABI decode + signature work. Defaults sized for this case. | -| **Multi-chain swarm** | 5+ modules × 2+ chains | 2B | 128 MiB | More headroom for parallel dispatch overhead; modules don't share state, but the per-store wasmtime overhead is per-(module, chain). | - -A module that consistently traps `OutOfFuel` is a bug, not a -tuning miss -- open an issue with the supervisor log -snippet rather than raising the fuel budget. The defaults are -already 5-10× the largest observed real-world dispatch. - ---- +## 7. Alerting -## 9. Alerting - -Prometheus alert rules (`prometheus-rules.yml`): +`prometheus-rules.yml`: ```yaml groups: - name: shepherd interval: 30s rules: - # P0: a production module is permanently quarantined. - # Recovery requires operator action (process restart + - # module triage). - alert: ShepherdModulePoisoned - expr: shepherd_module_poisoned > 0 + expr: shepherd_module_poisoned > 0 or shepherd_adapter_poisoned > 0 for: 1m - labels: - severity: page + labels: { severity: page } annotations: - summary: "Shepherd module {{ $labels.module }} is poisoned" - description: | - Module has crossed POISON_MAX_FAILURES traps within - POISON_WINDOW. Engine has stopped dispatching to it. - Investigate: journalctl -u shepherd | jq 'select(.fields.module=="{{ $labels.module }}")' - - # P1: trap rate climbing. Pre-poison signal — gives 5 min - # of warning before ShepherdModulePoisoned fires. + summary: "Shepherd {{ $labels.module }}{{ $labels.adapter }} is poisoned" + - alert: ShepherdModuleTraps expr: rate(shepherd_module_errors_total{error_kind="trap"}[5m]) > 0 for: 5m - labels: - severity: ticket + labels: { severity: ticket } annotations: summary: "Shepherd module {{ $labels.module }} trapping" - description: | - Module is restart-looping. Investigate before - POISON_MAX_FAILURES (5 traps / 10 min) trips. - # P1: RPC layer degraded. Engine keeps running but - # dispatches will degrade; operator should switch - # endpoints or escalate to provider. - alert: ShepherdRpcErrorRate expr: | sum by (chain_id) (rate(shepherd_chain_request_total{outcome="err"}[5m])) - / - sum by (chain_id) (rate(shepherd_chain_request_total[5m])) - > 0.05 + / sum by (chain_id) (rate(shepherd_chain_request_total[5m])) > 0.05 for: 10m - labels: - severity: ticket + labels: { severity: ticket } annotations: summary: "Shepherd RPC error rate > 5% on chain {{ $labels.chain_id }}" - # P1: WS reconnect storm. A flapping endpoint is worse - # than a hard-down one (subscriptions keep partially - # working but events get dropped during reconnect windows). - alert: ShepherdReconnectStorm expr: rate(shepherd_stream_reconnects_total[5m]) > 0.1 for: 5m - labels: - severity: ticket + labels: { severity: ticket } annotations: summary: "Shepherd WS reconnecting frequently" - # P2: orderbook degraded. Modules will retry per the SDK's - # `classify_api_error` taxonomy; this alert fires only on - # sustained errs and is a CoW-side signal more than a - # Shepherd signal. - - alert: ShepherdCowApiErrorRate - expr: | - sum by (chain_id) (rate(shepherd_cow_api_submit_total{outcome="err"}[10m])) - / - sum by (chain_id) (rate(shepherd_cow_api_submit_total[10m])) - > 0.20 - for: 15m - labels: - severity: ticket - annotations: - summary: "Shepherd cow-api submit error rate > 20% on chain {{ $labels.chain_id }}" - - # P2: dispatch latency. Modules with sustained p95 > 5 s - # are usually doing more on-chain reads than budgeted; not - # an outage but worth tuning. - alert: ShepherdDispatchLatency expr: | histogram_quantile(0.95, - sum by (module, le) (rate(shepherd_event_latency_seconds_bucket[10m])) - ) > 5 + sum by (module, le) (rate(shepherd_event_latency_seconds_bucket[10m]))) > 5 for: 15m - labels: - severity: ticket + labels: { severity: ticket } annotations: - summary: "Shepherd module {{ $labels.module }} p95 latency > 5 s" + summary: "Shepherd module {{ $labels.module }} p95 latency > 5s" - # P3: engine absent. Either crashed and systemd hasn't - # restarted yet, or metrics binding failed. - alert: ShepherdDown expr: up{job="shepherd"} == 0 for: 2m - labels: - severity: page + labels: { severity: page } annotations: summary: "Shepherd is down (metrics scrape failing)" ``` -Severity convention: +`page` wakes on-call (poison, down); `ticket` routes during business hours. + +## 8. RPC selection -| Label | Action | -|---|---| -| `page` | On-call wakes up. ShepherdModulePoisoned + ShepherdDown only. | -| `ticket` | Routed to the Shepherd team during business hours. | +The engine reaches chains through alloy providers configured at boot. Public nodes throttle `eth_subscribe` and `eth_call`, so production must use a paid endpoint (Alchemy, Infura, QuickNode). Prefer `wss://` where offered: a WebSocket pushes new blocks via `eth_subscribe(newHeads)`, an HTTP URL polls `eth_getBlockByNumber`; both work, push is lower-latency. `shepherd_chain_request_total{outcome="err"}` is the degradation signal. ---- +Resource caps are engine defaults today; per-module overrides in `[limits]` apply uniformly. A module that consistently traps `OutOfFuel` is a bug, not a tuning miss. -## 10. Operational runbook (common tasks) +## 9. Runbook -### 10.1 Tail a single module's events +Tail one module: ```bash journalctl -u shepherd -f --output=json \ | jq 'select(.MESSAGE | fromjson? | .fields.module == "twap-monitor")' ``` -### 10.2 Reset a poisoned module - -A poisoned module stays poisoned until process restart (M4 -design — no live un-poison API yet). The recovery flow: - -1. Triage the failure: `journalctl -u shepherd | jq 'select(.MESSAGE | fromjson? | .level == "ERROR" or (.fields.message | test("trapped|poisoned")))'`. -2. Fix the underlying bug (in the module's Rust code, or the - manifest config, or the on-chain target). Rebuild the module. -3. Restart the engine: `sudo systemctl restart shepherd`. The - `failure_count` + `failure_timestamps` ring is in-memory and - resets at boot. +Recover a poisoned module: fix the underlying bug, rebuild the artefact, then `sudo systemctl restart shepherd` (the failure ring is in-memory and clears at boot). The engine reads `[[modules]]` and `[[adapters]]` at boot only, so adding a module means editing `engine.toml` and restarting. Logging-level changes also require a restart. -### 10.3 Add a module to a running deploy +## 10. Pre-upgrade -The engine reads `[[modules]]` at boot only. To add a module: +- Read the CHANGELOG for breaking config or manifest changes. +- Cold-backup the local-store (§3). +- Stage the new binary, run it once with the production `engine.toml`, and confirm the supervisor-ready line before Ctrl-C. +- Swap the binary and `sudo systemctl restart shepherd`. +- Watch `journalctl -u shepherd -f` for new ERROR/WARN lines for at least 5 minutes. -1. Build the module's wasm artefact + drop it in the artefacts - directory. -2. Append a `[[modules]]` entry to `engine.toml`. -3. `sudo systemctl restart shepherd`. The graceful shutdown - writes `last_dispatched_block:{chain_id}` so new modules - know which block to start from (if they care). - -A live `engine::reload` API is not in scope for 0.2; tracked as -a 0.3+ follow-up. - -### 10.4 Inspect the local-store contents - -There is no `ls-dump` CLI today. Workarounds: - -- Boot a one-shot Rust script with `redb::Database::open` (read- - only) against the live file. Safe — redb supports concurrent - readers + a single writer. -- Stop the engine + use any redb inspector tool against the - copy. - -### 10.5 Bump the log level live - -Logging-level changes today require an engine restart (the -filter is wired at boot). On 0.3, a SIGHUP handler will re-read -`engine.toml::log_level`. Until then: - -```bash -sudo sed -i 's/log_level = "info"/log_level = "info,nexum_runtime=debug"/' \ - /etc/shepherd/engine.toml -sudo systemctl restart shepherd -# revert when the investigation is done -``` +## References ---- - -## 11. Pre-upgrade checklist - -Before bumping `nexum` between minor versions: - -- [ ] Read the CHANGELOG for breaking config / manifest - changes. -- [ ] Cold-backup the local-store per §4.1. -- [ ] Stage the new binary in `/opt/shepherd/bin/nexum.new` - + run it once with `--engine-config /etc/shepherd/engine.toml` - + Ctrl-C after `supervisor ready modules=N chains=M` to - validate the config still parses. Roll forward only if the - ready line appears. -- [ ] `mv /opt/shepherd/bin/nexum.new /opt/shepherd/bin/nexum`. -- [ ] `sudo systemctl restart shepherd`. -- [ ] Watch `journalctl -u shepherd -f` for ≥ 5 min after - restart. Look for any new ERROR / WARN lines that weren't - present pre-upgrade. - ---- - -## 12. References - -- Architectural rationale: `docs/06-production-hardening.md` -- Per-module developer handbook: `docs/tutorial-first-module.md` -- Testnet runbooks (staging validation): - - `docs/operations/m2-testnet-runbook.md` - - `docs/operations/m3-testnet-runbook.md` - - `docs/operations/e2e-testnet-runbook.md` (full 5-module run) -- ADRs touching production posture: - - `docs/adr/0001-engine-toml-separate-from-nexum-toml.md` - - `docs/adr/0002-provider-pool-transport-by-scheme.md` - - `docs/adr/0003-local-store-namespacing.md` +- Hardening design: `docs/06-production-hardening.md` +- Module handbook: `docs/tutorial-first-module.md` +- ADR-0001, ADR-0002, ADR-0003 (`docs/adr/`) diff --git a/docs/qa-signoff.md b/docs/qa-signoff.md deleted file mode 100644 index 808d03e6..00000000 --- a/docs/qa-signoff.md +++ /dev/null @@ -1,80 +0,0 @@ -# Internal QA sign-off - pre-upstream review pass - -**Branch**: `qa/cleanup` (tip of M2 + M3 stack) -**Generated**: 2026-06-17 - -## Mechanical checks (workspace-wide) - -| Check | Status | Notes | -|---|---|---| -| `cargo fmt --all --check` | ✅ | One pre-existing drift in `supervisor/tests.rs` (M1) plus M2/M3 leaf modules; bulk applied as single cleanup commit. | -| `cargo clippy --all-targets --workspace -- -D warnings` | ✅ | Clean. | -| `cargo test --workspace` | ✅ | 145 host tests + 1 doctest passing. | -| Em-dashes in `crates/`, `modules/`, `docs/` | ✅ | 0. One was in `price-alert/strategy.rs:4` (mine), fixed. | -| Em-dashes in `wit/**.wit` | ⚠ | 3 in mfw78's M1 prose. Intentionally left alone; flag for him in upstream review. | -| `warn(unused_crate_dependencies)` on every crate root | ✅ | sdk, sdk-test, nexum-engine, twap, ethflow, price-alert, balance-tracker, stop-loss. | -| WASM build (`wasm32-wasip2 --release`) | ✅ | All 5 modules build. Sizes: twap 314 KB, ethflow 282 KB, stop-loss 311 KB, price-alert 215 KB, balance-tracker 102 KB. | -| String-wrapped errors outside WIT boundary | ✅ | All hits in `crates/nexum-runtime/src/host/impls/*` (FFI boundary - exception per rust-idiomatic skill). No leaks in SDK or modules. | - -## Per-PR shape - -| PR | Module | Tests | Strategy/lib split | Notes | -|---|---|---|---|---| -| #2-#7 | twap-monitor M2 | 13 | ❌ no split until #24 | Stacked TWAP. Strategy ↔ lib.rs split landed at #24. | -| #8-#10 | ethflow-watcher M2 | 7 | ❌ no split until #25 | Split landed at #25. | -| #11 | module.toml manifests | - | - | Both M2 modules have manifests with capability + subscription comments. ✅ | -| #12 | shepherd-sdk skeleton | - | - | Public surface present. | -| #13 | sdk helpers extraction | - | - | OK. | -| #14 | M2 on SDK | - | - | M2 modules now consume `shepherd_sdk::cow` / `chain` helpers. | -| #15 | shepherd-sdk-test (MockHost) | 8 | - | Full mock surface; matches Host trait. | -| #16 | SDK docs | - | - | README + rustdoc on public items. **See architectural finding below.** | -| #17 | deployment guide | - | - | `docs/06-production-hardening.md` exists. | -| #18 | price-alert | 11 | ❌ no split until #22 | Refactor at #22. | -| #19 | balance-tracker | 13 | ❌ never refactored | Acceptable: balance-tracker has no submit path, dispatch matrix simpler. **Optional follow-up: bring to same shape for consistency.** | -| #20 | tutorial | - | - | Rewritten as guided tour at #23; reads top-to-bottom against real stop-loss source. | -| #21 | rust-idiomatic compliance | - | - | em-dash purge, thiserror, warn(unused_crate_dependencies). ✅ | -| #22 | price-alert host-trait | 16 | ✅ | Reference shape. | -| #23 | stop-loss | 7 | ✅ | First module with the full M3 surface (chain + local-store + cow-api + logging). | -| #24 | twap-monitor host-trait | 20 | ✅ | Strategy split; 7 new MockHost dispatch tests. | -| #25 | ethflow-watcher host-trait | 12 | ✅ | Strategy split; 5 new MockHost tests including PR #10 c5e4d7d regression guard. | - -## Architectural finding - DOC ↔ CODE divergence in M3 SDK - -**`docs/05-sdk-design.md` describes a 2-layer SDK that does not exist**: - -- `nexum-sdk` (universal) + `shepherd-sdk` (CoW extension) - we shipped only `shepherd-sdk`. No `nexum-sdk` crate. -- `#[nexum::module]` / `#[shepherd::module]` proc macros - not implemented. We use raw `wit_bindgen::generate!` + `WitBindgenHost` adapter pattern. -- Full alloy `Provider` backed by `HostTransport` - not implemented. We pass JSON-RPC method + params strings via `ChainHost::request`. -- Typed local-store helpers (serde over raw bytes) - not implemented. Modules call `host.set(&key, &value)` with raw bytes. -- Typed `Signer` for key management - not implemented. Modules use `Signature::PreSign` / `Signature::Eip1271`; no key custody on the module side. - -**Two paths**, mfw78's call: - -1. Update `docs/05-sdk-design.md` to describe what M3 actually shipped (Host traits + helpers + MockHost; defer proc macros, Provider, Signer, `nexum-sdk` split to M5+). -2. Or treat the doc as M5 north-star and implement the missing layers as part of M4 / M5 scope. - -Doc is currently aspirational; code is M3-scoped. They need to agree before upstream review. - -## Outstanding / deferred - -| Item | Issue | Status | -|---|---|---| -| `#[non_exhaustive]` batch on SDK public enums (`Fault`, `LogLevel`, `PollOutcome`, `RetryAction`) | - | Held until just before upstream cut. | -| WIT-file em-dashes in upstream prose (3 occurrences) | - | Ask mfw78. | -| balance-tracker host-trait refactor (consistency with other 4 modules) | - | Optional follow-up. | -| PR description template (mfw78's "What does this PR do? / Why / Changes / Breaking changes / Testing / AI disclosure") | - | Cosmetic; could template-bump existing PR bodies before upstream push. | -| ADR for the M3 Host trait surface | - | None today. Worth one short ADR (0009 candidate) capturing the strategy/lib split decision before upstream review. | - -## Sign-off - -| Area | Ready for upstream? | -|---|---| -| M2 modules (twap + ethflow + manifests) | ✅ once PRs #24 + #25 land | -| M3 SDK + examples | ✅ pending doc 05 reconciliation | -| Tutorial | ✅ | -| Rust-idiomatic compliance | ✅ | -| Tests + builds | ✅ | -| Docs | ⚠ doc 05 vs code mismatch must resolve | -| ADRs | ⚠ M3 host trait surface lacks an ADR | - -**Recommendation**: address the two ⚠ items (doc 05 + ADR-0009) before opening the consolidated upstream PR. Everything else is green. diff --git a/docs/sdk.md b/docs/sdk.md index e752c5fa..d3a5526e 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -1,115 +1,67 @@ -# The module SDK: nexum-sdk + shepherd-sdk +# The module SDK: nexum-sdk + videre-sdk -`nexum-sdk` is the guest-side library every module consumes: typed -primitives, ABI helpers, an effect-trait seam for testing, the -per-module adapter macro, and a `prelude` that keeps boilerplate out -of module crates. `shepherd-sdk` layers the CoW Protocol surface on -top; modules that touch the orderbook depend on both crates and -import each directly (nothing is re-exported between them). +`nexum-sdk` is the guest-side library every module consumes: typed primitives, ABI helpers, an effect-trait seam for testing, the `#[nexum_sdk::module]` attribute macro and per-module adapter macro, and a `prelude` that keeps boilerplate out of module crates. `videre-sdk` layers the venue surface on top: the typed venue client, the intent-body codec, the `VenueAdapter` seam and the keeper run. Modules that talk to a venue depend on both crates and import each directly (nothing is re-exported between them). -This page is the entry point. The full API reference is the rustdoc -site under `target/doc/nexum_sdk/` and `target/doc/shepherd_sdk/`, -generated by: +This page is the entry point. The full API reference is the rustdoc site under `target/doc/nexum_sdk/` and `target/doc/videre_sdk/`, generated by: ```sh -RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk --no-deps --open +RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p nexum-sdk -p videre-sdk -p nexum-module-macros -p videre-macros --no-deps --open ``` +## Authoring a module + +Modules are authored with the `#[nexum_sdk::module]` attribute (re-exported from `nexum-module-macros`). Apply it to an inherent `impl` block whose associated functions are the named event handlers - `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message` - each taking its wit-bindgen payload and returning `Result<(), Fault>`. The macro generates the rest of the per-cdylib glue: the `wit_bindgen::generate!` call, the `bind_host_via_wit_bindgen!()` adapter, a `Guest` impl whose `on_event` dispatches to whichever handlers are present (absent handlers no-op), and `export!`. See [doc 05](05-sdk-design.md#the-nexum_sdkmodule-macro) for a worked example and the `nexum-module-macros` rustdoc for the fine print. + ## Supported host capabilities -The SDK is host-neutral - it does not call wit-bindgen-generated -functions directly. Instead, it exposes traits that mirror the -on-the-wire host interfaces, and modules adapt their wit-bindgen -imports to the traits at the cdylib boundary (the -`bind_host_via_wit_bindgen!` / `bind_cow_host_via_wit_bindgen!` -macros generate that adapter). The traits in -[`nexum_sdk::host`][host-doc] and [`shepherd_sdk::cow`][cow-doc] are: +The SDK is host-neutral - it does not call wit-bindgen-generated functions directly. Instead, it exposes traits that mirror the on-the-wire host interfaces, and modules adapt their wit-bindgen imports to the traits at the cdylib boundary (the `bind_host_via_wit_bindgen!` macro generates that adapter). The traits in [`nexum_sdk::host`][host-doc] and the venue seam in [`videre_sdk::client`][client-doc] are: | Trait | Mirrors | What it does | |---|---|---| -| `ChainHost` | `nexum:host/chain@0.2.0` | JSON-RPC dispatch (`eth_call`, `eth_getLogs`, …) | -| `LocalStoreHost` | `nexum:host/local-store@0.2.0` | Per-module key-value store | -| `LoggingHost` | `nexum:host/logging@0.2.0` | Structured log lines tagged by module | +| `ChainHost` | `nexum:host/chain@0.1.0` | JSON-RPC dispatch (`eth_call`, `eth_getLogs`, …) | +| `LocalStoreHost` | `nexum:host/local-store@0.1.0` | Per-module key-value store | +| `LoggingHost` | `nexum:host/logging@0.1.0` | Structured log lines tagged by module | | `Host` | supertrait | Bundles the three core traits; blanket impl | -| `CowApiHost` (shepherd-sdk) | `shepherd:cow/cow-api@0.2.0` | Orderbook submission (`POST /api/v1/orders`) | -| `CowHost` (shepherd-sdk) | supertrait | `Host` + `CowApiHost` for orderbook strategies | +| `VenueTransport` (videre-sdk) | `videre:venue/client@0.1.0` | Quote, submit, observe, status and cancel against an installed venue adapter | -A module declaring `[capabilities].required = ["chain", "local-store", -"cow-api", "logging"]` in its `module.toml` matches the host trait -seam one-for-one. +A module declaring `[capabilities].required = ["chain", "local-store", "client", "logging"]` in its `module.toml` matches the host trait seam one-for-one. -[host-doc]: ../target/doc/nexum_sdk/host/index.html -[cow-doc]: ../target/doc/shepherd_sdk/cow/index.html +[host-doc]: ../target/doc/nexum_sdk/host/index.html [client-doc]: ../target/doc/videre_sdk/client/index.html ## Modules - [`nexum_sdk::prelude`](../target/doc/nexum_sdk/prelude/index.html) - and [`shepherd_sdk::prelude`](../target/doc/shepherd_sdk/prelude/index.html) - - bulk re-exports. The nexum prelude covers the alloy primitives - (`Address`, `B256`, `Bytes`, `U256`, `keccak256`); the shepherd - prelude covers cowprotocol's order / signing / orderbook surface. - -- [`cow`](../target/doc/shepherd_sdk/cow/index.html) - CoW Protocol - bridging: - - `cow::order::gpv2_to_order_data` - convert the on-chain - `GPv2OrderData` (12-field Solidity tuple with bytes32 markers) - into the typed `OrderData` shape the orderbook signs against. - - `cow::composable::PollOutcome` + `cow::composable::decode_revert` - - typed dispatch over the five `IConditionalOrder` custom errors - (`OrderNotValid`, `PollTryNextBlock`, `PollTryAtBlock`, - `PollTryAtEpoch`, `PollNever`). - - `cow::error::RetryAction` + `cow::error::classify_api_error` - - map `cow_api::submit_order` failures into `TryNextBlock` / - `Backoff(s)` / `Drop`. + - bulk re-exports covering the alloy primitives (`Address`, `B256`, +`Bytes`, `U256`, `keccak256`). `videre-sdk` has no prelude; it re-exports its surface from the crate root. + +- [`client`](../target/doc/videre_sdk/client/index.html) - the typed +venue seam (in `videre-sdk`): a `Venue` marker drives `VenueClient`, which encodes through `IntentBody` before the byte-level `VenueTransport` seam. `keeper::retry_action` folds a `VenueFault` into a `RetryAction`. + +- CoW-specific pieces live in their own L3 crates rather than the SDK: +`cow_venue::assembly::gpv2_to_order_data` converts the on-chain `GPv2OrderData` into the typed `OrderData` the orderbook signs against, and `composable_cow::{Verdict, LegacyRevertAdapter}` give typed dispatch over the five `IConditionalOrder` custom errors (`OrderNotValid`, `PollTryNextBlock`, `PollTryAtBlock`, `PollTryAtEpoch`, `PollNever`). - [`chain`](../target/doc/nexum_sdk/chain/index.html) - `eth_call` - JSON plumbing (in `nexum-sdk`): +JSON plumbing (in `nexum-sdk`): - `chain::eth_call_params(to, data)` - build the `[{to, data}, "latest"]` params array. - `chain::parse_eth_call_result(json)` - parse the `"0x..."` hex response into bytes. - `chain::chainlink::read_latest_answer` - Chainlink AggregatorV3 reader over the two helpers above. - (The CoW-specific `cow::decode_revert_hex(s)` - the `chain-error` - rpc revert bytes -> typed `PollOutcome` - lives in `shepherd-sdk`.) +(The CoW-specific `LegacyRevertAdapter` - the `chain-error` rpc revert bytes to a typed `Verdict` - lives in `composable-cow`.) - [`host`](../target/doc/nexum_sdk/host/index.html) - host trait - seam plus the SDK's host-neutral `Fault` vocabulary (same cases - as wit-bindgen's, bridged via one-liner converters per module), - in `nexum-sdk`. `config` and `address` parsing helpers sit - alongside it. +seam plus the SDK's host-neutral `Fault` vocabulary (same cases as wit-bindgen's, bridged via one-liner converters per module), in `nexum-sdk`. `config` and `address` parsing helpers sit alongside it. - [`http`](../target/doc/nexum_sdk/http/index.html) - outbound - HTTP over wasi:http, in `nexum-sdk`. - `http::fetch` performs one synchronous - request from a `wasm32-wasip2` guest; requests and responses are - the standard `http` crate's `Request` / `Response` types, and the - SDK's `Fetch` trait seam, `FetchError` taxonomy, and per-phase - `FetchOptions` timeouts compile on every target for host-free - strategy tests. The module must declare the `http` capability - and list the hosts it may contact in `[capabilities.http].allow` - in its `module.toml`; an off-list host surfaces as the matchable - `FetchError::Denied`, distinct from timeouts and transport - failures. See `modules/examples/http-probe` for a complete - module. - -## Companions: nexum-sdk-test and shepherd-sdk-test - -Add `nexum-sdk-test` as a dev-dep on the module crate to write -strategy tests against in-memory mocks; CoW modules use -`shepherd-sdk-test`, whose `MockHost` composes the generic mocks with -`MockCowApi`. See the crate docs -([nexum-sdk-test](../crates/nexum-sdk-test/src/lib.rs), -[shepherd-sdk-test](../crates/shepherd-sdk-test/src/lib.rs)) for the -usage pattern. +HTTP over wasi:http, in `nexum-sdk`. `http::fetch` performs one synchronous request from a `wasm32-wasip2` guest; requests and responses are the standard `http` crate's `Request` / `Response` types, and the SDK's `Fetch` trait seam, `FetchError` taxonomy, and per-phase `FetchOptions` timeouts compile on every target for host-free module tests. The module must declare the `http` capability and list the hosts it may contact in `[capabilities.http].allow` in its `module.toml`; an off-list host surfaces as the matchable `FetchError::Denied`, distinct from timeouts and transport failures. See `nexum/modules/examples/http-probe` for a complete module. + +## Companions: nexum-sdk-test and videre-test + +Add `nexum-sdk-test` as a dev-dep on the module crate to write module tests against in-memory mocks; its `MockHost` covers the chain, local store and logging seams. `videre-test` is the venue-side kit: codec vectors and header goldens for conformance runs, plus transport mocks such as `MockFetch`. See the crate docs ([nexum-sdk-test](../nexum/crates/nexum-sdk-test/src/lib.rs), [videre-test](../videre/crates/videre-test/src/lib.rs)) for the usage pattern. ## Versioning -The SDK crates are currently `0.1.0` and live at `crates/nexum-sdk/` -and `crates/shepherd-sdk/` in the shepherd monorepo. They are not yet -published to crates.io; modules depend on them via workspace paths. +The SDK crates are currently `0.1.0` and live at `nexum/crates/nexum-sdk/` and `videre/crates/videre-sdk/` in the shepherd monorepo. They are not yet published to crates.io; modules depend on them via workspace paths. -The `cowprotocol` crate is published to crates.io at `0.1.0`; the -workspace declares `cowprotocol = "0.1.0"` in `[workspace.dependencies]` -with no `[patch.crates-io]` or git overrides. Module Cargo.toml files -that inherit from the workspace pick it up automatically. +The `cowprotocol` crate is published to crates.io; the workspace declares `cowprotocol = "0.2.0"` in `[workspace.dependencies]`, with a temporary `[patch.crates-io]` git override to `nullislabs/cow-rs` pending a release that ships the hash-only `OrderCreationAppData` constructor (see the comment above the patch block in the root `Cargo.toml`). Module Cargo.toml files that inherit from the workspace pick it up automatically. diff --git a/docs/testing-runtime-harness.md b/docs/testing-runtime-harness.md index 1e2e4323..8c268ec1 100644 --- a/docs/testing-runtime-harness.md +++ b/docs/testing-runtime-harness.md @@ -1,59 +1,31 @@ # Testing the Runtime: the engine-side `test-utils` harness -Two entirely separate mock surfaces exist in this repo, for testing two -entirely separate things. Conflating them wastes effort - either testing -module logic through the slow, heavy engine harness, or trying (and -failing) to reach engine correctness through a guest-side mock. Read this -before writing a runtime test. +Two separate mock surfaces exist, for testing two separate things: module logic against a guest-side mock, and engine correctness against a real compiled component. Read this before writing a runtime test. ## The guardrail - **Module business logic is tested in plain Rust, no wasm.** A module's - decision logic lives in a host-generic `strategy.rs` - (`fn on_block(host: &H, ...)`), and its tests drive it against - `nexum-sdk-test::MockHost` (CoW modules: `shepherd-sdk-test::MockHost`). - No wasmtime, no component boundary, no engine crate at all. This is - already the dominant pattern across every shipped module (twap-monitor, - ethflow-watcher, stop-loss, price-alert, balance-tracker) - see - [docs/sdk.md](sdk.md#companions-nexum-sdk-test-and-shepherd-sdk-test). - **New module-logic tests belong here.** +decision logic lives in a host-generic `logic.rs` (`keeper.rs` in a keeper) as `fn on_block(host: &H, ...)`, and its tests drive it against `nexum-sdk-test::MockHost`; venue-facing logic adds `videre-test`'s transport mocks. No wasmtime, no component boundary, no engine crate at all. This is the dominant pattern across every shipped module (twap-monitor, ethflow-watcher, price-alert, balance-tracker); see [docs/sdk.md](sdk.md#companions-nexum-sdk-test-and-videre-test). **New module-logic tests belong here.** - **The engine harness (this page) is reserved for engine, host, and - boundary correctness**: supervision (poison, restart, resource traps), - dispatch isolation across chains and modules, fault and log capture, the - WASI clock override a real guest observes, capability wiring, stream - reconnect. These need a real compiled `.wasm` component over async mock - backends and genuinely cannot be faked in-process. +boundary correctness**: supervision (poison, restart, resource traps), dispatch isolation across chains and modules, fault and log capture, the WASI clock override a real guest observes, capability wiring, stream reconnect. These need a real compiled `.wasm` component over async mock backends and genuinely cannot be faked in-process. - **Do not test module business logic through the wasm harness.** If a - test only needs "given input X the strategy does Y", it belongs in - `strategy.rs` against `MockHost`, not in a booted fixture here. A - harness test that could be rewritten as a plain-Rust `MockHost` test - without losing coverage is in the wrong place. +test only needs "given input X the module does Y", it belongs in the module's pure-logic file against `MockHost`, not in a booted fixture here. A harness test that could be rewritten as a plain-Rust `MockHost` test without losing coverage is in the wrong place. ## What `test-utils` provides -`crates/nexum-runtime`'s `test_utils` module (gated behind the -`test-utils` cargo feature) ships two layers: +`nexum/crates/nexum-runtime`'s `test_utils` module (gated behind the `test-utils` cargo feature) ships two layers: - **The bare mock backends** - `MockChainProvider`, `MockStateStore`, and - `MockTypes` implement the engine's component-seam traits with no - network and no disk. `mock_components` / `mock_components_from` bundle - them into a `Components` ready for `Supervisor::boot`. Use these when a - test needs to drive the supervisor directly - multi-module scenarios, - custom extensions, or checking host-interface wiring the harness below - doesn't expose. +`MockTypes` implement the engine's component-seam traits with no network and no disk. `mock_components` / `mock_components_from` bundle them into a `Components` ready for `Supervisor::boot`. Use these when a test needs to drive the supervisor directly - multi-module scenarios, custom extensions, or checking host-interface wiring the harness below doesn't expose. - **`TestRuntime` / `TestRuntimeBuilder`** - a higher-level harness over - the same mocks: launch *one* module through the real public - `RuntimeBuilder` path, inject events, and read back logs and store - writes, with no supervisor ceremony. This is what most engine-level - tests want. +the same mocks: launch *one* module through the real public `RuntimeBuilder` path, inject events, and read back logs and store writes, with no supervisor ceremony. This is what most engine-level tests want. ### Feature gate and the self dev-dependency -`test_utils` only compiles under the `test-utils` feature (it pulls -`tempfile`, needed for manifest staging): +`test_utils` only compiles under the `test-utils` feature (it pulls `tempfile`, needed for manifest staging): ```toml -# crates/nexum-runtime/Cargo.toml +# nexum/crates/nexum-runtime/Cargo.toml [features] test-utils = ["dep:tempfile"] @@ -64,15 +36,11 @@ test-utils = ["dep:tempfile"] nexum-runtime = { path = ".", features = ["test-utils"] } ``` -A crate outside `nexum-runtime` that wants the harness - an extension -crate testing its own backend against a real supervisor, for instance - -depends on `nexum-runtime` with `features = ["test-utils"]` directly; no -self-dependency trick needed there. +A crate outside `nexum-runtime` that wants the harness - an extension crate testing its own backend against a real supervisor, for instance - depends on `nexum-runtime` with `features = ["test-utils"]` directly; no self-dependency trick needed there. ## Worked example: `TestRuntime` -Build the module fixture once (`cargo build --target wasm32-wasip2 ---release -p example`, or `just build-module`), then: +Build the module fixture once (`cargo build --target wasm32-wasip2 --release -p example`, or `just build-module`), then: ```rust use alloy_rpc_types_eth::Header; @@ -121,28 +89,16 @@ chain_id = 1 Beyond the happy path above: - **Chain-log injection** mirrors blocks: `rt.push_chain_log(log)` where - `log: alloy_rpc_types_eth::Log`. +`log: alloy_rpc_types_eth::Log`. - **Chain-request programming**, for a module that calls `chain::request` - (e.g. an `eth_call` oracle read): `rt.chain().on_method(ChainMethod::EthCall, - r#""0x...""#)` before `launch`, or `on_request` for a full - `(method, params)` match. `ChainMethod` is - `nexum_runtime::host::component::ChainMethod`. +(e.g. an `eth_call` oracle read): `rt.chain().on_method(ChainMethod::EthCall, r#""0x...""#)` before `launch`, or `on_request` for a full `(method, params)` match. `ChainMethod` is `nexum_runtime::host::component::ChainMethod`. - **Error and stream-end injection**, to exercise reconnect and fault - paths: `rt.chain().push_block_err(err)` delivers a transport error to - the open block stream (`err: nexum_runtime::host::provider_pool::ProviderError`); - `rt.chain().close_block_stream()` simulates the upstream ending the - subscription, so the test can assert the event loop's reconnect logic - re-opens it. +paths: `rt.chain().push_block_err(err)` delivers a transport error to the open block stream (`err: nexum_runtime::host::provider_pool::ProviderError`); `rt.chain().close_block_stream()` simulates the upstream ending the subscription, so the test can assert the event loop's reconnect logic re-opens it. - **Store assertions**: `rt.store()` exposes the same `MockStateStore` - the module wrote through `local-store` - read back what a dispatched - event persisted. +the module wrote through `local-store` - read back what a dispatched event persisted. - **Guest time**: `rt.clock()` is the `ManualClock` installed as the - module's WASI clock override; advance it to test time-dependent logic - without a real sleep. +module's WASI clock override; advance it to test time-dependent logic without a real sleep. ## If you landed here looking for module tests -You want `nexum-sdk-test::MockHost` (or `shepherd-sdk-test::MockHost` for -CoW modules) instead - no wasm build, no engine crate, runs in -milliseconds. See [docs/sdk.md](sdk.md) and any shipped module's -`strategy.rs` test module for the pattern. +You want `nexum-sdk-test::MockHost` instead (plus `videre-test`'s transport mocks for venue-facing logic): no wasm build, no engine crate, runs in milliseconds. See [docs/sdk.md](sdk.md) and any shipped module's pure-logic test module for the pattern. diff --git a/docs/tutorial-first-module.md b/docs/tutorial-first-module.md index 9c4c5604..2147b86a 100644 --- a/docs/tutorial-first-module.md +++ b/docs/tutorial-first-module.md @@ -1,128 +1,73 @@ -# Build your first Shepherd module +# Build your first module -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". +A walkthrough of the shipped `price-alert` example: a module that polls a Chainlink oracle on every block and logs when the price crosses a threshold. It exercises the three load-bearing patterns of a module: a block subscription, a `chain` read with ABI decode, and `[config]`-driven behaviour. Read the real files alongside this page under [`nexum/modules/examples/price-alert`](../nexum/modules/examples/price-alert). -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: +Venue submission (signing and posting an order to a venue such as CoW) is a separate concern layered on `videre-sdk`; see [Where to go from here](#where-to-go-from-here). -| 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) | +## Prerequisites -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. - -## 0. Prerequisites (15 minutes) - -You need a recent Rust toolchain (`rustc 1.91+`, ships with `cargo`) -and the WASM Component Model target. From the repo root: +A Rust toolchain and the WASM Component Model target: ```sh rustup target add wasm32-wasip2 ``` -Verify the engine builds and runs against the example module that -ships in the workspace: +Build and run the minimal `example` module to confirm the engine works: ```sh cargo build --target wasm32-wasip2 --release -p example cargo run -p nexum-cli -- \ target/wasm32-wasip2/release/example.wasm \ - modules/example/module.toml + nexum/modules/example/module.toml ``` -You should see two log lines from the example module - one in -`init`, one on the synthetic block event. Stop here and triage if -the build fails or those log lines do not appear; the rest of the -tutorial assumes a working local engine. +You should see the example module's `init` log line. Triage the build before continuing if it does not appear. -## 1. Scaffold the workspace member (15 minutes) +## Crate layout -Create a new crate under `modules/examples/`: - -```sh -mkdir -p modules/examples/stop-loss/src -``` - -The `Cargo.toml` follows the same template as `price-alert`: +A module is a `cdylib` crate that compiles to a WASM Component. `price-alert`'s `Cargo.toml`: ```toml -# modules/examples/stop-loss/Cargo.toml [package] -name = "stop-loss" +name = "price-alert" version = "0.1.0" edition.workspace = true -license.workspace = true -repository.workspace = true [lib] crate-type = ["cdylib"] [dependencies] nexum-sdk = { path = "../../../crates/nexum-sdk" } -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"] } +alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } +tracing = { version = "0.1", default-features = false } +wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] -shepherd-sdk-test = { path = "../../../crates/shepherd-sdk-test" } +nexum-sdk-test = { path = "../../../crates/nexum-sdk-test" } +alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } ``` -Note the four key features: - -- **`crate-type = ["cdylib"]`** - produces a WASM Component when - built for `wasm32-wasip2`. -- **`nexum-sdk` + `shepherd-sdk` path deps** - the generic helpers - (`chain::`, `host::`, `config::`, `prelude`) come from `nexum-sdk`; - the CoW surface (`cow::`, the cowprotocol `prelude`) from - `shepherd-sdk`. A module that never touches the orderbook depends - only on `nexum-sdk`. -- **`shepherd-sdk-test` as a dev-dep** - the CoW `MockHost` + - assertion helpers, only linked under `cargo test`. -- **No direct `nexum-runtime` dep** - modules never link the engine; - they communicate via wit-bindgen-generated shims. +The load-bearing points: -Add the new crate to the workspace `members` list in `Cargo.toml` -at the repo root: +- `crate-type = ["cdylib"]` produces a Component when built for `wasm32-wasip2`. +- `nexum-sdk` supplies the generic helpers (`chain`, `host`, `config`, `prelude`). A module that talks to a venue also depends on `videre-sdk`; `price-alert` does not. +- `nexum-sdk-test` is a dev-dep only: its `MockHost` links under `cargo test`, never into the artefact. +- Modules never depend on `nexum-runtime`. They reach the host through wit-bindgen-generated imports the SDK macro wires up. -```toml -[workspace] -members = [ - # ... existing members - "modules/examples/stop-loss", -] -``` +The crate is a workspace member; add its path to `members` in the root `Cargo.toml`. -`cargo check --target wasm32-wasip2 -p stop-loss` should fail with -"no library targets found" - expected, you have not written any -source yet. +## The manifest -## 2. Author the manifest (10 minutes) - -`module.toml` declares the capabilities, subscriptions, and -operator-supplied config. Drop this next to `Cargo.toml`: +`module.toml` declares capabilities, subscriptions, and operator config: ```toml -# modules/examples/stop-loss/module.toml [module] -name = "stop-loss" +name = "price-alert" version = "0.1.0" component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" [capabilities] -required = ["logging", "chain", "local-store", "cow-api"] +required = ["logging", "chain"] optional = [] [capabilities.http] @@ -133,410 +78,157 @@ kind = "block" chain_id = 11155111 # Sepolia [config] -# Chainlink AggregatorV3Interface address (ETH/USD on Sepolia). -oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" +oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" # ETH/USD on Sepolia 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) +threshold = "2500.00" +direction = "below" +every_n_blocks = "1" ``` -Three patterns worth noting: - -- **`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. -- **`[capabilities.http].allow` is empty** because stop-loss makes - no outbound HTTP calls. A module that needs them declares the - `http` capability, lists the hosts it may contact in `allow`, - and calls `nexum_sdk::http::fetch` (which wraps the standard - wasi:http interface); a - request to an off-list host fails with the matchable - `FetchError::Denied`. See `modules/examples/http-probe` for a - working example. -- **`[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. Write the strategy (60 minutes) - -The strategy logic splits into two layers: - -- 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 - `nexum_sdk::host::Host`. +- `required` lists the host capabilities the module imports. The engine enforces the list at instantiation: missing a used capability is a hard error. +- `[capabilities.http].allow` is empty because `price-alert` makes no outbound HTTP. A module that needs it declares the `http` capability, lists the hosts it may contact, and calls `nexum_sdk::http::fetch`; an off-list host returns the matchable `FetchError::Denied`. See [`nexum/modules/examples/http-probe`](../nexum/modules/examples/http-probe). +- `[config]` values are strings. `init` parses them into a typed `Settings`. -### 3a. The pure strategy (30 minutes) +## The pure logic -Sketch in `src/strategy.rs`: +Decision logic lives in `logic.rs` as a host-generic function. It never names `wit-bindgen` or `wasmtime`, so tests drive it directly: ```rust -use alloy_primitives::{Address, I256}; -use alloy_sol_types::{SolCall, sol}; -use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; -use nexum_sdk::host::Fault; -use nexum_sdk::prelude::*; -use shepherd_sdk::cow::{CowApiError, CowHost}; -use shepherd_sdk::prelude::*; - -sol! { - interface AggregatorV3 { - function latestRoundData() external view returns ( - uint80, int256 answer, uint256, uint256, uint80 - ); - } -} - -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, -} +use nexum_sdk::chain::chainlink::read_latest_answer; +use nexum_sdk::host::{ChainHost, Fault, LoggingHost}; -pub fn on_block( +pub fn on_block( host: &H, chain_id: u64, settings: &Settings, + block_number: u64, ) -> Result<(), Fault> { - // 1. Read the oracle. `host.request` returns a ChainError; `?` folds - // it into Fault via `From`. - 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 { - tracing::warn!("stop-loss: cannot decode oracle result"); - return Ok(()); - }; - let decoded = AggregatorV3::latestRoundDataCall::abi_decode_returns(&bytes) - .map_err(|e| Fault::InvalidInput(format!("oracle decode: {e}")))?; - let price = decoded.answer; - - // 2. Are we above trigger? Stay idle. - if price > settings.trigger_price_scaled { - tracing::info!(price = %price, "stop-loss idle"); + if !block_number.is_multiple_of(settings.every_n_blocks) { return Ok(()); } - - // 3. Dedup: did we already submit? - let dedup_key = format!("submitted:{:#x}", settings.owner); - if host.get(&dedup_key)?.is_some() { - tracing::info!("stop-loss: already submitted, skipping"); - return Ok(()); + let Some(answer) = + read_latest_answer(host, chain_id, settings.oracle_address, "price-alert") + else { + return Ok(()); // read_latest_answer already logged the failure + }; + if classify(answer, settings.threshold_scaled, settings.direction) { + tracing::warn!(answer = %answer, "price-alert: TRIGGERED"); + } else { + tracing::info!(answer = %answer, "price-alert: ok"); } - - // 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)?; - // A real strategy matches on `CowApiError::Rejected` to classify the - // orderbook's typed rejection; here we fold to a Fault for brevity. - let uid = host.submit_order(chain_id, &body).map_err(|e| match e { - CowApiError::Fault(f) => f, - other => Fault::Internal(other.to_string()), - })?; - - // 5. Persist + log. - host.set(&dedup_key, uid.as_bytes())?; - tracing::warn!(uid = %uid, "stop-loss triggered"); Ok(()) } - -fn build_order_body(_s: &Settings) -> Result, Fault> { - // 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") -} ``` The shape to internalise: -- **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. - -### 3b. The Guest adapter (15 minutes) - -`src/lib.rs` adapts wit-bindgen's free functions into a struct that -implements `Host`. Every module needs the same glue here: a -`WitBindgenHost` adapter, the `Fault` conversions, and the -`Level` <-> wire-enum mapping for logging. Rather than hand-write -it, call the SDK's bind macro. Plain modules use -`nexum_sdk::bind_host_via_wit_bindgen!()`; stop-loss also submits -CoW orders, so it reaches for the CoW-aware variant, -`shepherd_sdk::bind_cow_host_via_wit_bindgen!()`: +- Every interaction with the world goes through `host`, bounded by the traits the module actually imports (`ChainHost + LoggingHost` here, matching the two declared capabilities). +- The function recovers from transient upstream failure by logging and returning `Ok`, so one bad event does not poison the supervisor. + +Config parsing follows the same one-shot style: `parse_config(&[(String, String)]) -> Result`, using the `nexum_sdk::config` helpers (`get_required`, `scale_decimal`). See the full source in `logic.rs`. + +## The glue + +`lib.rs` declares the handlers and defers all per-cdylib glue to `#[nexum_sdk::module]`: ```rust #![allow(clippy::too_many_arguments)] -wit_bindgen::generate!({ - path: ["../../../wit/nexum-host", "../../../wit/shepherd-cow"], - world: "shepherd:cow/shepherd", - generate_all, -}); - -mod strategy; +mod logic; use std::sync::OnceLock; - use nexum::host::types; -// `WitBindgenHost`, `convert_fault`, `sdk_fault_into_wit`, `convert_level`, -// `HostLogSink`, `install_tracing` are generated below. Single source -// of truth in `nexum-sdk` + `shepherd-sdk`. -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); - -static SETTINGS: OnceLock = OnceLock::new(); +static SETTINGS: OnceLock = OnceLock::new(); -struct StopLoss; +struct PriceAlert; -impl Guest for StopLoss { +#[nexum_sdk::module] +impl PriceAlert { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; - tracing::info!( - "stop-loss init: owner={:#x} trigger={} sell={:#x} buy={:#x}", - cfg.owner, - cfg.trigger_price_scaled, - cfg.sell_token, - cfg.buy_token, - ); + let cfg = logic::parse_config(&config)?; let _ = SETTINGS.set(cfg); Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { - 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_fault_into_wit)?; - } - Ok(()) + fn on_block(block: types::Block) -> Result<(), Fault> { + let Some(cfg) = SETTINGS.get() else { return Ok(()) }; + logic::on_block(&WitBindgenHost, block.chain_id, cfg, block.number) + .map_err(Into::into) } } - -export!(StopLoss); ``` -The macro generates `WitBindgenHost`, the `ChainHost` / -`LocalStoreHost` / `LoggingHost` / `CowApiHost` impls, the -`Fault` conversions (`convert_fault`, `sdk_fault_into_wit`), and -`install_tracing`, which installs the guest `tracing` facade so -the `tracing::info!`, `warn!`, and `error!` macros reach the host -log call with no `Host` value to thread through. Call it once at -the top of `Guest::init`. Only the `Guest` impl and `SETTINGS` -initialisation above are per-module code. - -Once `install_tracing()` has run, those macros reach the host from -anywhere with no `Host` value to thread through, -so both `init` and the strategy log through the macros. Prefer them: -they take structured fields (`tracing::warn!(code = err.code, "...")`) -that the host records as `key=value` pairs, rather than a -pre-formatted string. The wire-level `logging::log(...)` and the -`host.log(Level::INFO, ...)` trait method still exist for the rare -call site that runs before the subscriber is installed, but every -shipped module logs through the facade. See -`modules/examples/balance-tracker` for a strategy written against -the macros throughout. - -### 3c. Unit tests against `MockHost` (15 minutes) - -In `src/strategy.rs`, append: +Apply the attribute to an inherent `impl` whose functions are the named handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`; absent handlers no-op). The macro generates the `wit_bindgen::generate!` call, the `WitBindgenHost` adapter, the `Fault` conversions, `install_tracing`, and the `Guest`/`export!` glue. Call `install_tracing()` once in `init`; after that the `tracing` macros reach the host log from anywhere with no `Host` value to thread through. + +## Tests against `MockHost` + +Because the module logic is host-generic, tests run in plain Rust with no wasm toolchain, driving it against `nexum_sdk_test::MockHost` and capturing the log output: ```rust #[cfg(test)] mod tests { use super::*; - use nexum_sdk::host::*; - use nexum_sdk_test::capture_tracing; - 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}\"") - } + use nexum_sdk_test::{MockHost, capture_tracing}; #[test] - fn idle_when_price_above_trigger() { + fn triggers_below_threshold() { let host = MockHost::new(); - let s = settings(/*trigger*/ 1_000); - // Oracle returns 2000 (above the 1000 trigger). - host.chain.respond_to( - "eth_call", - &nexum_sdk::chain::eth_call_params( - &s.oracle_address, - &AggregatorV3::latestRoundDataCall {}.abi_encode(), - ), - Ok(oracle_returns(2000)), - ); - - let (result, logs) = capture_tracing(|| on_block(&host, 11_155_111, &s)); + let settings = sample_settings(250_050_000_000, Direction::Below); + programmed_eth_call(&host, settings.oracle_address, Ok(oracle_response_json(200_000_000_000))); + + let (result, logs) = capture_tracing(|| on_block(&host, 11_155_111, &settings, 100)); result.unwrap(); - assert_eq!(host.cow_api.call_count(), 0); - assert!(logs.any(|e| e.message.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", - &nexum_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. - let (first, first_logs) = capture_tracing(|| on_block(&host, 11_155_111, &s)); - first.unwrap(); - assert_eq!(host.cow_api.call_count(), 1); - assert!(first_logs.any(|e| e.message.contains("triggered"))); - - // Second block at the same price: dedup'd by the - // `submitted:` key. - let (second, second_logs) = capture_tracing(|| on_block(&host, 11_155_111, &s)); - second.unwrap(); - assert_eq!(host.cow_api.call_count(), 1); - assert!(second_logs.any(|e| e.message.contains("already submitted"))); + let ev = logs.expect_one(|e| e.level == Level::WARN); + assert_eq!(ev.message, "price-alert: TRIGGERED"); } } ``` -Run with `cargo test -p stop-loss`. Both tests should pass on a -plain host - no wasm toolchain involved. +Run with `cargo test -p price-alert`. See `logic.rs` for the full test module, including the `MockHost` chain programming (`host.chain.respond_to`) and the throttle and error-path cases. + +Any behaviour expressible as "given this host state, do that" belongs here, not in the engine harness. See [testing-runtime-harness.md](testing-runtime-harness.md) for the guardrail between the two. -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. +## Build and run -## 4. Build the `.wasm` artefact (5 minutes) +Build the artefact: ```sh -cargo build --target wasm32-wasip2 --release -p stop-loss -ls -lh target/wasm32-wasip2/release/stop_loss.wasm +cargo build --target wasm32-wasip2 --release -p price-alert +ls -lh target/wasm32-wasip2/release/price_alert.wasm ``` -Expected size: 250–350 KB. If it 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) - -Add an RPC endpoint for Sepolia in `engine.toml`: +Block subscriptions ride `eth_subscribe`, so the chain needs a WebSocket endpoint. Point `engine.toml` at one: ```toml [chains.11155111] 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: +Run the engine over the module, supplying the chain config: ```sh cargo run -p nexum-cli -- \ - target/wasm32-wasip2/release/stop_loss.wasm \ - modules/examples/stop-loss/module.toml + target/wasm32-wasip2/release/price_alert.wasm \ + nexum/modules/examples/price-alert/module.toml \ + --engine-config engine.toml ``` -Expected output on first run (one log per: - -- `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. - -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` - through. - -## Time-budget check - -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. - -## Reference index - -- SDK overview: [`docs/sdk.md`](./sdk.md) -- Deployment runbook: [`docs/deployment.md`](./deployment.md) +You should see the `init` line, then one `price-alert: ok` or `price-alert: TRIGGERED` line per new block. An `unsupported` fault means the module imports a capability its `[capabilities].required` list omits. + +## Where to go from here + +- Venue submission: a module that signs and posts orders to a venue depends on `videre-sdk` and a venue adapter crate (`cow-venue` for CoW), and uses `#[videre_sdk::keeper]` rather than `#[nexum_sdk::module]`. See [`shepherd/modules/twap-monitor`](../shepherd/modules/twap-monitor) and [docs/sdk.md](sdk.md). +- Local state: declare `local-store` and persist through `host.set`/`host.get`; see [`nexum/modules/examples/balance-tracker`](../nexum/modules/examples/balance-tracker). +- Outbound HTTP: [`nexum/modules/examples/http-probe`](../nexum/modules/examples/http-probe). +- Resource limits: [docs/deployment.md](deployment.md). + +## Reference + +- SDK overview: [docs/sdk.md](sdk.md) +- Deployment: [docs/deployment.md](deployment.md) - 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/), - [`balance-tracker`](../modules/examples/balance-tracker/), - [`twap-monitor`](../modules/twap-monitor/), - [`ethflow-watcher`](../modules/ethflow-watcher/) +- ADR-0006 (TWAP and EthFlow as guest modules, no specialised WIT interfaces) +- Worked examples: [`price-alert`](../nexum/modules/examples/price-alert/), [`balance-tracker`](../nexum/modules/examples/balance-tracker/), [`twap-monitor`](../shepherd/modules/twap-monitor/), [`ethflow-watcher`](../shepherd/modules/ethflow-watcher/) diff --git a/engine.docker.toml b/engine.docker.toml index 72141a15..674e2863 100644 --- a/engine.docker.toml +++ b/engine.docker.toml @@ -55,7 +55,7 @@ rpc_url = "${BASE_RPC_URL}" # ---- modules ---- # -# The image bakes all five production modules at the paths below. +# The image bakes all four production modules at the paths below. # Comment out any you don't intend to run on this deployment. [[modules]] @@ -74,6 +74,15 @@ manifest = "/opt/shepherd/manifests/price-alert.toml" path = "/opt/shepherd/modules/balance_tracker.wasm" manifest = "/opt/shepherd/manifests/balance-tracker.toml" -[[modules]] -path = "/opt/shepherd/modules/stop_loss.wasm" -manifest = "/opt/shepherd/manifests/stop-loss.toml" +# ---- adapters ---- +# +# The bundled cow venue adapter: the venue registry resolves the `cow` +# venue id through it. The operator grant scopes its outbound HTTP to +# the production orderbook host. The Sepolia manifest matches +# twap-monitor's Sepolia subscriptions; a mainnet run swaps in +# /opt/shepherd/manifests/cow-venue.toml. + +[[adapters]] +path = "/opt/shepherd/modules/cow_venue.wasm" +manifest = "/opt/shepherd/manifests/cow-venue.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.e2e.toml b/engine.e2e.toml index 330774b7..e3a7d26b 100644 --- a/engine.e2e.toml +++ b/engine.e2e.toml @@ -1,16 +1,15 @@ # E2E testnet integration config for nexum. # -# Boots all 5 production + example modules on Sepolia simultaneously +# Boots all 4 production + example modules on Sepolia simultaneously # for the 4-6 h E2E run: # -# - twap-monitor (modules/twap-monitor) -# - ethflow-watcher (modules/ethflow-watcher) -# - price-alert (modules/examples/price-alert) -# - balance-tracker (modules/examples/balance-tracker) -# - stop-loss (modules/examples/stop-loss) +# - twap-monitor (shepherd/modules/twap-monitor) +# - ethflow-watcher (shepherd/modules/ethflow-watcher) +# - price-alert (nexum/modules/examples/price-alert) +# - balance-tracker (nexum/modules/examples/balance-tracker) # # This is the integration step between the M3 single-chain runbook -# (`engine.m3.toml`, 3 modules) and the 7-day soak +# (`engine.m3.toml`, 2 modules) and the 7-day soak # (Sepolia + Arb Sepolia, all modules, no human-in-the-loop). The # E2E run validates correctness in a real-chain dispatch context; # the soak validates stability afterwards. @@ -48,20 +47,26 @@ rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" [[modules]] path = "target/wasm32-wasip2/release/twap_monitor.wasm" -manifest = "modules/twap-monitor/module.toml" +manifest = "shepherd/modules/twap-monitor/module.toml" [[modules]] path = "target/wasm32-wasip2/release/ethflow_watcher.wasm" -manifest = "modules/ethflow-watcher/module.toml" +manifest = "shepherd/modules/ethflow-watcher/module.toml" [[modules]] path = "target/wasm32-wasip2/release/price_alert.wasm" -manifest = "modules/examples/price-alert/module.toml" +manifest = "nexum/modules/examples/price-alert/module.toml" [[modules]] path = "target/wasm32-wasip2/release/balance_tracker.wasm" -manifest = "modules/examples/balance-tracker/module.toml" +manifest = "nexum/modules/examples/balance-tracker/module.toml" -[[modules]] -path = "target/wasm32-wasip2/release/stop_loss.wasm" -manifest = "modules/examples/stop-loss/module.toml" +# --- adapters --------------------------------------------------------- + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain twap indexes. +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "shepherd/crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.example.toml b/engine.example.toml index 39624528..748fd86e 100644 --- a/engine.example.toml +++ b/engine.example.toml @@ -52,6 +52,15 @@ log_level = "info" # total_deadline_ms = 60_000 # response_body_max_bytes = 16_777_216 +# Chain JSON-RPC response size cap, applied host-side before a response +# is copied into guest memory. A single response - and the aggregate of +# one request-batch - beyond the cap fails with a typed invalid-input +# fault instead of inflating the module toward its memory_bytes limit. +# Modules that need more data per call should paginate (block-range +# chunking, request-batch). 0 saturates to 1. Default 1 MiB. +[limits.chain] +# response_body_max_bytes = 1_048_576 + # Per-run log retention. bytes_per_run is the byte budget for one run's # in-memory ring (oldest records evict first, the newest is never evicted # to nothing); runs_retained is how many past runs are kept per module. @@ -91,3 +100,18 @@ rpc_url = "${ARBITRUM_RPC_URL}" [chains.8453] # Base rpc_url = "${BASE_RPC_URL}" + +# ---- adapters ---- +# +# Venue-adapter components the venue registry resolves venue ids through. +# The operator, not the adapter author, grants the transport scope: +# `http_allow` is the outbound wasi:http host allowlist. The bundled +# cow adapter builds with `just build-cow-venue`; its venue id is the +# manifest name (`cow`). One manifest per chain: `module.toml` is +# mainnet, `module.sepolia.toml` Sepolia - wire the one matching the +# chain the submitting modules index. + +# [[adapters]] +# path = "target/wasm32-wasip2/release/cow_venue.wasm" +# manifest = "shepherd/crates/cow-venue/module.toml" +# http_allow = ["api.cow.fi"] diff --git a/engine.load.toml b/engine.load.toml index 7999669d..4c837856 100644 --- a/engine.load.toml +++ b/engine.load.toml @@ -7,8 +7,8 @@ # # Differences vs engine.e2e.toml: # - chain points at the local Anvil fork on ws://localhost:8545 -# - [extensions.cow.orderbook_urls] points at tools/orderbook-mock -# (no live cow.fi) +# - the cow adapter's load-variant manifest points the orderbook at +# tools/orderbook-mock (no live cow.fi) # - state_dir is per-run (./data/load) so successive runs do not # inherit local-store rows from each other # - log level is debug for the supervisor-dispatch surface so the @@ -33,14 +33,18 @@ bind_addr = "127.0.0.1:9100" [chains.11155111] rpc_url = "ws://localhost:8545" -# Point the cow-api extension at tools/orderbook-mock. -[extensions.cow.orderbook_urls] -11155111 = "http://localhost:9999" - [[modules]] path = "./target/wasm32-wasip2/release/twap_monitor.wasm" -manifest = "./modules/twap-monitor/module.toml" +manifest = "./shepherd/modules/twap-monitor/module.toml" + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Load-variant manifest: Sepolia chain id with the +# orderbook re-pointed at tools/orderbook-mock. +[[adapters]] +path = "./target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "./shepherd/crates/cow-venue/module.load.toml" +http_allow = ["localhost"] [[modules]] path = "./target/wasm32-wasip2/release/ethflow_watcher.wasm" -manifest = "./modules/ethflow-watcher/module.toml" +manifest = "./shepherd/modules/ethflow-watcher/module.toml" diff --git a/engine.m2.toml b/engine.m2.toml index cce435c3..5a45e4cd 100644 --- a/engine.m2.toml +++ b/engine.m2.toml @@ -28,8 +28,16 @@ rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" [[modules]] path = "target/wasm32-wasip2/release/twap_monitor.wasm" -manifest = "modules/twap-monitor/module.toml" +manifest = "shepherd/modules/twap-monitor/module.toml" [[modules]] path = "target/wasm32-wasip2/release/ethflow_watcher.wasm" -manifest = "modules/ethflow-watcher/module.toml" +manifest = "shepherd/modules/ethflow-watcher/module.toml" + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain twap indexes. +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "shepherd/crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.m3.toml b/engine.m3.toml index d5fb7e69..5a1ee826 100644 --- a/engine.m3.toml +++ b/engine.m3.toml @@ -1,17 +1,17 @@ # M3 smoke / validation config for nexum. # -# 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). +# Boots the 2 M3 example modules (price-alert + balance-tracker) +# against Sepolia. The modules exercise the full SDK helper surface +# (chain::request via Chainlink read, local-store diffing, pool submit +# through the cow adapter 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-cli -- --engine-config engine.m3.toml +# cargo build -p cow-venue --features adapter --target wasm32-wasip2 --release +# cargo run -p shepherd -- --engine-config engine.m3.toml [engine] # Separate from data/m2 and the M1 example state. @@ -25,12 +25,18 @@ rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" [[modules]] path = "target/wasm32-wasip2/release/price_alert.wasm" -manifest = "modules/examples/price-alert/module.toml" +manifest = "nexum/modules/examples/price-alert/module.toml" [[modules]] path = "target/wasm32-wasip2/release/balance_tracker.wasm" -manifest = "modules/examples/balance-tracker/module.toml" +manifest = "nexum/modules/examples/balance-tracker/module.toml" -[[modules]] -path = "target/wasm32-wasip2/release/stop_loss.wasm" -manifest = "modules/examples/stop-loss/module.toml" +# --- adapters --------------------------------------------------------- + +# The cow venue adapter the modules submit through (`just +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain the oracle is read on. +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "shepherd/crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.soak.docker.toml b/engine.soak.docker.toml index 3f29ea10..c3e93f39 100644 --- a/engine.soak.docker.toml +++ b/engine.soak.docker.toml @@ -43,6 +43,11 @@ manifest = "/opt/shepherd/manifests/price-alert.toml" path = "/opt/shepherd/modules/balance_tracker.wasm" manifest = "/opt/shepherd/manifests/balance-tracker.toml" -[[modules]] -path = "/opt/shepherd/modules/stop_loss.wasm" -manifest = "/opt/shepherd/manifests/stop-loss.toml" +# --- adapters ----------------------------------------------------------- + +# The cow venue adapter twap-monitor submits through. Sepolia +# manifest: the adapter's orderbook must match the chain twap indexes. +[[adapters]] +path = "/opt/shepherd/modules/cow_venue.wasm" +manifest = "/opt/shepherd/manifests/cow-venue.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.soak.toml b/engine.soak.toml index 8f6cabf8..07f337ea 100644 --- a/engine.soak.toml +++ b/engine.soak.toml @@ -8,7 +8,9 @@ # cargo build --release -p nexum-cli # cargo build --target wasm32-wasip2 --release \ # -p twap-monitor -p ethflow-watcher -p price-alert \ -# -p balance-tracker -p stop-loss +# -p balance-tracker +# cargo build --target wasm32-wasip2 --release \ +# -p cow-venue --features cow-venue/adapter # ./target/release/nexum --engine-config engine.soak.toml # # Swap rpc_url below for your paid endpoint before starting, or set it @@ -49,20 +51,26 @@ rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" [[modules]] path = "target/wasm32-wasip2/release/twap_monitor.wasm" -manifest = "modules/twap-monitor/module.toml" +manifest = "shepherd/modules/twap-monitor/module.toml" [[modules]] path = "target/wasm32-wasip2/release/ethflow_watcher.wasm" -manifest = "modules/ethflow-watcher/module.toml" +manifest = "shepherd/modules/ethflow-watcher/module.toml" [[modules]] path = "target/wasm32-wasip2/release/price_alert.wasm" -manifest = "modules/examples/price-alert/module.toml" +manifest = "nexum/modules/examples/price-alert/module.toml" [[modules]] path = "target/wasm32-wasip2/release/balance_tracker.wasm" -manifest = "modules/examples/balance-tracker/module.toml" +manifest = "nexum/modules/examples/balance-tracker/module.toml" -[[modules]] -path = "target/wasm32-wasip2/release/stop_loss.wasm" -manifest = "modules/examples/stop-loss/module.toml" +# --- adapters --------------------------------------------------------- + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain twap indexes. +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "shepherd/crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/extensions.toml b/extensions.toml new file mode 100644 index 00000000..e836e923 --- /dev/null +++ b/extensions.toml @@ -0,0 +1,9 @@ +# Extension capability registry for this composition root: the +# per-namespace rows the module world synthesis emits beyond the core +# nexum:host table. Each row names the WIT import a `[capabilities]` +# declaration turns into and the package directories its resolve path +# needs, in dependency order. + +[extensions.client] +import = "videre:venue/client@0.1.0" +packages = ["videre-value-flow", "videre-types", "videre-venue"] diff --git a/flake.nix b/flake.nix index 410ffa14..a861afc7 100644 --- a/flake.nix +++ b/flake.nix @@ -43,6 +43,7 @@ devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ rustToolchain + cargo-nextest wasm-tools wabt just @@ -72,6 +73,7 @@ fi echo "nexum dev shell — $(rustc --version)" command -v sccache >/dev/null && echo " compiler cache: $(sccache --version)" + echo " test runner: $(cargo nextest --version | head -n1)" ${lib.optionalString stdenv.isLinux ''command -v mold >/dev/null && echo " linker (native): mold $(mold --version | head -n1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"''} ''; }; diff --git a/justfile b/justfile index c922906a..58c06824 100644 --- a/justfile +++ b/justfile @@ -1,11 +1,22 @@ -# Build the host engine +# Build the engine binaries: the bare `nexum` engine and the cow +# composition root `shepherd`. build-engine: - cargo build -p nexum-cli + cargo build -p nexum-cli -p shepherd # Build the example WASM module build-module: cargo build --target wasm32-wasip2 --release -p example +# Build the reference venue adapter (echo-venue) for wasm32-wasip2. Its +# per-component world pins the #[videre_sdk::venue] acceptance test. +build-venue: + cargo build --target wasm32-wasip2 --release -p echo-venue + +# Build the bundled cow venue adapter component. Install via the +# engine.toml [[adapters]] stanza; the venue id is its manifest name. +build-cow-venue: + cargo build --target wasm32-wasip2 --release -p cow-venue --features adapter + # Build everything build: build-engine build-module @@ -13,15 +24,15 @@ build: build-engine build-module # module's module.toml — without it the engine prints the 0.1-compat # deprecation warning and proceeds with empty capabilities/config. run: build-module build-engine - cargo run -p nexum-cli -- target/wasm32-wasip2/release/example.wasm modules/example/module.toml + cargo run -p nexum-cli -- target/wasm32-wasip2/release/example.wasm nexum/modules/example/module.toml # Run host engine unit tests test: - cargo test -p nexum-runtime + cargo nextest run -p nexum-runtime # Build module + engine, then run E2E integration tests test-e2e: build-module build-engine - cargo test -p nexum-runtime supervisor::tests::e2e + cargo nextest run -p nexum-runtime supervisor::tests::e2e # Build the M2 modules (twap-monitor + ethflow-watcher) for wasm32-wasip2. build-m2: @@ -32,61 +43,89 @@ build-m2: # (Sepolia, both M2 modules). See `docs/operations/m2-testnet-runbook.md`. # --pretty-logs keeps the runbook-friendly human-readable formatter; # production deploys omit the flag and emit JSON. -run-m2: build-m2 build-engine - cargo run -p nexum-cli -- --engine-config engine.m2.toml --pretty-logs +run-m2: build-m2 build-cow-venue build-engine + cargo run -p shepherd -- --engine-config engine.m2.toml --pretty-logs -# Build the M3 example modules (price-alert + balance-tracker + stop-loss) -# for wasm32-wasip2. +# Build the M3 example modules (price-alert + balance-tracker) 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 wired for the M3 smoke / validation scenario -# (Sepolia, 3 example modules). See `docs/operations/m3-testnet-runbook.md`. +# (Sepolia, 2 example modules). See `docs/operations/m3-testnet-runbook.md`. # --pretty-logs keeps the runbook-friendly human-readable formatter; # production deploys omit the flag and emit JSON. -run-m3: build-m3 build-engine - cargo run -p nexum-cli -- --engine-config engine.m3.toml --pretty-logs +run-m3: build-m3 build-cow-venue build-engine + cargo run -p shepherd -- --engine-config engine.m3.toml --pretty-logs # Build the http-probe example module (wasi:http fetch + allowlist # denial demo) for wasm32-wasip2. build-http-probe: cargo build -p http-probe --target wasm32-wasip2 --release -# Build all 5 modules required by the E2E run (twap-monitor + -# ethflow-watcher + price-alert + balance-tracker + stop-loss). +# Build all 4 modules required by the E2E run (twap-monitor + +# ethflow-watcher + price-alert + balance-tracker). build-e2e: build-m2 build-m3 -# Run the 4-6 h E2E integration scenario on Sepolia. All 5 modules +# Run the 4-6 h E2E integration scenario on Sepolia. All 4 modules # dispatched simultaneously against a live RPC; metrics scraped at # 127.0.0.1:9100/metrics. JSON logs (no --pretty-logs) so a # downstream `jq` filter can mine submitted/dropped/backoff markers # for the e2e report. See `docs/operations/e2e-testnet-runbook.md`. -run-e2e: build-e2e build-engine - cargo run -p nexum-cli -- --engine-config engine.e2e.toml +run-e2e: build-e2e build-cow-venue build-engine + cargo run -p shepherd -- --engine-config engine.e2e.toml + +# Zero-leak gate: host-layer crate graphs, runtime charter-symbol and +# router-field scans, and the nexum:host WIT leaf and foreign-namespace +# scans. Blocking in CI. +check-venue-agnostic: + ./scripts/check-venue-agnostic.sh + +# Orderbook-only gate: the CoW venue crate carries no composable +# symbol. Blocking in CI. +check-cow-orderbook-only: + ./scripts/check-cow-orderbook-only.sh + +# Dep-sync gate: every crate is grouped under nexum/videre/shepherd and +# depends only within or below its tier, so no upward edge becomes a +# circular repo dependency at the carve. Blocking in CI (M5 #403). +check-carve-groups: + ./scripts/check-carve-groups.sh # Check the entire workspace check: cargo check --target wasm32-wasip2 -p example cargo check -p nexum-runtime - cargo check -p nexum-cli + cargo check -p nexum-cli -p shepherd # Run the full CI series locally before pushing. Mirrors # .github/workflows/ci.yml one-to-one: rustfmt, clippy, rustdoc, the # module wasms the integration tests need, and the workspace test -# suite, all under the `-D warnings` the CI workflow sets globally. +# suite via nextest plus the doctests, all under the `-D warnings` the +# CI workflow sets globally. ci: #!/usr/bin/env bash set -euo pipefail - export RUSTFLAGS="-D warnings" - export RUSTDOCFLAGS="-D warnings" + # Append -D warnings without clobbering the devshell's flags (mold linker, + # set in flake.nix), so the local run keeps fast native linking. RUSTC_WRAPPER + # is already sccache from the devshell shellHook. + export RUSTFLAGS="${RUSTFLAGS:-} -D warnings" + export RUSTDOCFLAGS="${RUSTDOCFLAGS:-} -D warnings" cargo fmt --all --check cargo clippy --workspace --all-targets --all-features -- -D warnings + ./scripts/check-carve-groups.sh cargo doc --workspace --no-deps cargo build --release --target wasm32-wasip2 \ -p example -p twap-monitor -p ethflow-watcher -p price-alert \ - -p balance-tracker -p stop-loss -p http-probe \ - -p clock-reader -p flaky-bomb -p fuel-bomb -p memory-bomb -p panic-bomb \ - -p slow-host - cargo test --workspace --all-features --no-fail-fast + -p balance-tracker -p http-probe -p echo-venue \ + -p echo-client -p clock-reader -p flaky-bomb -p flaky-venue -p fuel-bomb \ + -p memory-bomb -p panic-bomb -p slow-host + # The module build above emits a featureless cow_venue.wasm through + # twap-monitor's cow-venue dep; rebuild the adapter component over it + # (as CI does) so the platform e2e tests load the adapter, not the stub. + cargo build --release --target wasm32-wasip2 -p cow-venue --features adapter + # nextest for the suite (as CI does); doctests run separately since nextest + # does not cover them. + cargo nextest run --workspace --all-features --no-fail-fast + cargo test --doc --workspace --all-features diff --git a/modules/ethflow-watcher/Cargo.toml b/modules/ethflow-watcher/Cargo.toml deleted file mode 100644 index 3beb4710..00000000 --- a/modules/ethflow-watcher/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "ethflow-watcher" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true - -[lib] -# `cdylib` is the wasm-component artefact the engine loads at -# runtime. `rlib` exposes the pure-Rust strategy module so native -# tools (e.g. `shepherd-backtest`) can drive `on_chain_logs` -# directly without an embedded runtime. -crate-type = ["cdylib", "rlib"] - -[dependencies] -nexum-sdk = { path = "../../crates/nexum-sdk" } -shepherd-sdk = { path = "../../crates/shepherd-sdk" } -cowprotocol = { version = "0.2.0", default-features = false } -alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } -alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } -tracing = { version = "0.1", default-features = false } -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } - -[dev-dependencies] -shepherd-sdk-test = { path = "../../crates/shepherd-sdk-test" } -nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs deleted file mode 100644 index 3286a566..00000000 --- a/modules/ethflow-watcher/src/lib.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! # ethflow-watcher (Shepherd module) -//! -//! Subscribes to `CoWSwapOnchainOrders.OrderPlacement` logs from the -//! canonical CoWSwap EthFlow contracts and verifies the orderbook's -//! native indexer caught each placement via `GET /api/v1/orders/{uid}`. -//! See `strategy.rs` for the design rationale: the orderbook -//! backend indexes EthFlow `OrderPlacement` events server-side with -//! its own dual-validTo bookkeeping, so `POST /api/v1/orders` is -//! structurally the wrong endpoint for on-chain EthFlow orders. The -//! module observes and verifies, it does not submit. -//! -//! ## Module layout -//! -//! - `strategy.rs` holds the pure logic and unit tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter that bridges the generated -//! free functions to the SDK traits, and the `Guest` impl that -//! delegates the `chain-logs` event variant to `strategy::on_chain_logs`. - -// wit_bindgen::generate! expands to host-import shims whose arity -// matches the WIT signatures, which can exceed clippy's -// too-many-arguments threshold. -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -// The wit-bindgen-generated import shims only resolve against the -// engine's wasm component host - they have no native-target -// equivalent. Cfg-gate the entire glue layer so the `rlib` artefact -// (consumed by `shepherd-backtest`) carries just the -// strategy code without dangling `extern "C"` imports. The -// `use wit_bindgen as _` line below silences the unused-crate -// lint on native targets where the macro never expands. -#[cfg(not(target_arch = "wasm32"))] -use wit_bindgen as _; - -#[cfg(target_arch = "wasm32")] -wit_bindgen::generate!({ - path: ["../../wit/nexum-host", "../../wit/shepherd-cow"], - world: "shepherd:cow/shepherd", - generate_all, -}); - -pub mod strategy; - -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level` -// are generated below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. -// Gated on `wasm32` so the strategy can be reused in native targets -// (e.g. the backtest replay harness in `crates/shepherd-backtest`). -#[cfg(target_arch = "wasm32")] -use nexum::host::types; - -#[cfg(target_arch = "wasm32")] -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); - -#[cfg(target_arch = "wasm32")] -struct EthFlowWatcher; - -#[cfg(target_arch = "wasm32")] -impl Guest for EthFlowWatcher { - fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { - install_tracing(); - tracing::info!("ethflow-watcher init"); - Ok(()) - } - - fn on_event(event: types::Event) -> Result<(), Fault> { - if let types::Event::ChainLogs(batch) = event { - let logs: Vec = - batch.logs.into_iter().map(Into::into).collect(); - strategy::on_chain_logs(&WitBindgenHost, batch.chain_id, &logs) - .map_err(sdk_fault_into_wit)?; - } - // Block / Tick / Message are not used by this module. - Ok(()) - } -} - -#[cfg(target_arch = "wasm32")] -export!(EthFlowWatcher); diff --git a/modules/ethflow-watcher/src/strategy.rs b/modules/ethflow-watcher/src/strategy.rs deleted file mode 100644 index 99e25ba1..00000000 --- a/modules/ethflow-watcher/src/strategy.rs +++ /dev/null @@ -1,580 +0,0 @@ -//! Pure strategy logic for the ethflow-watcher module. -//! -//! Every interaction with the world flows through the -//! `nexum_sdk::host::Host` trait seam - no direct calls to wit- -//! bindgen-generated free functions live here. The `lib.rs` glue -//! wraps a `WitBindgenHost` adapter around the per-cdylib wit-bindgen -//! imports and hands it to [`on_chain_logs`]; tests under `#[cfg(test)]` -//! drive the same function with `shepherd_sdk_test::MockHost`. -//! -//! ## Design (redesign) -//! -//! The original design POSTed each on-chain `OrderPlacement` -//! to `/api/v1/orders` with the EthFlow contract as the EIP-1271 owner. -//! Empirical evidence (2026-06-22 Sepolia soak) showed that path cannot -//! succeed: the orderbook backend indexes EthFlow `OrderPlacement` -//! events natively and writes server-only fields (`onchainUser`, -//! `onchainOrderData`, `ethflowData.userValidTo`) the public POST body -//! does not carry. Submissions through `/api/v1/orders` are rejected -//! with `ExcessiveValidTo` even though the same UID is `fulfilled` on -//! the orderbook by the time we look. -//! -//! This strategy therefore **observes + verifies** instead of -//! submitting: -//! -//! 1. Decode the `OrderPlacement` log against the canonical EthFlow -//! contract addresses. -//! 2. Compute the orderbook UID from the on-chain order shape -//! (`OrderData::uid(domain, contract)`). -//! 3. GET `/api/v1/orders/{uid}` to confirm the orderbook indexer -//! picked up the placement. On 200, record `observed:{uid}` in the -//! keeper idempotency journal so log re-delivery is a no-op. On -//! 404, log at Info - typical indexer lag, do not write the marker -//! so the next re-delivery rechecks. Any other error is logged at -//! Warn for operator follow-up. - -use alloy_primitives::{Address, Bytes}; -use alloy_sol_types::SolEvent; -use cowprotocol::{ - Chain, CoWSwapOnchainOrders::OrderPlacement, ETH_FLOW_PRODUCTION, ETH_FLOW_STAGING, - GPv2OrderData, OnchainSignature, OrderUid, -}; -use nexum_sdk::events::Log; -use nexum_sdk::host::Fault; -use nexum_sdk::keeper::Journal; -use shepherd_sdk::cow::{CowApiError, CowHost, gpv2_to_order_data}; - -/// Decoded payload of a `CoWSwapOnchainOrders.OrderPlacement` log. -/// `GPv2OrderData` is ~300 bytes; box it so the struct stays -/// cache-friendly when threaded through the observe path. -#[derive(Debug)] -pub(crate) struct DecodedPlacement { - /// EthFlow contract that emitted the event - also the EIP-1271 - /// owner of the resulting orderbook entry, used as the UID - /// `owner` input. - pub(crate) contract: Address, - /// Original native-token seller. Logged for operator diagnostics; - /// not the orderbook owner. - pub(crate) sender: Address, - pub(crate) order: Box, - /// Decoded signature. Recorded by the orderbook indexer itself; - /// not consumed by the observe path. - #[allow(dead_code)] - pub(crate) signature: OnchainSignature, - /// Refund pointer / opaque placer metadata embedded in the - /// `OrderPlacement` event. The orderbook indexer derives - /// `ethflowData.userValidTo` from this blob; we keep it on the - /// struct for parity with the decoder contract. - #[allow(dead_code)] - pub(crate) data: Bytes, -} - -/// Entry point: decode every `OrderPlacement` chain-log in a dispatch batch -/// and feed each decoded placement to the observe path. -pub fn on_chain_logs(host: &H, chain_id: u64, logs: &[Log]) -> Result<(), Fault> { - for log in logs { - if let Some(placement) = decode_order_placement(log) { - observe_placement(host, chain_id, &placement)?; - } - } - Ok(()) -} - -// ---- decode ---- - -/// Decode a raw event log against `CoWSwapOnchainOrders.OrderPlacement`. -/// -/// Returns `None` when: -/// - the log's contract address is neither `ETH_FLOW_PRODUCTION` nor -/// `ETH_FLOW_STAGING` (defensive - the host's `[[subscription]]` -/// filter already pins the address, but a misconfigured engine could -/// still leak through); -/// - topic0 does not match the event signature; or -/// - the ABI body fails to decode. -pub(crate) fn decode_order_placement(log: &Log) -> Option { - let contract = log.address(); - if contract != ETH_FLOW_PRODUCTION && contract != ETH_FLOW_STAGING { - return None; - } - let decoded = OrderPlacement::decode_log(&log.inner).ok()?; - Some(DecodedPlacement { - contract, - sender: decoded.data.sender, - order: Box::new(decoded.data.order), - signature: decoded.data.signature, - data: decoded.data.data, - }) -} - -// ---- observe + verify (redesign) ---- - -/// Compute the orderbook UID for the placement and confirm the -/// orderbook's native EthFlow indexer picked it up. -fn observe_placement( - host: &H, - chain_id: u64, - placement: &DecodedPlacement, -) -> Result<(), Fault> { - let uid_hex = match compute_uid(chain_id, placement) { - Some(uid) => format!("{uid}"), - None => { - tracing::warn!( - "ethflow uid build skipped (sender={:#x}): unsupported chain {chain_id} or unknown order marker", - placement.sender, - ); - return Ok(()); - } - }; - - // Idempotency: once verified, do not re-check on log re-delivery - // (engine restart, reorg replay, supervisor restart). - let journal = Journal::observed(host); - if journal.contains(&uid_hex)? { - return Ok(()); - } - - let path = format!("/api/v1/orders/{uid_hex}"); - match host.cow_api_request(chain_id, "GET", &path, None) { - Ok(_) => { - journal.record(&uid_hex)?; - tracing::info!( - "ethflow observed {uid_hex} (orderbook indexed, sender={:#x})", - placement.sender, - ); - } - Err(CowApiError::Http(http)) if http.status == 404 => { - // Indexer lag is expected immediately after the block lands - - // shepherd's WebSocket can deliver the log a few hundred - // milliseconds before the orderbook's own indexer commits. - // Do NOT write the marker so a later re-delivery (or a future - // block-tick poll) can recheck. Info keeps the soak dashboard - // quiet on normal lag. - tracing::info!( - "ethflow not yet indexed {uid_hex} (sender={:#x}); will recheck on re-delivery", - placement.sender, - ); - } - Err(err) => { - tracing::warn!( - "ethflow indexer check failed {uid_hex}: {err} (sender={:#x})", - placement.sender, - ); - } - } - Ok(()) -} - -/// Compute the canonical 56-byte orderbook UID for the placement. -/// `OrderData::uid` packs `digest || owner || valid_to`; the owner -/// input is the EthFlow contract (which signs via EIP-1271), not the -/// native-token sender. -fn compute_uid(chain_id: u64, placement: &DecodedPlacement) -> Option { - let chain = Chain::try_from(chain_id).ok()?; - let domain = chain.settlement_domain(); - let order_data = gpv2_to_order_data(&placement.order)?; - Some(order_data.uid(&domain, placement.contract)) -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_primitives::{U256, address, b256, hex}; - use alloy_sol_types::SolValue; - use cowprotocol::{BuyTokenDestination, OnchainSigningScheme, OrderKind, SellTokenSource}; - use nexum_sdk::Level; - use nexum_sdk::host::{Fault, LocalStoreHost as _}; - use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::HttpFailure; - use shepherd_sdk_test::MockHost; - - const SEPOLIA: u64 = 11_155_111; - - fn sample_order() -> GPv2OrderData { - GPv2OrderData { - sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), - buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), - receiver: address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"), - sellAmount: U256::from(1_000_000_u64), - buyAmount: U256::from(999_u64), - validTo: 0xffff_ffff, - appData: cowprotocol::EMPTY_APP_DATA_HASH, - feeAmount: U256::ZERO, - kind: OrderKind::SELL, - partiallyFillable: false, - sellTokenBalance: SellTokenSource::ERC20, - buyTokenBalance: BuyTokenDestination::ERC20, - } - } - - fn sample_event() -> OrderPlacement { - OrderPlacement { - sender: address!("00112233445566778899aabbccddeeff00112233"), - order: sample_order(), - signature: OnchainSignature { - scheme: OnchainSigningScheme::Eip1271, - data: hex!("c0ffeec0ffeec0ffee").to_vec().into(), - }, - data: hex!("deadbeef").to_vec().into(), - } - } - - fn encode_log(event: &OrderPlacement) -> (Vec>, Vec) { - let mut sender_topic = vec![0u8; 12]; - sender_topic.extend_from_slice(event.sender.as_slice()); - let topics = vec![OrderPlacement::SIGNATURE_HASH.to_vec(), sender_topic]; - let data = ( - event.order.clone(), - event.signature.clone(), - event.data.clone(), - ) - .abi_encode_params(); - (topics, data) - } - - /// Assemble the alloy log a placement decodes from, through the same - /// WIT-edge path the bind macro uses at runtime. - fn make_log(address_bytes: &[u8], topics: &[Vec], data: &[u8]) -> Log { - nexum_sdk::events::ChainLogParts { - address: address_bytes, - topics, - data, - ..Default::default() - } - .into() - } - - fn computed_uid(placement: &DecodedPlacement) -> String { - format!( - "{}", - compute_uid(SEPOLIA, placement).expect("sepolia + canonical markers") - ) - } - - // ---- decode (invariants preserved) ---- - - #[test] - fn decodes_well_formed_placement() { - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let decoded = decode_order_placement(&log).expect("decode succeeds"); - assert_eq!(decoded.contract, ETH_FLOW_PRODUCTION); - assert_eq!(decoded.sender, event.sender); - assert_eq!(decoded.signature.scheme, OnchainSigningScheme::Eip1271); - } - - #[test] - fn rejects_unrelated_contract_address() { - let event = sample_event(); - let (topics, data) = encode_log(&event); - let stranger = address!("dead00000000000000000000000000000000dead"); - let log = make_log(stranger.as_slice(), &topics, &data); - assert!(decode_order_placement(&log).is_none()); - } - - #[test] - fn rejects_wrong_topic_signature() { - let event = sample_event(); - let (_, data) = encode_log(&event); - let bad_topic = vec![0xaa_u8; 32]; - let sender_topic = vec![0u8; 32]; - let log = make_log( - ETH_FLOW_PRODUCTION.as_slice(), - &[bad_topic, sender_topic], - &data, - ); - assert!(decode_order_placement(&log).is_none()); - } - - // ---- UID computation ---- - - #[test] - fn compute_uid_pins_owner_to_ethflow_contract_and_validto() { - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let decoded = decode_order_placement(&log).unwrap(); - - let uid = compute_uid(SEPOLIA, &decoded).expect("sepolia + canonical markers"); - let bytes: [u8; 56] = uid.into(); - // owner suffix (bytes 32..52) = EthFlow contract address. - assert_eq!(&bytes[32..52], ETH_FLOW_PRODUCTION.as_slice()); - // valid_to suffix (bytes 52..56) = u32 BE of the on-chain validTo. - assert_eq!( - u32::from_be_bytes(bytes[52..56].try_into().unwrap()), - event.order.validTo, - ); - } - - #[test] - fn compute_uid_returns_none_on_unsupported_chain() { - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let decoded = decode_order_placement(&log).unwrap(); - assert!(compute_uid(9999, &decoded).is_none()); - } - - // ---- observe + verify dispatch (Host-trait integration) ---- - - /// 200 from `GET /api/v1/orders/{uid}` → `observed:{uid}` written - /// + Info log + zero submit attempts. - #[test] - fn placement_log_marks_observed_on_orderbook_200() { - let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); - - // Minimal stub of the orderbook's GET response - strategy only - // checks for 200 vs 404 vs other, the body is opaque to it. - host.cow_api.respond_to_request_for( - "GET", - format!("/api/v1/orders/{uid}"), - Ok(r#"{"status":"fulfilled"}"#.to_string()), - ); - - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); - - assert!( - host.store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "200 response must write observed:{{uid}} marker" - ); - assert_eq!( - host.cow_api.request_calls().len(), - 1, - "exactly one orderbook GET per log" - ); - assert_eq!( - host.cow_api.call_count(), - 0, - "observe path must never call submit_order" - ); - } - - /// 404 from `GET /api/v1/orders/{uid}` → no marker written + Info - /// log + the next re-delivery rechecks (no early dedup). - #[test] - fn placement_log_does_not_mark_observed_on_orderbook_404() { - let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); - - host.cow_api - .respond_to_request(Err(CowApiError::Http(HttpFailure { - status: 404, - body: None, - }))); - - let (result, logs) = capture_tracing(|| on_chain_logs(&host, SEPOLIA, &[log])); - result.unwrap(); - - assert!( - !host - .store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "404 must NOT write observed: so re-delivery can recheck" - ); - assert_eq!( - host.cow_api.request_calls().len(), - 1, - "the orderbook GET was attempted" - ); - let ev = logs.expect_one(|e| e.message.contains("not yet indexed")); - assert_eq!( - ev.level, - Level::INFO, - "indexer lag is expected; Info keeps soak dashboards quiet" - ); - } - - /// Non-404 error from the orderbook check → Warn log + no marker. - #[test] - fn placement_log_warns_on_orderbook_other_error() { - let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - - host.cow_api - .respond_to_request(Err(CowApiError::Fault(Fault::Unavailable( - "bad gateway".into(), - )))); - - let (result, logs) = capture_tracing(|| on_chain_logs(&host, SEPOLIA, &[log])); - result.unwrap(); - - assert!( - host.store.snapshot().is_empty(), - "non-404 error must not write any marker" - ); - assert_eq!( - host.cow_api.request_calls().len(), - 1, - "the orderbook GET was attempted" - ); - logs.expect_one(|e| e.level == Level::WARN && e.message.contains("indexer check failed")); - } - - /// Idempotency: a placement that already has `observed:{uid}` in - /// local store does NOT trigger a fresh GET on re-delivery. - #[test] - fn previously_observed_placement_is_skipped_on_redelivery() { - let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); - - host.store - .set(&format!("observed:{uid}"), b"") - .expect("seed observed marker"); - - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); - - assert_eq!( - host.cow_api.request_calls().len(), - 0, - "observed:{{uid}} must short-circuit before the orderbook GET" - ); - assert_eq!( - host.cow_api.call_count(), - 0, - "and certainly no submit_order" - ); - } - - /// Defensive: unsupported chain id surfaces a Warn but does not - /// panic and does not touch the orderbook. - #[test] - fn unsupported_chain_logs_warn_without_orderbook_call() { - let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - - // 9999 is not in cowprotocol::Chain. - let (result, logs) = capture_tracing(|| on_chain_logs(&host, 9999, &[log])); - result.unwrap(); - - assert_eq!(host.cow_api.request_calls().len(), 0); - assert_eq!(host.cow_api.call_count(), 0); - assert!(host.store.snapshot().is_empty()); - logs.expect_one(|e| { - e.level == Level::WARN && e.message.contains("ethflow uid build skipped") - }); - } - - /// Strategy must never call `submit_order` - the trait still - /// exposes it for other modules (twap-monitor legitimately - /// submits), but ethflow-watcher's observe design never does. - /// Belt-and-suspenders regression guard. - #[test] - fn strategy_never_calls_submit_order() { - let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - host.cow_api.respond_to_request(Ok("{}".to_string())); - - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); - - assert_eq!( - host.cow_api.call_count(), - 0, - "submit_order count must stay at zero - ethflow-watcher is observer-only" - ); - } - - /// Guard: the topic-0 hardcoded in `module.toml` matches the - /// keccak256 of the canonical `OrderPlacement` signature. - /// A typo or ABI drift would silently miss every EthFlow event. - #[test] - fn topic0_matches_order_placement_canonical_signature() { - assert_eq!( - OrderPlacement::SIGNATURE_HASH, - b256!("cf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9"), - "module.toml event_signature must equal keccak256 of the canonical ABI signature", - ); - } - - /// Stronger guard than the constant check above: read the shipped - /// `module.toml` and assert its pinned `event_signature` actually - /// equals `OrderPlacement::SIGNATURE_HASH` - catches a manifest/code - /// drift the ABI-hash assertion cannot see. (Ported from #164.) - #[test] - fn manifest_topic0_matches_order_placement_signature_hash() { - let manifest = include_str!("../module.toml"); - let expected = format!("0x{:x}", OrderPlacement::SIGNATURE_HASH); - assert!( - manifest.contains(&expected), - "module.toml event_signature must equal OrderPlacement::SIGNATURE_HASH ({expected})", - ); - } - - /// 429 (rate-limit) from the orderbook check → Warn log + no marker. - /// Verifies the strategy does not conflate 429 with 404 (which would - /// suppress the warning) and does not panic or return an error. - #[test] - fn placement_log_warns_on_429_rate_limit() { - let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); - - host.cow_api - .respond_to_request(Err(CowApiError::Http(HttpFailure { - status: 429, - body: Some("Too Many Requests".to_string()), - }))); - - let (result, logs) = capture_tracing(|| on_chain_logs(&host, SEPOLIA, &[log])); - result.unwrap(); - - assert!( - !host - .store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "429 must NOT write observed: marker" - ); - logs.expect_one(|e| e.level == Level::WARN && e.message.contains("indexer check failed")); - } - - /// HTTP 200 with a malformed (non-JSON) body → `observed:{uid}` still - /// written. The strategy only inspects Ok vs Err, never parses the body, - /// so any successful response confirms indexer pickup regardless of body - /// content. - #[test] - fn placement_log_marks_observed_on_malformed_response_body() { - let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); - - host.cow_api - .respond_to_request(Ok("not-valid-json{{{{".to_string())); - - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); - - assert!( - host.store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "200 with malformed body must still write observed: — strategy does not parse the response", - ); - } -} diff --git a/modules/example/src/lib.rs b/modules/example/src/lib.rs deleted file mode 100644 index 6ed4deb6..00000000 --- a/modules/example/src/lib.rs +++ /dev/null @@ -1,69 +0,0 @@ -// wit_bindgen::generate! expands to host-import shims whose arity matches -// the WIT signatures, which can exceed clippy's too-many-arguments threshold. -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -wit_bindgen::generate!({ - path: "../../wit/nexum-host", - world: "nexum:host/event-module", -}); - -use nexum::host::logging; -use nexum::host::types; - -// This is the SDK-free reference module: it depends only on -// `wit-bindgen` and installs no tracing subscriber, so it logs through -// the raw host `logging` binding directly. That binding is the same -// sink the `tracing` facade forwards to in the SDK-based modules, so -// the records are indistinguishable to the host. -struct ExampleModule; - -impl Guest for ExampleModule { - fn init(config: Vec<(String, String)>) -> Result<(), Fault> { - let name = config - .iter() - .find(|(k, _)| k == "name") - .map(|(_, v)| v.as_str()) - .unwrap_or("unknown"); - logging::log( - logging::Level::Info, - &format!("example module init (name={name})"), - ); - Ok(()) - } - - fn on_event(event: types::Event) -> Result<(), Fault> { - match &event { - types::Event::Block(block) => { - logging::log( - logging::Level::Info, - &format!( - "block {} on chain {} (ts={}ms)", - block.number, block.chain_id, block.timestamp - ), - ); - } - types::Event::ChainLogs(batch) => { - logging::log( - logging::Level::Info, - &format!("received {} chain-log entries", batch.logs.len()), - ); - } - types::Event::Tick(tick) => { - logging::log( - logging::Level::Info, - &format!("tick fired at {}ms", tick.fired_at), - ); - } - types::Event::Message(msg) => { - logging::log( - logging::Level::Info, - &format!("message on topic {}", msg.content_topic), - ); - } - } - Ok(()) - } -} - -export!(ExampleModule); diff --git a/modules/examples/balance-tracker/src/lib.rs b/modules/examples/balance-tracker/src/lib.rs deleted file mode 100644 index 55aec121..00000000 --- a/modules/examples/balance-tracker/src/lib.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! # balance-tracker (example Shepherd module) -//! -//! Subscribes to blocks, reads `eth_getBalance(addr)` for every -//! address in `[config].addresses` (comma-separated), persists the -//! last seen value under `balance:{addr}` in local-store, and emits -//! a Warn-level log line when the balance changes by more than -//! `[config].change_threshold` wei since the previous block. -//! -//! ## Module layout -//! -//! - `strategy.rs` holds the pure logic and tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter, the `Guest` impl. -//! -//! ## Config -//! -//! ```toml -//! [config] -//! # Comma-separated list of 0x-prefixed 20-byte addresses. -//! addresses = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8,0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" -//! # Change threshold in wei; an alert fires when the delta exceeds it. -//! change_threshold = "100000000000000000" # 0.1 ETH -//! ``` - -#![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 nexum::host::types; - -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level`, -// `HostLogSink`, `install_tracing` are generated below. Single source -// of truth in `nexum-sdk`. -nexum_sdk::bind_host_via_wit_bindgen!(); - -static SETTINGS: OnceLock = OnceLock::new(); - -struct BalanceTracker; - -impl Guest for BalanceTracker { - fn init(config: Vec<(String, String)>) -> Result<(), Fault> { - install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; - tracing::info!( - "balance-tracker init: {} addresses, threshold={} wei", - cfg.addresses.len(), - cfg.change_threshold, - ); - let _ = SETTINGS.set(cfg); - Ok(()) - } - - fn on_event(event: types::Event) -> Result<(), Fault> { - 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_fault_into_wit)?; - } - Ok(()) - } -} - -export!(BalanceTracker); diff --git a/modules/examples/http-probe/src/lib.rs b/modules/examples/http-probe/src/lib.rs deleted file mode 100644 index bb9ab42e..00000000 --- a/modules/examples/http-probe/src/lib.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! # http-probe (example Shepherd module) -//! -//! On every matching block, fetches an allowlisted URL over wasi:http -//! and logs the response status, then fetches an off-list URL and -//! verifies the host denies it before any connection is made. -//! Demonstrates the guest-side HTTP patterns of a Shepherd module: -//! -//! - `nexum_sdk::http::fetch` (wasi:http via the SDK helper) -//! - the `[capabilities.http].allow` allowlist and its denial path -//! - `[config]` driven behaviour parsed once in `init` -//! -//! ## Module layout -//! -//! - `strategy.rs` holds the pure logic and tests against the SDK's -//! `http::Fetch` seam, logging through the `tracing` facade. It does -//! not know `wit-bindgen` exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter, `install_tracing`, the -//! `Guest` impl. -//! -//! ## Settings -//! -//! ```toml -//! [config] -//! # URL fetched on every matching block; host must be allowlisted. -//! probe_url = "https://api.cow.fi/mainnet/api/v1/version" -//! # URL whose host is deliberately off-list; the module expects the -//! # denied error and treats any other outcome as a failure. -//! denied_url = "https://example.com/" -//! # Optional throttle: probe every N blocks. Default 1. -//! every_n_blocks = "1" -//! ``` - -// wit_bindgen::generate! expands to host-import shims whose arity matches -// the WIT signatures, which can exceed clippy's too-many-arguments threshold. -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -wit_bindgen::generate!({ - path: ["../../../wit/nexum-host", "../../../wit/shepherd-cow"], - world: "shepherd:cow/shepherd", - generate_all, -}); - -mod strategy; - -use std::sync::OnceLock; - -use nexum::host::types; - -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level`, -// `HostLogSink`, `install_tracing` are generated below. Single source -// of truth in `nexum-sdk`. -nexum_sdk::bind_host_via_wit_bindgen!(); - -static SETTINGS: OnceLock = OnceLock::new(); - -struct HttpProbe; - -impl Guest for HttpProbe { - fn init(config: Vec<(String, String)>) -> Result<(), Fault> { - install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; - tracing::info!( - "http-probe init: probe_url={} denied_url={} every_n_blocks={}", - cfg.probe_url, - cfg.denied_url, - cfg.every_n_blocks, - ); - let _ = SETTINGS.set(cfg); - Ok(()) - } - - fn on_event(event: types::Event) -> Result<(), Fault> { - let Some(cfg) = SETTINGS.get() else { - return Ok(()); - }; - if let types::Event::Block(block) = event { - strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number) - .map_err(sdk_fault_into_wit)?; - } - Ok(()) - } -} - -export!(HttpProbe); diff --git a/modules/examples/price-alert/src/lib.rs b/modules/examples/price-alert/src/lib.rs deleted file mode 100644 index e9d922d0..00000000 --- a/modules/examples/price-alert/src/lib.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! # price-alert (example Shepherd module) -//! -//! Polls a Chainlink price oracle on every new block and emits a -//! Warn-level log when the price crosses a config-supplied -//! threshold. Demonstrates the three load-bearing patterns of a -//! Shepherd module: -//! -//! - `chain::request` + ABI decode via `alloy_sol_types` -//! - `nexum_sdk` helpers (`prelude`, `chain::eth_call_params`, -//! `chain::parse_eth_call_result`) -//! - `[config]` driven behaviour parsed once in `init` and read on -//! every subsequent event -//! -//! ## Module layout -//! -//! - `strategy.rs` holds the pure logic and tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter, the `Guest` impl. -//! -//! ## Settings -//! -//! ```toml -//! [config] -//! # Chainlink AggregatorV3Interface address. -//! oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" # ETH/USD on Sepolia -//! # Oracle's decimals (Chainlink USD pairs are 8; ETH pairs 18). -//! decimals = "8" -//! # Threshold in the oracle's native units (decimal string). The -//! # module multiplies by 10**decimals at init. -//! threshold = "2500.00" -//! # Either "above" or "below". Fires when the answer crosses on -//! # the configured side. -//! direction = "below" -//! # Optional throttle: poll every N blocks. Default 1. -//! every_n_blocks = "1" -//! ``` - -// wit_bindgen::generate! expands to host-import shims whose arity matches -// the WIT signatures, which can exceed clippy's too-many-arguments threshold. -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -wit_bindgen::generate!({ - path: ["../../../wit/nexum-host", "../../../wit/shepherd-cow"], - world: "shepherd:cow/shepherd", - generate_all, -}); - -mod strategy; - -use std::sync::OnceLock; - -use nexum::host::types; - -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level` are generated -// below. Single source of truth in `nexum-sdk`. -nexum_sdk::bind_host_via_wit_bindgen!(); - -static SETTINGS: OnceLock = OnceLock::new(); - -struct PriceAlert; - -impl Guest for PriceAlert { - fn init(config: Vec<(String, String)>) -> Result<(), Fault> { - install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; - tracing::info!( - "price-alert init: oracle={:#x} threshold={} direction={:?} every_n_blocks={}", - cfg.oracle_address, - cfg.threshold_scaled, - cfg.direction, - cfg.every_n_blocks, - ); - let _ = SETTINGS.set(cfg); - Ok(()) - } - - fn on_event(event: types::Event) -> Result<(), Fault> { - let Some(cfg) = SETTINGS.get() else { - return Ok(()); - }; - if let types::Event::Block(block) = event { - strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number) - .map_err(sdk_fault_into_wit)?; - } - Ok(()) - } -} - -export!(PriceAlert); diff --git a/modules/examples/stop-loss/module.toml b/modules/examples/stop-loss/module.toml deleted file mode 100644 index 58065270..00000000 --- a/modules/examples/stop-loss/module.toml +++ /dev/null @@ -1,61 +0,0 @@ -# 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. The Sepolia -# Chainlink ETH/USD feed reports a mocked value around $1681 at the -# time of the E2E run (2026-06-18). Setting the trigger -# *above* the live price + direction=below ensures the strategy fires -# on the first block. -trigger_price = "2000.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. -# -# E2E run pinning: test EOA on Sepolia with 0.05 ETH -# balance. Without a pre-sign + a WETH wrap the orderbook will reject -# with TransferSimulationFailed which the SDK classifies as -# TryNextBlock — that itself is a valid terminal marker (`backoff:` -# write to local-store) and proves the full submit path E2E. -owner = "0x7bF140727D27ea64b607E042f1225680B40ECa6A" -# WETH9 Sepolia (`wss://sepolia.etherscan.io/token/0xfff9976782d46cc05630d1f6ebab18b2324d6b14`). -sell_token = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14" -# COW token Sepolia (verified on-chain: name="CoW Protocol Token", -# symbol="COW", decimals=18). -buy_token = "0x0625aFB445C3B6B7B929342a04A22599fd5dBB59" -# 0.005 WETH (small enough to fit in the 0.01 WETH wrap budget the -# E2E runbook recommends; large enough that the orderbook's min- -# quote endpoint actually returns a price). -sell_amount_wei = "5000000000000000" -# 20 COW (conservative; current quote on cow.fi/sepolia at the time -# of the E2E run is ~30 COW per 0.005 WETH so a 20 COW buy_amount -# leaves room for slippage without making the order too generous). -buy_amount_wei = "20000000000000000000" -# uint32::MAX = order never expires. -valid_to_seconds = "4294967295" diff --git a/modules/examples/stop-loss/src/lib.rs b/modules/examples/stop-loss/src/lib.rs deleted file mode 100644 index b5df56f9..00000000 --- a/modules/examples/stop-loss/src/lib.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! # 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 -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter, the `Guest` impl. -//! -//! Same recipe as `price-alert` - the wit-bindgen adapter -//! is intentionally mechanical and is a candidate for a future -//! declarative macro in the 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 nexum::host::types; - -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level` are generated -// below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); - -static SETTINGS: OnceLock = OnceLock::new(); - -struct StopLoss; - -impl Guest for StopLoss { - fn init(config: Vec<(String, String)>) -> Result<(), Fault> { - install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; - tracing::info!( - "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<(), Fault> { - 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_fault_into_wit)?; - } - Ok(()) - } -} - -export!(StopLoss); diff --git a/modules/examples/stop-loss/src/strategy.rs b/modules/examples/stop-loss/src/strategy.rs deleted file mode 100644 index bcec815b..00000000 --- a/modules/examples/stop-loss/src/strategy.rs +++ /dev/null @@ -1,543 +0,0 @@ -//! 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 [`CowHost`] trait so the tests can -//! drive it against `shepherd_sdk_test::MockHost`. - -use alloy_primitives::I256; -use nexum_sdk::chain::chainlink::read_latest_answer; -use nexum_sdk::config::{self, ConfigError}; -use nexum_sdk::host::Fault; -use nexum_sdk::prelude::{Address, Bytes, U256}; -use shepherd_sdk::cow::{ - CowApiError, CowHost, RetryAction, classify_api_error, gpv2_to_order_data, -}; -use shepherd_sdk::prelude::{ - BuyTokenDestination, Chain, EMPTY_APP_DATA_JSON, GPv2OrderData, OrderCreation, OrderKind, - OrderUid, SellTokenSource, Signature, -}; - -/// 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<(), Fault> { - let price = match read_latest_answer(host, chain_id, settings.oracle_address, "stop-loss") { - Some(p) => p, - None => return Ok(()), // logged inside read_latest_answer - }; - - if price > settings.trigger_price_scaled { - tracing::info!( - price = %price, - trigger = %settings.trigger_price_scaled, - "stop-loss idle", - ); - 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) => { - tracing::warn!(error = %e, "stop-loss skipped (build)"); - return Ok(()); - } - }; - let uid_hex = format!("{uid}"); - let dedup_key = format!("submitted:{uid_hex}"); - if host.get(&dedup_key)?.is_some() { - tracing::info!(uid = %uid_hex, "stop-loss already submitted, idle"); - return Ok(()); - } - let dropped_key = format!("dropped:{uid_hex}"); - if host.get(&dropped_key)?.is_some() { - tracing::info!(uid = %uid_hex, "stop-loss previously dropped, idle"); - return Ok(()); - } - - let body = match serde_json::to_vec(&creation) { - Ok(b) => b, - Err(e) => { - tracing::error!(error = %e, "OrderCreation JSON encode failed"); - return Ok(()); - } - }; - match host.submit_order(chain_id, &body) { - Ok(server_uid) => { - if server_uid != uid_hex { - tracing::warn!( - local = %uid_hex, - server = %server_uid, - "stop-loss uid drift", - ); - } - host.set(&format!("submitted:{server_uid}"), b"")?; - tracing::warn!( - price = %price, - trigger = %settings.trigger_price_scaled, - uid = %server_uid, - "stop-loss TRIGGERED", - ); - } - Err(err) => { - // Only a typed orderbook rejection classifies; transport - // faults and raw HTTP errors are transient (retry next - // block) rather than a terminal drop. - let action = match &err { - CowApiError::Rejected(rejection) => classify_api_error(rejection), - _ => RetryAction::TryNextBlock, - }; - match action { - RetryAction::TryNextBlock | RetryAction::Backoff { .. } => { - tracing::warn!(error = %err, "stop-loss retry on next block"); - } - RetryAction::Drop => { - host.set(&dropped_key, b"")?; - tracing::warn!(uid = %uid_hex, error = %err, "stop-loss dropped"); - } - // `RetryAction` is `#[non_exhaustive]`; treat unknown - // future variants like `TryNextBlock` rather than - // silently dropping the watch on an SDK bump. - _ => { - tracing::warn!( - error = %err, - "stop-loss unknown retry-action - retry on next block", - ); - } - } - } - } - Ok(()) -} - -// `read_oracle` moved into `nexum_sdk::chain::chainlink::read_latest_answer` -// (PR #55 review): the same flow + `Option` return shape now serves -// price-alert + stop-loss from the SDK, with `domain: &str` carrying the -// module label into the Warn log. - -/// 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), Fault> { - let chain = Chain::try_from(chain_id).map_err(|_| { - Fault::Unsupported(format!("chain {chain_id} not supported by cowprotocol")) - })?; - 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(|| { - Fault::InvalidInput("GPv2OrderData carried an unknown enum marker".into()) - })?; - let uid = order_data.uid(&domain, settings.owner); - let creation = OrderCreation::new( - &order_data, - Signature::PreSign, - settings.owner, - EMPTY_APP_DATA_JSON.to_string(), - None, - ) - .map_err(|e| Fault::InvalidInput(format!("cowprotocol rejected the body: {e}")))?; - // 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 = config::get_required(entries, "oracle_address") - .map_err(config_err)? - .parse::
() - .map_err(|e| invalid(format!("oracle_address: {e}")))?; - let decimals = config::get_required(entries, "decimals") - .map_err(config_err)? - .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 = config::scale_decimal( - config::get_required(entries, "trigger_price").map_err(config_err)?, - decimals, - "trigger_price", - ) - .map_err(config_err)?; - let owner = config::get_required(entries, "owner") - .map_err(config_err)? - .parse::
() - .map_err(|e| invalid(format!("owner: {e}")))?; - let sell_token = config::get_required(entries, "sell_token") - .map_err(config_err)? - .parse::
() - .map_err(|e| invalid(format!("sell_token: {e}")))?; - let buy_token = config::get_required(entries, "buy_token") - .map_err(config_err)? - .parse::
() - .map_err(|e| invalid(format!("buy_token: {e}")))?; - let sell_amount = config::get_required(entries, "sell_amount_wei") - .map_err(config_err)? - .parse::() - .map_err(|e| invalid(format!("sell_amount_wei: {e}")))?; - let buy_amount = config::get_required(entries, "buy_amount_wei") - .map_err(config_err)? - .parse::() - .map_err(|e| invalid(format!("buy_amount_wei: {e}")))?; - let valid_to = config::get_required(entries, "valid_to_seconds") - .map_err(config_err)? - .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, - }) -} - -/// Lift a free-text invalid-config detail into a [`Fault::InvalidInput`]. -/// Used when the SDK helper does not own the error (e.g. an -/// `Address::from_str` failure or a `U256::from_str` overflow). -fn invalid(message: impl Into) -> Fault { - Fault::InvalidInput(message.into()) -} - -/// Project a `nexum_sdk::config::ConfigError` into a -/// [`Fault::InvalidInput`] via `Display`. -fn config_err(e: ConfigError) -> Fault { - invalid(e.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_primitives::hex; - use alloy_sol_types::SolCall; - use nexum_sdk::Level; - use nexum_sdk::chain::chainlink::AggregatorV3; - use nexum_sdk::chain::eth_call_params; - use nexum_sdk::host::{ChainError, Fault}; - use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::OrderRejection; - 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}") - } - - /// Regression test pinning the OrderUid produced by the - /// E2E run's `modules/examples/stop-loss/module.toml` config so an - /// operator can `setPreSignature(uid, true)` ahead of the run - /// without re-deriving the UID from the EIP-712 / domain- - /// separator dance. If this assertion ever flips, either: - /// (a) the module.toml has drifted from the pinned settings, or - /// (b) the EIP-712 type-hash / domain-separator changed, - /// and the runbook's `setPreSignature` step needs the new UID. - #[test] - fn e2e_settings_yield_expected_uid() { - let settings = Settings { - oracle_address: "0x694AA1769357215DE4FAC081bf1f309aDC325306" - .parse() - .unwrap(), - trigger_price_scaled: I256::try_from(200_000_000_000_i128).unwrap(), - owner: "0x7bF140727D27ea64b607E042f1225680B40ECa6A" - .parse() - .unwrap(), - sell_token: "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14" - .parse() - .unwrap(), - buy_token: "0x0625aFB445C3B6B7B929342a04A22599fd5dBB59" - .parse() - .unwrap(), - sell_amount: U256::from(5_000_000_000_000_000_u128), - buy_amount: U256::from(20_000_000_000_000_000_000_u128), - valid_to: u32::MAX, - }; - assert_eq!( - programmed_uid(&settings), - "0xc2b9cb4ea1ee5a86d8049ac09d8f494bf04cca0a68407285f31e2e6379800be87bf140727d27ea64b607e042f1225680b40eca6affffffff", - ); - } - - #[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_eq!( - host.chain.call_count(), - 1, - "oracle consulted: idle because above trigger, not because unread" - ); - } - - #[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.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_eq!( - host.chain.call_count(), - 2, - "oracle still polled each block; dedup is at the submit stage" - ); - } - - #[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 the - // retriable-error classifier. - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InvalidSignature".into(), - description: "bad sig".into(), - data: None, - }))); - - 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}")) - ); - - // Second block: dropped marker idles the loop. - on_block(&host, SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); // no resubmit - } - - #[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)), - ); - - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InsufficientFee".into(), - description: "fee too low".into(), - data: None, - }))); - - let (result, logs) = capture_tracing(|| on_block(&host, SEPOLIA, &s)); - result.unwrap(); - - // No persistence flag - next block will retry. - assert_eq!(host.store.len(), 0); - assert_eq!(host.cow_api.call_count(), 1, "the submit was attempted"); - logs.expect_one(|e| e.level == Level::WARN && e.message.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(ChainError::Fault(Fault::Timeout)), - ); - - 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(); - let Fault::InvalidInput(message) = err else { - panic!("expected invalid-input fault, got {err:?}"); - }; - assert!(message.contains("owner")); - } -} diff --git a/modules/fixtures/slow-host/src/lib.rs b/modules/fixtures/slow-host/src/lib.rs deleted file mode 100644 index 5ed99ae1..00000000 --- a/modules/fixtures/slow-host/src/lib.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! # slow-host (test fixture) -//! -//! On every event issues a single `chain::request` host call and returns -//! `Ok`. The handler does no guest-side work of note; the point is the -//! host call itself. -//! -//! Fuel meters only guest wasm instructions and epoch interruption fires -//! only at wasm instruction boundaries, so neither can see, let alone -//! bound, time the guest spends suspended inside a host call. This fixture -//! makes that gap observable: the integration test wires the `chain` -//! capability to a mock provider that parks the first `request` far past a -//! short `event_deadline_secs` override. The guest suspends inside the host -//! call, the per-dispatch wall-clock deadline fires, and the supervisor -//! must drop the suspended call, mark the module dead, and reinstantiate it -//! on a fresh store. On the next dispatch the mock answers promptly, so the -//! same guest recovers and returns `Ok`. -//! -//! The result of the call is deliberately ignored: whether the request -//! resolves, errors, or is cut off, the handler returns `Ok(())`, so the -//! only thing that can end a dispatch early is the deadline under test. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only and never gets loaded by the testnet configs. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -wit_bindgen::generate!({ - path: "../../../wit/nexum-host", - world: "nexum:host/event-module", -}); - -use nexum::host::{chain, logging, types}; - -struct SlowHost; - -impl Guest for SlowHost { - fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { - // Minimal SDK-free fixture: no tracing subscriber is installed, - // so log through the raw host binding directly. - logging::log(logging::Level::Info, "slow-host init"); - Ok(()) - } - - fn on_event(_event: types::Event) -> Result<(), Fault> { - // A single read-only RPC. The test's mock provider decides how long - // it takes to answer; the guest just awaits it. `eth_blockNumber` - // with empty params is the cheapest well-formed request in the - // permitted read surface. - let _ = chain::request(1, "eth_blockNumber", "[]"); - logging::log(logging::Level::Info, "slow-host on_event returned"); - Ok(()) - } -} - -export!(SlowHost); diff --git a/modules/twap-monitor/module.toml b/modules/twap-monitor/module.toml deleted file mode 100644 index e49b8dec..00000000 --- a/modules/twap-monitor/module.toml +++ /dev/null @@ -1,41 +0,0 @@ -# twap-monitor: poll registered ComposableCoW conditional orders and -# submit ready ones via the CoW Protocol orderbook. - -[module] -name = "twap-monitor" -version = "0.1.0" -# Placeholder content hash. 0.2 parses but does not verify this; 0.3 will -# compare it against the sha256 of the loaded component bytes. -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -# Host interfaces the module imports and exercises: -# - logging -> structured runtime logs -# - local-store -> watch: / next_block: / next_epoch: / submitted: / -# backoff: / dropped: persistence -# - chain -> eth_call into ComposableCoW.getTradeableOrderWithSignature -# - cow-api -> POST /api/v1/orders submission path -required = ["logging", "local-store", "chain", "cow-api"] -optional = [] - -[capabilities.http] -# All outbound HTTP goes through `cow-api` (which routes through the -# host's pinned orderbook URL); no direct `http` calls. -allow = [] - -# --- subscriptions ------------------------------------------------------ - -# ComposableCoW.ConditionalOrderCreated emissions on Sepolia. topic-0 = -# keccak256("ConditionalOrderCreated(address,(address,bytes32,bytes))"). -# Both `address` and `event_signature` are pinned so the supervisor -# does not deliver unrelated logs to the module. -[[subscription]] -kind = "chain-log" -chain_id = 11155111 -address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" -event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361" - -# New-block ticks drive the TWAP poll loop (`getTradeableOrderWithSignature`). -[[subscription]] -kind = "block" -chain_id = 11155111 diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs deleted file mode 100644 index e6f4fa62..00000000 --- a/modules/twap-monitor/src/lib.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! # twap-monitor (Shepherd module) -//! -//! Indexes `ComposableCoW.ConditionalOrderCreated` logs and polls each -//! watched conditional order on every block, submitting tranches to -//! the CoW orderbook as they go live. -//! -//! ## Module layout -//! -//! - `strategy.rs` holds the pure logic and unit tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter that bridges the generated -//! free functions to the SDK traits, and the `Guest` impl that -//! delegates each event variant to `strategy`. -//! -//! Same recipe as `modules/examples/price-alert` and -//! `modules/examples/stop-loss`. - -// wit_bindgen::generate! expands to host-import shims whose arity -// matches the WIT signatures, which can exceed clippy's -// too-many-arguments threshold. -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -wit_bindgen::generate!({ - path: ["../../wit/nexum-host", "../../wit/shepherd-cow"], - world: "shepherd:cow/shepherd", - generate_all, -}); - -mod strategy; - -use nexum::host::types; - -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level` -// are generated below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); - -struct TwapMonitor; - -impl Guest for TwapMonitor { - fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { - install_tracing(); - tracing::info!("twap-monitor init"); - Ok(()) - } - - fn on_event(event: types::Event) -> Result<(), Fault> { - match event { - types::Event::ChainLogs(batch) => { - let logs: Vec = - batch.logs.into_iter().map(Into::into).collect(); - strategy::on_chain_logs(&WitBindgenHost, &logs).map_err(sdk_fault_into_wit)?; - } - types::Event::Block(block) => { - let info = strategy::BlockInfo { - chain_id: block.chain_id, - number: block.number, - timestamp: block.timestamp, - }; - strategy::on_block(&WitBindgenHost, info).map_err(sdk_fault_into_wit)?; - } - // Tick / Message are not used by this module. - _ => {} - } - Ok(()) - } -} - -export!(TwapMonitor); diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs deleted file mode 100644 index 44d789d2..00000000 --- a/modules/twap-monitor/src/strategy.rs +++ /dev/null @@ -1,1144 +0,0 @@ -//! Pure strategy logic for the twap-monitor module. -//! -//! Every interaction with the world flows through the -//! `nexum_sdk::host::Host` trait seam - no direct calls to wit- -//! bindgen-generated free functions live here. The `lib.rs` glue -//! wraps a `WitBindgenHost` adapter around the per-cdylib wit-bindgen -//! imports and hands it to [`on_chain_logs`] / [`on_block`]; tests under -//! `#[cfg(test)]` hand the same functions a -//! `shepherd_sdk_test::MockHost`. - -use alloy_primitives::{Address, B256, Bytes, keccak256}; -use alloy_sol_types::{SolCall, SolEvent, SolValue}; -use cowprotocol::{ - COMPOSABLE_COW, Chain, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, - GPv2OrderData, OrderCreation, Signature, -}; -use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; -use nexum_sdk::events::Log; -use nexum_sdk::host::{ChainError, Fault}; -use shepherd_sdk::cow::{ - CowApiError, CowHost, PollOutcome, RetryAction, classify_api_error, decode_revert, - gpv2_to_order_data, -}; - -/// Block fields the poll path reads on every dispatch. -pub struct BlockInfo { - pub chain_id: u64, - pub number: u64, - pub timestamp: u64, -} - -mod abi { - use alloy_sol_types::sol; - - sol! { - /// Wire-format mirror of `cowprotocol::ConditionalOrderParams`. sol! - /// cannot reference Rust types declared in another sol! block, but - /// the ABI is identical (same field types in the same order) so - /// the generated call selector matches the real contract. - struct Params { - address handler; - bytes32 salt; - bytes staticInput; - } - - /// Selector source for `eth_call`. The successful return path - /// decodes into the canonical `cowprotocol::GPv2OrderData` - /// instead of duplicating the 12-field struct here. - function getTradeableOrderWithSignature( - address owner, - Params params, - bytes offchainInput, - bytes32[] proof - ) external view; - } -} - -/// Indexer entry: decode every `ComposableCoW.ConditionalOrderCreated` -/// chain-log in a dispatch batch and persist its watch. -pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { - for log in logs { - if let Some((owner, params)) = decode_conditional_order_created(log) { - persist_watch(host, owner, ¶ms)?; - } - } - Ok(()) -} - -/// Poll entry: scan every persisted watch and dispatch ready tranches. -pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { - poll_all_watches(host, &block) -} - -// ---- indexing path ---- - -fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOrderParams)> { - let decoded = ConditionalOrderCreated::decode_log(&log.inner).ok()?; - Some((decoded.data.owner, decoded.data.params)) -} - -/// `set` overwrites in place, so re-indexing the same log (re-org -/// replay, overlapping subscription windows) produces no observable -/// side effect. -fn persist_watch( - host: &H, - owner: Address, - params: &ConditionalOrderParams, -) -> Result<(), Fault> { - let encoded = params.abi_encode(); - let params_hash = keccak256(&encoded); - let key = watch_key(&owner, ¶ms_hash); - host.set(&key, &encoded)?; - tracing::info!("indexed {key}"); - Ok(()) -} - -// ---- poll path ---- - -fn poll_all_watches(host: &H, block: &BlockInfo) -> Result<(), Fault> { - let now_epoch_s = block.timestamp / 1000; - let keys = host.list_keys("watch:")?; - for key in keys { - let Some((owner_hex, hash_hex)) = parse_watch_key(&key) else { - continue; - }; - if !is_ready(host, owner_hex, hash_hex, block.number, now_epoch_s)? { - continue; - } - let Some(value) = host.get(&key)? else { - continue; - }; - let Ok(params) = ConditionalOrderParams::abi_decode(&value) else { - tracing::warn!("watch {key} carried unparseable params; skipping"); - continue; - }; - let Ok(owner) = owner_hex.parse::
() else { - continue; - }; - let outcome = poll_one(host, block.chain_id, &owner, ¶ms); - tracing::info!("poll {key} -> {}", outcome_label(&outcome)); - match outcome { - PollOutcome::Ready { order, signature } => { - submit_ready( - host, - block.chain_id, - owner, - &order, - signature, - &key, - now_epoch_s, - )?; - } - non_ready => { - apply_watch_update(host, outcome_to_update(&non_ready), &key)?; - } - } - } - Ok(()) -} - -fn poll_one( - host: &H, - chain_id: u64, - owner: &Address, - params: &ConditionalOrderParams, -) -> PollOutcome { - let call = abi::getTradeableOrderWithSignatureCall { - owner: *owner, - params: abi::Params { - handler: params.handler, - salt: params.salt, - staticInput: params.staticInput.clone(), - }, - offchainInput: Bytes::new(), - proof: Vec::new(), - }; - let params_json = eth_call_params(&COMPOSABLE_COW, &call.abi_encode()); - match host.request(chain_id, "eth_call", ¶ms_json) { - Ok(result_json) => parse_eth_call_result(&result_json) - .and_then(|bytes| decode_return(&bytes)) - .unwrap_or(PollOutcome::TryNextBlock), - // A structured JSON-RPC error (the normal shape for an - // `eth_call` revert): the chain backend has already hex-decoded - // the `error.data` payload, so `decode_revert` dispatches - // `PollTryAtBlock` / `PollTryAtEpoch` / `OrderNotValid` / - // `PollNever` straight off the bytes. A revert the decoder does - // not recognise falls through to the safe `TryNextBlock`. - Err(ChainError::Rpc(rpc)) => rpc - .data - .as_deref() - .and_then(|bytes| decode_revert(bytes)) - .unwrap_or_else(|| { - tracing::warn!( - "eth_call reverted ({}); defaulting to TryNextBlock", - rpc.message - ); - PollOutcome::TryNextBlock - }), - // A transport-level fault (timeout, RPC down, ...): retry on the - // next block. - Err(ChainError::Fault(fault)) => { - tracing::warn!("eth_call failed ({fault}); defaulting to TryNextBlock"); - PollOutcome::TryNextBlock - } - } -} - -/// Decode a successful `getTradeableOrderWithSignature` return into -/// `Ready { order, signature }`. The wire format is `abi.encode(order, -/// signature)` - the canonical Solidity return tuple - so the two-tuple -/// parameter decode lines up. -fn decode_return(data: &[u8]) -> Option { - let (order, signature) = <(GPv2OrderData, Bytes)>::abi_decode_params(data).ok()?; - Some(PollOutcome::Ready { - order: Box::new(order), - signature, - }) -} - -fn outcome_label(o: &PollOutcome) -> &'static str { - match o { - PollOutcome::Ready { .. } => "Ready", - PollOutcome::TryAtEpoch(_) => "TryAtEpoch", - PollOutcome::TryOnBlock(_) => "TryOnBlock", - PollOutcome::TryNextBlock => "TryNextBlock", - PollOutcome::DontTryAgain => "DontTryAgain", - } -} - -// ---- key conventions ---- - -fn watch_key(owner: &Address, params_hash: &B256) -> String { - format!("watch:{owner:#x}:{params_hash:#x}") -} - -fn parse_watch_key(key: &str) -> Option<(&str, &str)> { - let rest = key.strip_prefix("watch:")?; - let (owner, hash) = rest.split_once(':')?; - Some((owner, hash)) -} - -fn is_ready( - host: &H, - owner_hex: &str, - hash_hex: &str, - block_number: u64, - epoch_s: u64, -) -> Result { - if let Some(next) = read_u64(host, &format!("next_block:{owner_hex}:{hash_hex}"))? - && block_number < next - { - return Ok(false); - } - if let Some(next) = read_u64(host, &format!("next_epoch:{owner_hex}:{hash_hex}"))? - && epoch_s < next - { - return Ok(false); - } - Ok(true) -} - -fn read_u64(host: &H, key: &str) -> Result, Fault> { - let bytes = host.get(key)?; - Ok(bytes - .and_then(|b| <[u8; 8]>::try_from(b.as_slice()).ok()) - .map(u64::from_le_bytes)) -} - -// ---- submission path ---- - -/// `cowprotocol`-side rejection envelope for an `OrderCreation` we -/// failed to assemble. Surfaces in a Warn log; the watch is left in -/// place so the next poll can either re-construct or transition on -/// its own. -/// -/// `IntoStaticStr` exposes each variant as a snake_case `&'static -/// str` so the submission warning log can carry `error_kind = -/// unknown_marker` without a match-ladder in the call site. -#[derive(Debug, thiserror::Error, strum::IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -enum BuildError { - /// `GPv2OrderData` carried a marker (`kind`, balance enum) we don't - /// know how to map. - #[error("GPv2OrderData carried an unknown enum marker")] - UnknownMarker, - /// `cowprotocol` rejected the body - typically `from == - /// Address::ZERO` or a `validTo` beyond the client-side horizon. - #[error(transparent)] - Cowprotocol(#[from] cowprotocol::Error), -} - -/// Assemble the `OrderCreation` body the orderbook expects from a -/// freshly-polled TWAP tranche. -/// -/// The signed `order.appData` digest is submitted verbatim (the -/// hash-only `OrderCreationAppData::Hash` wire shape) - watch-tower -/// parity. The orderbook joins the document it already has registered -/// for that digest; when it has none, the submit rejects with -/// `INVALID_APP_DATA` and [`classify_api_error`] dispatches the retry. -fn build_order_creation( - order: &GPv2OrderData, - signature: Bytes, - from: Address, -) -> Result { - let order_data = gpv2_to_order_data(order).ok_or(BuildError::UnknownMarker)?; - let signature = Signature::Eip1271(signature.to_vec()); - let creation = OrderCreation::new_app_data_hash_only(&order_data, signature, from, None)?; - Ok(creation) -} - -fn submit_ready( - host: &H, - chain_id: u64, - owner: Address, - order: &GPv2OrderData, - signature: Bytes, - watch_key: &str, - now_epoch_s: u64, -) -> Result<(), Fault> { - // Short-circuit if the orderbook UID for this exact - // (order, owner, chain) tuple is already in our local-store as - // `submitted:`. The poll-tick can re-fire `Ready` for the same - // TWAP child in successive blocks - `getTradeableOrderWithSignature` - // does not know shepherd already POSTed it - and re-submitting - // wastes a submit_order call and emits a misleading - // `DuplicatedOrder` Warn. The UID computation is deterministic - // from on-chain inputs (and matches what the orderbook derives - // server-side from the signed payload), so we can check before - // doing any network work. We also reuse the computed value below - // as the `submitted:{uid}` marker key, so the read and write - // paths agree. - let client_uid_hex = compute_uid_hex(chain_id, order, owner); - if let Some(uid_hex) = client_uid_hex.as_deref() - && host.get(&format!("submitted:{uid_hex}"))?.is_some() - { - tracing::info!("twap {uid_hex} already submitted; skipping poll re-submit"); - return Ok(()); - } - - // CoW Swap UI (and other clients) sign TWAPs with a non-empty - // `appData` hash that points at a JSON document already registered - // with the orderbook. Submit the signed digest verbatim (hash-only - // shape) and let the orderbook join its own registry - watch-tower - // parity. An unregistered digest rejects as `INVALID_APP_DATA` and - // `classify_api_error` dispatches the backoff. - let creation = match build_order_creation(order, signature, owner) { - Ok(c) => c, - Err(e) => { - tracing::warn!("twap submit skipped for {owner:#x}: {e}"); - return Ok(()); - } - }; - let body = match serde_json::to_vec(&creation) { - Ok(b) => b, - Err(e) => { - tracing::error!("OrderCreation JSON encode failed: {e}"); - return Ok(()); - } - }; - match host.submit_order(chain_id, &body) { - Ok(server_uid) => { - // Prefer the client-computed UID for the marker key so the - // idempotency check at the top of `submit_ready` reads what - // we wrote. In production the server-returned - // UID is the same value (both sides derive it from the - // signed `OrderData` via the canonical - // `digest || owner || valid_to` layout); a divergence - // would be a protocol-level bug worth surfacing rather - // than silently splitting the keyspace. - let marker_uid = client_uid_hex.as_deref().unwrap_or(server_uid.as_str()); - let key = format!("submitted:{marker_uid}"); - // Empty marker - presence of the key is the receipt. - host.set(&key, b"")?; - if let Some(client_uid) = client_uid_hex.as_deref() - && client_uid != server_uid - { - tracing::warn!( - "twap UID divergence: client={client_uid} server={server_uid} \ - (marker stored under client UID for idempotency consistency)" - ); - } - tracing::info!("submitted {key}"); - } - Err(err) => { - apply_submit_retry(host, &err, watch_key, now_epoch_s)?; - } - } - Ok(()) -} - -/// Compute the orderbook UID hex (`0x` + 112 hex chars) for the given -/// on-chain (order, owner, chain) tuple, mirroring what `submit_order` -/// will deduce server-side. Used by [`submit_ready`] to short-circuit -/// poll-tick re-submissions of an already-submitted TWAP child. -/// -/// Returns `None` if the chain id is unsupported by `cowprotocol::Chain` -/// or the order carries an unknown enum marker - both cases also stop -/// the regular submit path downstream, so the caller can fall through -/// to the normal flow and let it surface the appropriate diagnostic. -fn compute_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { - let chain = Chain::try_from(chain_id).ok()?; - let domain = chain.settlement_domain(); - let order_data = gpv2_to_order_data(order)?; - Some(format!("{}", order_data.uid(&domain, owner))) -} - -// ---- OrderPostError -> retry action ---- - -fn apply_submit_retry( - host: &H, - err: &CowApiError, - watch_key: &str, - now_epoch_s: u64, -) -> Result<(), Fault> { - // Only a typed orderbook rejection classifies; transport faults and - // raw HTTP errors are transient, so the watch stays in place. - let action = match err { - CowApiError::Rejected(rejection) => classify_api_error(rejection), - _ => RetryAction::TryNextBlock, - }; - match action { - RetryAction::TryNextBlock => { - tracing::warn!("submit retry-next-block: {err}"); - } - RetryAction::Backoff { seconds } => { - let until = now_epoch_s.saturating_add(seconds); - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - host.set( - &format!("next_epoch:{owner_hex}:{hash_hex}"), - &until.to_le_bytes(), - )?; - } - tracing::warn!("submit backoff {seconds}s -> next_epoch={until}: {err}"); - } - RetryAction::Drop => { - host.delete(watch_key)?; - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - let _ = host.delete(&format!("next_block:{owner_hex}:{hash_hex}")); - let _ = host.delete(&format!("next_epoch:{owner_hex}:{hash_hex}")); - } - tracing::warn!("submit dropped watch: {err}"); - } - // `RetryAction` is `#[non_exhaustive]`; future variants - // default to "leave the watch in place" (the conservative - // dispatch choice). Once a new variant gets a real meaning - // its arm should be added explicitly. - _ => { - tracing::warn!("submit unknown retry-action: {err} - leaving watch in place"); - } - } - Ok(()) -} - -// ---- PollOutcome lifecycle dispatch ---- - -/// What `apply_watch_update` should do for a given outcome. Kept as a -/// data type (rather than running the effects directly) so the -/// decision is host-free testable. -#[derive(Debug, Eq, PartialEq)] -enum WatchUpdate { - /// Leave the store untouched. Next block re-polls the watch. - NoOp, - /// Write `next_block:` so subsequent polls skip until the given - /// block number is reached. - SetNextBlock(u64), - /// Write `next_epoch:` so subsequent polls skip until the given - /// Unix-seconds timestamp is reached. - SetNextEpoch(u64), - /// Delete the watch and any stale gate keys - TWAP completed, - /// cancelled, or otherwise irrecoverable. - DropWatch, -} - -/// Pure mapping from a non-Ready `PollOutcome` to the lifecycle effect -/// the contract specifies. `Ready` is handled by the submit -/// path and is rejected here so a caller cannot -/// accidentally erase the watch when an order was actually produced. -fn outcome_to_update(outcome: &PollOutcome) -> WatchUpdate { - match outcome { - PollOutcome::Ready { .. } => WatchUpdate::NoOp, - PollOutcome::TryNextBlock => WatchUpdate::NoOp, - PollOutcome::TryOnBlock(n) => WatchUpdate::SetNextBlock(*n), - PollOutcome::TryAtEpoch(t) => WatchUpdate::SetNextEpoch(*t), - PollOutcome::DontTryAgain => WatchUpdate::DropWatch, - } -} - -fn apply_watch_update( - host: &H, - update: WatchUpdate, - watch_key: &str, -) -> Result<(), Fault> { - match update { - WatchUpdate::NoOp => Ok(()), - WatchUpdate::SetNextBlock(n) => { - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - host.set( - &format!("next_block:{owner_hex}:{hash_hex}"), - &n.to_le_bytes(), - )?; - } - Ok(()) - } - WatchUpdate::SetNextEpoch(t) => { - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - host.set( - &format!("next_epoch:{owner_hex}:{hash_hex}"), - &t.to_le_bytes(), - )?; - } - Ok(()) - } - WatchUpdate::DropWatch => { - host.delete(watch_key)?; - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - let _ = host.delete(&format!("next_block:{owner_hex}:{hash_hex}")); - let _ = host.delete(&format!("next_epoch:{owner_hex}:{hash_hex}")); - } - tracing::info!("dropped watch {watch_key}"); - Ok(()) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_primitives::{U256, address, b256, hex}; - use cowprotocol::OrderCreationAppData; - use cowprotocol::{BuyTokenDestination, OrderKind, SellTokenSource}; - use nexum_sdk::Level; - use nexum_sdk::host::LocalStoreHost as _; - use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::OrderRejection; - use shepherd_sdk_test::MockHost; - - const SEPOLIA: u64 = 11_155_111; - - /// `validTo` a given number of seconds from now. The constructor's - /// client-side max-horizon policy reads the wall clock (not the - /// block clock), so test orders must expire relative to it. - fn valid_to_in(seconds: u64) -> u32 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock is after the epoch") - .as_secs(); - u32::try_from(now + seconds).expect("test validTo fits u32") - } - - fn sample_params() -> ConditionalOrderParams { - ConditionalOrderParams { - handler: address!("ffeeddccbbaa00998877665544332211ffeeddcc"), - salt: b256!("0101010101010101010101010101010101010101010101010101010101010101"), - staticInput: hex!("deadbeef").to_vec().into(), - } - } - - fn sample_order() -> GPv2OrderData { - GPv2OrderData { - sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), - buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), - receiver: address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"), - sellAmount: U256::from(1_000_u64), - buyAmount: U256::from(2_000_u64), - validTo: 1_700_000_000, - appData: B256::repeat_byte(0xaa), - feeAmount: U256::ZERO, - kind: B256::repeat_byte(0xbb), - partiallyFillable: false, - sellTokenBalance: B256::repeat_byte(0xcc), - buyTokenBalance: B256::repeat_byte(0xdd), - } - } - - fn submittable_order() -> GPv2OrderData { - GPv2OrderData { - sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), - buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), - receiver: Address::ZERO, - sellAmount: U256::from(1_000_000_u64), - buyAmount: U256::from(999_u64), - validTo: valid_to_in(3_600), - appData: cowprotocol::EMPTY_APP_DATA_HASH, - feeAmount: U256::ZERO, - kind: OrderKind::SELL, - partiallyFillable: false, - sellTokenBalance: SellTokenSource::ERC20, - buyTokenBalance: BuyTokenDestination::ERC20, - } - } - - // ---- existing pure tests ---- - - #[test] - fn decodes_well_formed_log() { - let owner = address!("00112233445566778899aabbccddeeff00112233"); - let params = sample_params(); - let log = make_log(owner, ¶ms); - - let (decoded_owner, decoded_params) = - decode_conditional_order_created(&log).expect("decode succeeds"); - assert_eq!(decoded_owner, owner); - assert_eq!(decoded_params, params); - } - - #[test] - fn rejects_wrong_topic() { - let topics = vec![ - b256!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").to_vec(), - ]; - let log: Log = nexum_sdk::events::ChainLogParts { - address: COMPOSABLE_COW.as_slice(), - topics: &topics, - ..Default::default() - } - .into(); - assert!(decode_conditional_order_created(&log).is_none()); - } - - #[test] - fn rejects_empty_topics() { - let log: Log = nexum_sdk::events::ChainLogParts { - address: COMPOSABLE_COW.as_slice(), - ..Default::default() - } - .into(); - assert!(decode_conditional_order_created(&log).is_none()); - } - - #[test] - fn decode_return_round_trip() { - let order = sample_order(); - let sig: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); - let wire = (order.clone(), sig.clone()).abi_encode_params(); - - match decode_return(&wire).expect("decode succeeds") { - PollOutcome::Ready { - order: o, - signature: s, - } => { - assert_eq!(o.sellToken, order.sellToken); - assert_eq!(o.buyAmount, order.buyAmount); - assert_eq!(s, sig); - } - other => panic!("expected Ready, got {other:?}"), - } - } - - /// The signed `appData` digest goes into the body verbatim as the - /// hash-only shape - no document lookup, no digest re-derivation. - #[test] - fn build_order_creation_submits_app_data_hash_verbatim() { - let owner = address!("00112233445566778899aabbccddeeff00112233"); - let sig: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); - let mut order = submittable_order(); - order.appData = B256::repeat_byte(0xee); - let creation = build_order_creation(&order, sig.clone(), owner).expect("build succeeds"); - assert_eq!(creation.from, owner); - assert_eq!(creation.signing_scheme, cowprotocol::SigningScheme::Eip1271); - assert_eq!(creation.signature.to_bytes(), sig.to_vec()); - assert_eq!( - creation.app_data, - OrderCreationAppData::Hash { - hash: order.appData - } - ); - } - - #[test] - fn build_order_creation_rejects_zero_from() { - let err = - build_order_creation(&submittable_order(), Bytes::new(), Address::ZERO).unwrap_err(); - assert!(matches!(err, BuildError::Cowprotocol(_))); - } - - #[test] - fn watch_key_round_trips_via_parse() { - let owner = address!("00112233445566778899aabbccddeeff00112233"); - let hash = b256!("0202020202020202020202020202020202020202020202020202020202020202"); - let key = watch_key(&owner, &hash); - let (o, h) = parse_watch_key(&key).expect("parse"); - assert_eq!(o.parse::
().unwrap(), owner); - assert_eq!(h.parse::().unwrap(), hash); - } - - #[test] - fn outcome_try_next_block_is_no_op() { - assert_eq!( - outcome_to_update(&PollOutcome::TryNextBlock), - WatchUpdate::NoOp - ); - } - - #[test] - fn outcome_try_on_block_sets_next_block_gate() { - assert_eq!( - outcome_to_update(&PollOutcome::TryOnBlock(12_345)), - WatchUpdate::SetNextBlock(12_345), - ); - } - - #[test] - fn outcome_try_at_epoch_sets_next_epoch_gate() { - assert_eq!( - outcome_to_update(&PollOutcome::TryAtEpoch(1_700_000_000)), - WatchUpdate::SetNextEpoch(1_700_000_000), - ); - } - - #[test] - fn outcome_dont_try_again_drops_watch() { - assert_eq!( - outcome_to_update(&PollOutcome::DontTryAgain), - WatchUpdate::DropWatch - ); - } - - #[test] - fn outcome_ready_is_handled_by_submit_path_not_lifecycle() { - let order = Box::new(submittable_order()); - let outcome = PollOutcome::Ready { - order, - signature: Bytes::new(), - }; - assert_eq!(outcome_to_update(&outcome), WatchUpdate::NoOp); - } - - // ---- MockHost dispatch tests ---- - - /// Build the alloy log the indexer expects from a well-formed - /// `ConditionalOrderCreated`, assembled through the same WIT-edge - /// path the bind macro uses at runtime. - fn make_log(owner: Address, params: &ConditionalOrderParams) -> Log { - let mut owner_topic = vec![0u8; 12]; - owner_topic.extend_from_slice(owner.as_slice()); - let topics = vec![ - ConditionalOrderCreated::SIGNATURE_HASH.to_vec(), - owner_topic, - ]; - let data = params.abi_encode(); - nexum_sdk::events::ChainLogParts { - address: COMPOSABLE_COW.as_slice(), - topics: &topics, - data: &data, - ..Default::default() - } - .into() - } - - /// Build the `params_json` `poll_one` passes to `host.request`. - fn programmed_eth_call_params(owner: Address, params: &ConditionalOrderParams) -> String { - let call = abi::getTradeableOrderWithSignatureCall { - owner, - params: abi::Params { - handler: params.handler, - salt: params.salt, - staticInput: params.staticInput.clone(), - }, - offchainInput: Bytes::new(), - proof: Vec::new(), - }; - eth_call_params(&COMPOSABLE_COW, &call.abi_encode()) - } - - /// JSON-encode a hex blob as the raw `result` field a JSON-RPC - /// response carries (a quoted hex string). - fn quoted_hex(bytes: &[u8]) -> String { - let hex = alloy_primitives::hex::encode_prefixed(bytes); - serde_json::to_string(&hex).unwrap() - } - - /// Pre-seed a `watch:` row identical to what the indexer would - /// write. - fn seed_watch(host: &MockHost, owner: Address, params: &ConditionalOrderParams) -> String { - let encoded = params.abi_encode(); - let key = watch_key(&owner, &keccak256(&encoded)); - host.store.set(&key, &encoded).unwrap(); - key - } - - fn sample_block(number: u64) -> BlockInfo { - BlockInfo { - chain_id: SEPOLIA, - number, - timestamp: 1_700_000_000_000, - } - } - - #[test] - fn index_records_new_watch_on_conditional_order_created() { - let host = MockHost::new(); - let owner = address!("00112233445566778899aabbccddeeff00112233"); - let params = sample_params(); - let log = make_log(owner, ¶ms); - - on_chain_logs(&host, &[log]).unwrap(); - - let expected_key = watch_key(&owner, &keccak256(params.abi_encode())); - assert_eq!(host.store.len(), 1); - assert!(host.store.snapshot().contains_key(&expected_key)); - } - - #[test] - fn index_overwrites_in_place_on_redelivered_log() { - // Re-indexing the same `(owner, params)` - // pair must be a no-op on top of the existing watch - re-org - // replays and overlapping subscription windows are normal. - let host = MockHost::new(); - let owner = address!("00112233445566778899aabbccddeeff00112233"); - let params = sample_params(); - - on_chain_logs(&host, &[make_log(owner, ¶ms)]).unwrap(); - // Re-deliver the same log. - on_chain_logs(&host, &[make_log(owner, ¶ms)]).unwrap(); - - assert_eq!(host.store.len(), 1, "redelivery must not duplicate watches"); - } - - #[test] - fn poll_skips_when_next_block_gate_is_in_future() { - let host = MockHost::new(); - let owner = address!("00112233445566778899aabbccddeeff00112233"); - let params = sample_params(); - let key = seed_watch(&host, owner, ¶ms); - let (_, hash_hex) = parse_watch_key(&key).unwrap(); - let owner_hex = format!("{owner:#x}"); - // Gate the watch at block 500; poll at block 100. - host.store - .set( - &format!("next_block:{owner_hex}:{hash_hex}"), - &500u64.to_le_bytes(), - ) - .unwrap(); - - on_block(&host, sample_block(100)).unwrap(); - - assert_eq!( - host.chain.call_count(), - 0, - "gated watch must not issue eth_call" - ); - assert_eq!(host.cow_api.call_count(), 0); - } - - #[test] - fn poll_ready_submits_order_and_persists_submitted_uid() { - let host = MockHost::new(); - let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); - let params = sample_params(); - seed_watch(&host, owner, ¶ms); - - let ready_order = submittable_order(); - let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); - let wire = (ready_order.clone(), signature.clone()).abi_encode_params(); - host.chain.respond_to( - "eth_call", - programmed_eth_call_params(owner, ¶ms), - Ok(quoted_hex(&wire)), - ); - host.cow_api.respond(Ok("0xfeedface".to_string())); - - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); - result.unwrap(); - - let expected_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); - assert_eq!(host.chain.call_count(), 1); - assert_eq!(host.cow_api.call_count(), 1); - assert!( - host.store - .snapshot() - .contains_key(&format!("submitted:{expected_uid}")), - "expected submitted:{{client_uid}} marker" - ); - assert!( - !host.store.snapshot().contains_key("submitted:0xfeedface"), - "marker must key on the client UID, not the divergent server UID" - ); - // The MockHost orderbook stub returns `0xfeedface` instead of - // the canonical UID; the strategy logs a Warn about the - // divergence (real orderbooks would not diverge). - let ev = logs - .expect_one(|e| e.level == Level::WARN && e.message.contains("twap UID divergence")); - assert!(ev.message.contains(&format!("client={expected_uid}"))); - assert!(ev.message.contains("server=0xfeedface")); - } - - /// Regression guard: when `getTradeableOrderWithSignature` - /// returns the same Ready tuple in consecutive poll-ticks (the - /// on-chain conditional order does not know shepherd already - /// POSTed it), the second tick must NOT call `submit_order` - /// again. Without the guard the orderbook responds with - /// `DuplicatedOrder` and a Warn fires for what is in fact - /// correct, finished work. The guard is the `submitted:{uid}` - /// short-circuit at the top of `submit_ready`. - #[test] - fn poll_ready_skips_submit_when_submitted_uid_already_in_store() { - let host = MockHost::new(); - let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); - let params = sample_params(); - seed_watch(&host, owner, ¶ms); - - let ready_order = submittable_order(); - let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); - let wire = (ready_order.clone(), signature.clone()).abi_encode_params(); - host.chain.respond_to( - "eth_call", - programmed_eth_call_params(owner, ¶ms), - Ok(quoted_hex(&wire)), - ); - - // Seed the marker that a previous successful poll-tick would - // have written. The poll path must read this and skip; the - // orderbook submit must not be attempted. - let already_submitted_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); - host.store - .set(&format!("submitted:{already_submitted_uid}"), b"") - .expect("seed submitted marker"); - - on_block(&host, sample_block(1_000)).unwrap(); - - assert_eq!( - host.chain.call_count(), - 1, - "poll still consults the chain to see Ready", - ); - assert_eq!( - host.cow_api.call_count(), - 0, - "submit_order must NOT be called when submitted:{{uid}} already exists", - ); - assert_eq!( - host.cow_api.request_calls().len(), - 0, - "the REST passthrough must NOT be touched - the guard short-circuits early", - ); - } - - /// A Ready order with a non-empty `appData` digest submits the - /// digest verbatim as the hash-only wire shape: `appData` carries - /// the `0x`-hex digest, `appDataHash` is absent, and no orderbook - /// GET runs first - watch-tower parity. The absence of - /// `appDataHash` is load-bearing: with both fields present the - /// orderbook reads the body as the full-document shape and rejects - /// it for a digest mismatch. - #[test] - fn poll_ready_submits_non_empty_app_data_hash_only() { - use alloy_primitives::keccak256; - let host = MockHost::new(); - let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); - let params = sample_params(); - seed_watch(&host, owner, ¶ms); - - let app_data_hash = keccak256(b"registered elsewhere; this client never sees the doc"); - let mut ready_order = submittable_order(); - ready_order.appData = app_data_hash; - - let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); - let wire = (ready_order.clone(), signature.clone()).abi_encode_params(); - host.chain.respond_to( - "eth_call", - programmed_eth_call_params(owner, ¶ms), - Ok(quoted_hex(&wire)), - ); - host.cow_api.respond(Ok("0xfeedface".to_string())); - - on_block(&host, sample_block(1_000)).unwrap(); - - assert_eq!( - host.chain.call_count(), - 1, - "exactly one eth_call to poll Ready" - ); - assert_eq!(host.cow_api.call_count(), 1, "exactly one orderbook submit"); - assert!( - host.cow_api.request_calls().is_empty(), - "no appData GET before submit - the digest goes out verbatim", - ); - let body = host.cow_api.last_body_as_json().expect("body is JSON"); - assert_eq!( - body["appData"], - format!("0x{}", alloy_primitives::hex::encode(app_data_hash)), - ); - assert!( - body.get("appDataHash").is_none(), - "hash-only body must omit appDataHash, got: {body}" - ); - let expected_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); - assert!( - host.store - .snapshot() - .contains_key(&format!("submitted:{expected_uid}")), - "submitted:{{client_uid}} marker must be written after a successful submit" - ); - } - - #[test] - fn submit_transient_error_leaves_state_unchanged_for_next_block() { - let host = MockHost::new(); - let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); - let params = sample_params(); - let watch_key_str = seed_watch(&host, owner, ¶ms); - - let ready_order = submittable_order(); - let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); - let wire = (ready_order, signature).abi_encode_params(); - host.chain.respond_to( - "eth_call", - programmed_eth_call_params(owner, ¶ms), - Ok(quoted_hex(&wire)), - ); - - // InsufficientFee classifies as TryNextBlock per the - // retriable-error classifier. - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InsufficientFee".into(), - description: "fee too low".into(), - data: None, - }))); - - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); - result.unwrap(); - - // Watch still present, no gate written, no submitted marker. - assert!(host.store.snapshot().contains_key(&watch_key_str)); - let (owner_hex, hash_hex) = parse_watch_key(&watch_key_str).unwrap(); - assert!( - !host - .store - .snapshot() - .contains_key(&format!("next_epoch:{owner_hex}:{hash_hex}")), - ); - assert!( - !host - .store - .snapshot() - .keys() - .any(|k| k.starts_with("submitted:")), - ); - logs.expect_one(|e| { - e.level == Level::WARN && e.message.contains("submit retry-next-block") - }); - } - - #[test] - fn submit_permanent_error_drops_watch() { - let host = MockHost::new(); - let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); - let params = sample_params(); - let watch_key_str = seed_watch(&host, owner, ¶ms); - - let ready_order = submittable_order(); - let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); - let wire = (ready_order, signature).abi_encode_params(); - host.chain.respond_to( - "eth_call", - programmed_eth_call_params(owner, ¶ms), - Ok(quoted_hex(&wire)), - ); - - // InvalidSignature classifies as Drop. - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InvalidSignature".into(), - description: "bad sig".into(), - data: None, - }))); - - on_block(&host, sample_block(1_000)).unwrap(); - - let store = host.store.snapshot(); - assert!( - !store.contains_key(&watch_key_str), - "permanent error must drop the watch" - ); - let (owner_hex, hash_hex) = parse_watch_key(&watch_key_str).unwrap(); - assert!(!store.contains_key(&format!("next_block:{owner_hex}:{hash_hex}"))); - assert!(!store.contains_key(&format!("next_epoch:{owner_hex}:{hash_hex}"))); - assert!(!store.keys().any(|k| k.starts_with("submitted:"))); - } - - #[test] - fn poll_dont_try_again_drops_watch_and_gates() { - // When `decode_revert` produces `DontTryAgain`, the lifecycle - // layer must delete the watch and any stale gates. Simulate the - // wire shape the chain backend forwards: a `ChainError::Rpc` - // carrying the already-decoded `OrderNotValid` revert bytes. - use alloy_sol_types::SolError; - use nexum_sdk::host::RpcError; - use shepherd_sdk::cow::IConditionalOrder; - - let host = MockHost::new(); - let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); - let params = sample_params(); - let watch_key_str = seed_watch(&host, owner, ¶ms); - let (owner_hex, hash_hex) = parse_watch_key(&watch_key_str).unwrap(); - host.store - .set( - &format!("next_block:{owner_hex}:{hash_hex}"), - &0u64.to_le_bytes(), - ) - .unwrap(); - - let revert = IConditionalOrder::OrderNotValid { - reason: "dead".into(), - } - .abi_encode(); - host.chain.respond_to( - "eth_call", - programmed_eth_call_params(owner, ¶ms), - Err(ChainError::Rpc(RpcError { - code: -32000, - message: "execution reverted".into(), - data: Some(revert.into()), - })), - ); - - on_block(&host, sample_block(1_000)).unwrap(); - - assert!(!host.store.snapshot().contains_key(&watch_key_str)); - assert!( - !host - .store - .snapshot() - .contains_key(&format!("next_block:{owner_hex}:{hash_hex}")), - ); - assert_eq!( - host.cow_api.call_count(), - 0, - "revert-to-drop path never submits" - ); - } - - /// Guard: the topic-0 hardcoded in `module.toml` matches the - /// keccak256 of the canonical `ConditionalOrderCreated` signature. - /// A typo or ABI drift would silently miss every registration event. - #[test] - fn topic0_matches_conditional_order_created_canonical_signature() { - assert_eq!( - ConditionalOrderCreated::SIGNATURE_HASH, - b256!("2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361"), - "module.toml event_signature must equal keccak256 of the canonical ABI signature", - ); - } - - /// Stronger guard than the constant check above: read the shipped - /// `module.toml` and assert its pinned `event_signature` actually - /// equals `ConditionalOrderCreated::SIGNATURE_HASH`. (Ported from #164.) - #[test] - fn manifest_topic0_matches_conditional_order_created_signature_hash() { - let manifest = include_str!("../module.toml"); - let expected = format!("0x{:x}", ConditionalOrderCreated::SIGNATURE_HASH); - assert!( - manifest.contains(&expected), - "module.toml event_signature must equal ConditionalOrderCreated::SIGNATURE_HASH ({expected})", - ); - } -} diff --git a/crates/nexum-cli/Cargo.toml b/nexum/crates/nexum-cli/Cargo.toml similarity index 53% rename from crates/nexum-cli/Cargo.toml rename to nexum/crates/nexum-cli/Cargo.toml index cc727db9..05bab22d 100644 --- a/crates/nexum-cli/Cargo.toml +++ b/nexum/crates/nexum-cli/Cargo.toml @@ -13,13 +13,8 @@ name = "nexum" path = "src/main.rs" [dependencies] +nexum-launch = { path = "../nexum-launch" } nexum-runtime = { path = "../nexum-runtime" } -# Composition root wires the cow-api host extension into the reference -# lattice; the runtime itself carries no cow dependency. -shepherd-cow-host = { path = "../shepherd-cow-host" } anyhow.workspace = true -clap.workspace = true tokio.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true diff --git a/nexum/crates/nexum-cli/src/main.rs b/nexum/crates/nexum-cli/src/main.rs new file mode 100644 index 00000000..c7be67c3 --- /dev/null +++ b/nexum/crates/nexum-cli/src/main.rs @@ -0,0 +1,11 @@ +//! The bare `nexum` engine binary: the core lattice with no extension +//! payload, composed over the generic launcher. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +use nexum_runtime::preset::CoreRuntime; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + nexum_launch::run("nexum", CoreRuntime).await +} diff --git a/nexum/crates/nexum-launch/Cargo.toml b/nexum/crates/nexum-launch/Cargo.toml new file mode 100644 index 00000000..aff18adc --- /dev/null +++ b/nexum/crates/nexum-launch/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "nexum-launch" +version = "0.2.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +nexum-runtime = { path = "../nexum-runtime" } + +anyhow.workspace = true +clap.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/nexum/crates/nexum-launch/src/cli.rs b/nexum/crates/nexum-launch/src/cli.rs new file mode 100644 index 00000000..c2de94b0 --- /dev/null +++ b/nexum/crates/nexum-launch/src/cli.rs @@ -0,0 +1,83 @@ +//! Shared CLI surface for engine binaries, derived via clap. + +use std::path::PathBuf; + +use clap::{CommandFactory, FromArgMatches, Parser}; + +/// Parsed CLI surface. +/// +/// ` [ []] [--engine-config ] [--pretty-logs]` +/// +/// Positional `` synthesises a one-module engine config. +/// Production deployments pass `--engine-config` and declare modules in +/// TOML. +/// +/// `--pretty-logs` selects the human-readable tracing formatter; without +/// it the engine emits JSON log lines per the structured-logging contract. +#[derive(Parser, Debug, Default)] +#[command( + about = "Run one or more Wasm Component modules under the engine supervisor", + long_about = None, + version, +)] +pub struct Cli { + /// Optional positional path to a Wasm Component file. Synthesises + /// a one-module engine config when no `--engine-config` is given. + pub wasm: Option, + + /// Optional positional path to the module's `module.toml` manifest. + /// Only consulted alongside the positional `wasm` shortcut. + pub manifest: Option, + + /// Optional explicit path to the engine-wide `engine.toml` config. + /// When omitted, the engine resolves the default search path + /// documented in `engine_config::load_or_default`. + #[arg(long = "engine-config")] + pub engine_config: Option, + + /// Use the human-readable tracing formatter instead of the + /// default JSON formatter (structured-logging contract). + #[arg(long = "pretty-logs")] + pub pretty_logs: bool, + + /// Override `[engine] log_backfill_concurrency`, the chain-log + /// poller's per-block `eth_getLogs` concurrency during backfill. + #[arg(long = "log-backfill-concurrency")] + pub log_backfill_concurrency: Option, +} + +impl Cli { + /// Parse the process arguments under the binary's `name`, exiting on + /// `--help`/`--version` or a usage error. + #[must_use] + pub fn parse_as(name: &'static str) -> Self { + let matches = Self::command().name(name).get_matches(); + Self::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The flags land on the parsed surface under a caller-supplied name. + #[test] + fn flags_parse_under_a_supplied_name() { + let matches = Cli::command() + .name("nexum") + .try_get_matches_from([ + "nexum", + "--engine-config", + "engine.toml", + "--pretty-logs", + "--log-backfill-concurrency", + "8", + ]) + .expect("valid arguments parse"); + let cli = Cli::from_arg_matches(&matches).expect("matches destructure"); + assert_eq!(cli.engine_config, Some(PathBuf::from("engine.toml"))); + assert!(cli.pretty_logs); + assert_eq!(cli.log_backfill_concurrency, Some(8)); + assert!(cli.wasm.is_none()); + } +} diff --git a/nexum/crates/nexum-launch/src/lib.rs b/nexum/crates/nexum-launch/src/lib.rs new file mode 100644 index 00000000..0e472374 --- /dev/null +++ b/nexum/crates/nexum-launch/src/lib.rs @@ -0,0 +1,66 @@ +//! Generic engine launcher: parse the shared CLI, load the engine config, +//! initialise tracing, and drive a [`Runtime`] preset until shutdown. +//! +//! A binary is one line: `nexum_launch::run("nexum", CoreRuntime)`. The +//! preset supplies the lattice, backends, extension list, and add-ons; +//! this crate knows nothing beyond the runtime seam. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +mod cli; + +pub use cli::Cli; + +use nexum_runtime::builder::RuntimeBuilder; +use nexum_runtime::engine_config::{self, EngineConfig}; +use nexum_runtime::preset::Runtime; +use tracing::info; +use tracing_subscriber::EnvFilter; + +/// Parse the process arguments as `name`, then [`launch`] the preset. +pub async fn run(name: &'static str, preset: R) -> anyhow::Result<()> { + launch(name, preset, Cli::parse_as(name)).await +} + +/// Load the config, initialise tracing, and run the preset until shutdown. +pub async fn launch(name: &str, preset: R, cli: Cli) -> anyhow::Result<()> { + let mut engine_cfg = engine_config::load_or_default(cli.engine_config.as_deref())?; + if let Some(n) = cli.log_backfill_concurrency { + engine_cfg.engine.log_backfill_concurrency = n; + } + + init_tracing(cli.pretty_logs, &engine_cfg); + + info!("{name} starting"); + + RuntimeBuilder::new(&engine_cfg) + .with_runtime(preset) + .with_module_source(cli.wasm, cli.manifest) + .launch() + .await? + .wait() + .await +} + +/// Install the global tracing subscriber: JSON by default, the +/// human-readable formatter behind `--pretty-logs`. The same +/// [`EnvFilter`] (`RUST_LOG`, else the config level) applies to both. +fn init_tracing(pretty: bool, engine_cfg: &EngineConfig) { + let env_filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(&engine_cfg.engine.log_level)) + .unwrap_or_else(|_| EnvFilter::new("info")); + if pretty { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_target(true) + .init(); + } else { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_target(true) + .json() + .flatten_event(true) + .with_current_span(false) + .init(); + } +} diff --git a/nexum/crates/nexum-module-macros/Cargo.toml b/nexum/crates/nexum-module-macros/Cargo.toml new file mode 100644 index 00000000..96c35dc9 --- /dev/null +++ b/nexum/crates/nexum-module-macros/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "nexum-module-macros" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Proc-macro glue for nexum runtime modules: #[module] emits the per-cdylib wit-bindgen, host adapter, event dispatch, and export." + +[lib] +proc-macro = true + +[lints] +workspace = true + +[dependencies] +nexum-world = { path = "../nexum-world", features = ["macros"] } +proc-macro2.workspace = true +quote.workspace = true +syn = { workspace = true, features = ["full"] } diff --git a/nexum/crates/nexum-module-macros/src/lib.rs b/nexum/crates/nexum-module-macros/src/lib.rs new file mode 100644 index 00000000..d021d6bd --- /dev/null +++ b/nexum/crates/nexum-module-macros/src/lib.rs @@ -0,0 +1,253 @@ +//! Proc-macro glue for nexum runtime modules. +//! +//! [`module`] turns an `impl` block of named handlers into a complete +//! per-cdylib module. Reach it through `nexum_sdk::module`, not this +//! crate directly; the venue-side macros live in `videre-macros`. + +use proc_macro::TokenStream; +use quote::quote; +use syn::{ImplItem, ItemImpl}; + +/// The handler names recognised on a `#[module]` impl. An `on_`-prefixed +/// method outside this set is a compile error; an absent handler +/// dispatches as a no-op. +const HANDLERS: [&str; 6] = [ + "init", + "on_block", + "on_chain_logs", + "on_tick", + "on_message", + "on_custom", +]; + +/// Generate the per-cdylib glue for a nexum module. +/// +/// Apply to an `impl` block whose associated functions are the event +/// handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, +/// `on_message`, `on_custom`); each takes its event's wit-bindgen +/// payload and returns `Result<(), Fault>`, and `init` takes the config +/// table. Undefined handlers dispatch as no-ops. Emits +/// `wit_bindgen::generate!`, the host adapter, the `Guest` impl, and +/// `export!` around the untouched impl. +/// +/// The world is per module: the macro reads the crate's `module.toml` +/// and synthesizes a world importing exactly the +/// `[capabilities].required` and `optional` declarations, so the +/// load-time capability check passes by construction. An undeclared +/// capability's bindings do not exist. Requirements: the manifest sits +/// at the crate root with a `[capabilities]` section; the crate depends +/// on `wit-bindgen` directly; and the crate root must not shadow the +/// std prelude names `Result`, `Vec`, or `Ok` (the generated `Guest` +/// trait refers to them unqualified). +#[proc_macro_attribute] +pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { + if !attr.is_empty() { + return syn::Error::new( + proc_macro2::Span::call_site(), + "#[nexum_sdk::module] takes no arguments", + ) + .to_compile_error() + .into(); + } + + let input = syn::parse_macro_input!(item as ItemImpl); + + let self_ty = &input.self_ty; + if !nexum_world::is_plain_type(self_ty) { + return syn::Error::new_spanned( + self_ty, + "#[nexum_sdk::module] must be applied to an inherent impl of a named type", + ) + .to_compile_error() + .into(); + } + if let Some((_, trait_path, _)) = &input.trait_ { + return syn::Error::new_spanned( + trait_path, + "#[nexum_sdk::module] must be applied to an inherent impl, not a trait impl", + ) + .to_compile_error() + .into(); + } + if !input.generics.params.is_empty() { + return syn::Error::new_spanned( + &input.generics, + "#[nexum_sdk::module] must be applied to a non-generic impl", + ) + .to_compile_error() + .into(); + } + + // A typo'd handler (`on_blocks`, `on_chainlogs`, ...) would otherwise + // compile as an ordinary helper while its event silently no-ops, so + // reserve the `on_` prefix for the recognised handler set. + for item in &input.items { + if let ImplItem::Fn(f) = item { + let name = f.sig.ident.to_string(); + if name.starts_with("on_") && !HANDLERS.contains(&name.as_str()) { + return syn::Error::new_spanned( + &f.sig.ident, + format!( + "`{name}` is not a recognised #[nexum_sdk::module] handler; expected one \ + of {HANDLERS:?} (rename helpers so they do not start with `on_`)" + ), + ) + .to_compile_error() + .into(); + } + } + } + + let present: Vec<&str> = input + .items + .iter() + .filter_map(|item| match item { + ImplItem::Fn(f) => { + let name = f.sig.ident.to_string(); + HANDLERS.into_iter().find(|h| *h == name) + } + _ => None, + }) + .collect(); + if present.is_empty() { + return syn::Error::new_spanned( + self_ty, + "#[nexum_sdk::module] found no recognised handlers on this impl; define at least one \ + of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_custom`", + ) + .to_compile_error() + .into(); + } + let has = |name: &str| present.contains(&name); + + let (anchors, module_world) = match derive_module_world() { + Ok(parts) => parts, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let wit_paths = match nexum_world::manifest_wit_packages(&module_world.packages) { + Ok(paths) => paths, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let inline_world = &module_world.wit; + let adapter_caps: Vec = module_world + .adapters + .iter() + .map(|cap| syn::Ident::new(cap, proc_macro2::Span::call_site())) + .collect(); + + // `init` is a required export; when the handler is absent the config + // is bound but unused, so drop it to keep the module warning-clean. + let init_impl = if has("init") { + quote! { + fn init( + config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + <#self_ty>::init(config) + } + } + } else { + quote! { + fn init( + _config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + ::core::result::Result::Ok(()) + } + } + }; + + let arm = |handler: &str, variant| -> proc_macro2::TokenStream { + let variant = syn::Ident::new(variant, proc_macro2::Span::call_site()); + if has(handler) { + let call = syn::Ident::new(handler, proc_macro2::Span::call_site()); + quote! { nexum::host::types::Event::#variant(payload) => <#self_ty>::#call(payload), } + } else { + quote! { nexum::host::types::Event::#variant(_) => ::core::result::Result::Ok(()), } + } + }; + let block_arm = arm("on_block", "Block"); + let logs_arm = arm("on_chain_logs", "ChainLogs"); + let tick_arm = arm("on_tick", "Tick"); + let message_arm = arm("on_message", "Message"); + let custom_arm = arm("on_custom", "Custom"); + + quote! { + // Anchor a rebuild on the manifest and the extension registry: + // the emitted world is derived from them, so an edit to either + // must recompile the module. + #(const _: &[u8] = ::core::include_bytes!(#anchors);)* + + wit_bindgen::generate!({ + inline: #inline_world, + path: [#(#wit_paths),*], + world: "nexum:module-world/module", + generate_all, + }); + + ::nexum_sdk::bind_host_via_wit_bindgen!(caps: [#(#adapter_caps),*]); + + #input + + #[doc(hidden)] + struct __NexumModuleExport; + + impl Guest for __NexumModuleExport { + #init_impl + + fn on_event(event: nexum::host::types::Event) -> ::core::result::Result<(), Fault> { + match event { + #block_arm + #logs_arm + #tick_arm + #message_arm + #custom_arm + } + } + } + + export!(__NexumModuleExport); + } + .into() +} + +/// Synthesize the per-module world from the crate's `module.toml` +/// `[capabilities]` plus the nearest ancestor `extensions.toml`. +/// Returns the rebuild anchor paths alongside the world. +fn derive_module_world() -> Result<(Vec, nexum_world::ModuleWorld), String> { + let crate_dir = nexum_world::manifest_dir()?; + let manifest_path = crate_dir.join("module.toml"); + let text = std::fs::read_to_string(&manifest_path).map_err(|e| { + format!( + "could not read {} ({e}); #[nexum_sdk::module] derives the component's WIT world \ + from the manifest's [capabilities] section, so the manifest must sit next to \ + Cargo.toml", + manifest_path.display() + ) + })?; + let declared = nexum_world::manifest_capabilities(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))?; + let manifest_path = manifest_path.to_string_lossy().into_owned(); + + let mut anchors = vec![manifest_path.clone()]; + let extensions = match nexum_world::find_extensions_manifest(&crate_dir) { + None => Vec::new(), + Some(registry) => { + let text = std::fs::read_to_string(®istry) + .map_err(|e| format!("could not read {}: {e}", registry.display()))?; + let rows = nexum_world::manifest_extensions(&text) + .map_err(|e| format!("{}: {e}", registry.display()))?; + anchors.push(registry.to_string_lossy().into_owned()); + rows + } + }; + let module_world = nexum_world::synthesize(&declared, &extensions) + .map_err(|e| format!("{manifest_path}: {e}"))?; + Ok((anchors, module_world)) +} diff --git a/crates/nexum-runtime/Cargo.toml b/nexum/crates/nexum-runtime/Cargo.toml similarity index 93% rename from crates/nexum-runtime/Cargo.toml rename to nexum/crates/nexum-runtime/Cargo.toml index a4ddab3e..c867cbd3 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/nexum/crates/nexum-runtime/Cargo.toml @@ -22,6 +22,7 @@ wasmtime-wasi-http.workspace = true # Async + error plumbing. anyhow.workspace = true thiserror.workspace = true +async-trait.workspace = true # `strum::IntoStaticStr` on error enums gives metric labels (`error_kind`) # free via a snake_case `&'static str` for every variant. Used at # `tracing::warn!(error_kind = .into(), ...)` sites and @@ -35,6 +36,9 @@ tokio.workspace = true # Task lifecycle and graceful shutdown; the sole crate that raw-spawns # tokio tasks. Every engine task routes through its executor. nexum-tasks = { path = "../nexum-tasks" } +# Single-source capability and fault-label vocabularies; the registry's +# core interface set is emitted from its table. +nexum-world = { path = "../nexum-world" } # Manifest parsing. serde.workspace = true @@ -66,7 +70,7 @@ bytes.workspace = true # from a `WsConnect`/`Http` transport so the host's `request` / # `request-batch` impls can hand a raw `(method, params)` pair to # alloy's JSON-RPC layer without reimplementing the codec. -alloy-provider.workspace = true +alloy-provider = { workspace = true, features = ["ws", "ipc", "pubsub", "reqwest"] } alloy-rpc-client.workspace = true alloy-rpc-types-eth.workspace = true alloy-transport.workspace = true diff --git a/crates/nexum-runtime/examples/embed.rs b/nexum/crates/nexum-runtime/examples/embed.rs similarity index 72% rename from crates/nexum-runtime/examples/embed.rs rename to nexum/crates/nexum-runtime/examples/embed.rs index c166e006..75c26dff 100644 --- a/crates/nexum-runtime/examples/embed.rs +++ b/nexum/crates/nexum-runtime/examples/embed.rs @@ -1,20 +1,10 @@ //! Embed the runtime without the CLI: point the builder at a loaded config //! and a [`Runtime`] preset, then launch and run until shutdown. //! -//! [`CoreRuntime`] is the domain-free preset: it bundles the reference core -//! backends (chain provider pool, local redb store, empty extension slot) and -//! the Prometheus add-on. A domain capability such as cow-api is added by -//! writing a preset that names its extension builder in the `Ext` slot and -//! its linker hook via `with_extensions`, or by dropping to the explicit -//! `with_components` builder path. The returned [`RuntimeHandle`] carries the -//! in-process log read side; clone it to keep reading after `wait` consumes -//! the handle. -//! //! Build the example module first (`just build-module`), then run //! `cargo run -p nexum-runtime --example embed` from the repo root. //! //! [`Runtime`]: nexum_runtime::preset::Runtime -//! [`RuntimeHandle`]: nexum_runtime::builder::RuntimeHandle use nexum_runtime::builder::RuntimeBuilder; use nexum_runtime::engine_config::{EngineConfig, ModuleEntry}; diff --git a/crates/nexum-runtime/src/addons.rs b/nexum/crates/nexum-runtime/src/addons.rs similarity index 62% rename from crates/nexum-runtime/src/addons.rs rename to nexum/crates/nexum-runtime/src/addons.rs index 114d2357..165ae019 100644 --- a/crates/nexum-runtime/src/addons.rs +++ b/nexum/crates/nexum-runtime/src/addons.rs @@ -1,30 +1,19 @@ -//! Cross-cutting runtime add-ons: process-wide facilities that attach to -//! the launch path without the core knowing their concrete type. -//! -//! An add-on installs a facility from the resolved config (a metrics -//! recorder today) and hands back a handle the launcher keeps alive for the -//! run. The composition root chooses the set, so an embedder omits or -//! replaces any of them instead of inheriting a fixed install. -//! -//! A future control-surface add-on (an admin or RPC socket) slots in beside -//! [`PrometheusAddOn`]: implement [`RuntimeAddOn`], read its own section -//! from [`AddOnsContext`], and add it to the launcher's list at the -//! composition root. +//! Cross-cutting runtime add-ons: process-wide facilities that attach to the +//! launch path without the core knowing their concrete type. An add-on +//! installs a facility from the resolved config and returns a handle the +//! launcher keeps alive for the run. use tracing::info; use crate::engine_config::MetricsSection; -/// Inputs an add-on reads at install time. Grows as add-ons are added: a -/// future control-surface add-on carries its own resolved section here. +/// Inputs an add-on reads at install time. pub struct AddOnsContext<'a> { /// Resolved `[engine.metrics]` config. pub metrics: &'a MetricsSection, } -/// A live add-on installation, retained by the launcher for the length of -/// the run. Names the add-on for diagnostics; an add-on that needs RAII -/// teardown grows a resource slot here when one arrives. +/// A live add-on installation, retained by the launcher for the run. pub struct AddOnHandle { /// The add-on's name, for diagnostics. pub name: &'static str, @@ -37,23 +26,18 @@ impl AddOnHandle { } } -/// A process-wide facility attached to the launch path. `install` reads the -/// resolved config from `ctx` and returns a handle the launcher retains. +/// A process-wide facility attached to the launch path. pub trait RuntimeAddOn { /// Install the facility, returning its live handle. fn install(&self, ctx: &AddOnsContext<'_>) -> anyhow::Result; } -/// An owned, ordered add-on set gathered behind one value. A preset or -/// composition root returns this so a heterogeneous set travels together; -/// the launcher borrows each element to install it. +/// An owned, ordered add-on set. pub type AddOns = Vec>; -/// The Prometheus exporter add-on. With `[engine.metrics].enabled = true` -/// it binds an HTTP listener serving `/metrics`; otherwise it installs the -/// recorder alone so `metrics::counter!` call sites stay live but no port -/// opens. The same binary thus runs in CI without binding a port and in -/// production with observability by flipping one config flag. +/// The Prometheus exporter add-on. With `[engine.metrics].enabled = true` it +/// binds an HTTP listener serving `/metrics`; otherwise it installs the +/// recorder alone so `metrics::counter!` call sites stay live but no port opens. pub struct PrometheusAddOn; impl RuntimeAddOn for PrometheusAddOn { @@ -86,8 +70,7 @@ mod tests { use super::*; use crate::engine_config::MetricsSection; - /// An enabled exporter with an unparseable bind address surfaces the - /// wrapped error at install, before any recorder is touched. + /// An enabled exporter with an unparseable bind address fails at install. #[test] fn prometheus_add_on_rejects_an_invalid_bind_addr() { let metrics = MetricsSection { diff --git a/nexum/crates/nexum-runtime/src/bindings.rs b/nexum/crates/nexum-runtime/src/bindings.rs new file mode 100644 index 00000000..e79803af --- /dev/null +++ b/nexum/crates/nexum-runtime/src/bindings.rs @@ -0,0 +1,17 @@ +//! WIT bindings generated by `wasmtime::component::bindgen!`. +//! +//! Binds the `nexum:host/event-module` world (the six core primitives). +//! Outbound HTTP is wasi:http, linked separately; clocks are ambient +//! wasi:clocks. `nexum:host` is a leaf package: the host `event` variant +//! carries a status transition as opaque bytes. An extension remaps onto these +//! shared interfaces with `with`, so the `Host` impls and `fault` type its +//! components see are the ones the core host constructs. `PartialEq` is derived +//! so extension services can compare event payloads. + +wasmtime::component::bindgen!({ + path: ["../../../wit/nexum-host"], + world: "nexum:host/event-module", + imports: { default: async }, + exports: { default: async }, + additional_derives: [PartialEq], +}); diff --git a/crates/nexum-runtime/src/bootstrap.rs b/nexum/crates/nexum-runtime/src/bootstrap.rs similarity index 50% rename from crates/nexum-runtime/src/bootstrap.rs rename to nexum/crates/nexum-runtime/src/bootstrap.rs index 532e5582..88d70785 100644 --- a/crates/nexum-runtime/src/bootstrap.rs +++ b/nexum/crates/nexum-runtime/src/bootstrap.rs @@ -1,15 +1,13 @@ -//! Generic launch entry point: assemble the [`AssembledRuntime`] from -//! pre-built backends and run it until shutdown. +//! Generic launch entry point: assemble an [`AssembledRuntime`] from pre-built +//! backends and run it until shutdown. //! -//! Parameterised over the [`RuntimeTypes`] lattice. The composition root -//! builds the concrete [`Components`] and the extension list (including any -//! domain extension such as cow-api) and hands them here; this thin wrapper -//! forwards to the [`builder`](crate::builder) launcher and blocks until the -//! event loop returns. A launcher that wants the +//! Forwards to the [`builder`](crate::builder) launcher and blocks until the +//! event loop returns. A launcher wanting the //! [`RuntimeHandle`](crate::builder::RuntimeHandle) back drives //! [`LaunchRuntime`] directly. use std::path::Path; +use std::sync::Arc; use crate::addons::RuntimeAddOn; use crate::builder::{AssembledRuntime, LaunchContext, LaunchRuntime}; @@ -21,20 +19,15 @@ use crate::host::extension::Extension; /// Launch the runtime from a loaded config and run until shutdown. /// -/// `components` carries the shared backends threaded into every module -/// store; `extensions` carries the linker hooks and capability namespaces -/// assembled at the composition root. Both must agree: a module importing -/// an extension interface boots only if that extension is present in both. -/// -/// `add_ons` carries the cross-cutting facilities (the Prometheus exporter -/// today) installed before the engine boots; the composition root picks the -/// set so an embedder omits or replaces any of them. +/// `components` and `extensions` must agree: a module importing an extension +/// interface boots only if that extension is present in both. `add_ons` are the +/// cross-cutting facilities installed before the engine boots. pub async fn run( engine_cfg: &EngineConfig, wasm: Option<&Path>, manifest: Option<&Path>, components: &Components, - extensions: &[Extension], + extensions: &[Arc>], add_ons: &[&dyn RuntimeAddOn], ) -> anyhow::Result<()> { let runtime = AssembledRuntime { diff --git a/crates/nexum-runtime/src/builder.rs b/nexum/crates/nexum-runtime/src/builder.rs similarity index 58% rename from crates/nexum-runtime/src/builder.rs rename to nexum/crates/nexum-runtime/src/builder.rs index be027497..195cefdc 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/nexum/crates/nexum-runtime/src/builder.rs @@ -1,43 +1,36 @@ //! Type-state runtime builder and the imperative launcher it drives. //! -//! [`RuntimeBuilder`] accumulates the assembly (config, the [`RuntimeTypes`] -//! lattice, extensions, the component builders, add-ons) through a type-state -//! chain; [`ReadyBuilder::launch`] opens the backends and hands off to -//! [`LaunchRuntime::launch`]. The launcher runs one imperative sequence - -//! install add-ons, build the engine and linker, boot the supervisor, open -//! subscriptions through the [`TaskManager`]'s executor, spawn the event -//! loop - and returns a [`RuntimeHandle`] owning the manager and the -//! running tasks. -//! -//! The reference binary reaches this through its `run_from_config` one-liner; -//! an embedder holding pre-built backends constructs an [`AssembledRuntime`] -//! and calls [`LaunchRuntime::launch`] directly. For the common case, -//! [`RuntimeBuilder::runtime`] binds a [`Runtime`] preset that bundles the -//! lattice, component builders, and add-ons in one call. +//! [`RuntimeBuilder`] accumulates the assembly (config, lattice, extensions, +//! component builders, add-ons) through a type-state chain; +//! [`ReadyBuilder::launch`] opens the backends and hands off to +//! [`LaunchRuntime::launch`], which installs add-ons, builds the engine and +//! linker, boots the supervisor, opens subscriptions, spawns the event loop, +//! and returns a [`RuntimeHandle`]. [`RuntimeBuilder::runtime`] binds a +//! [`Runtime`] preset for the common case. use std::future::{Future, IntoFuture}; use std::marker::PhantomData; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::Duration; use nexum_tasks::{DrainOutcome, TaskExit, TaskHandle, TaskManager, TaskSet}; use tracing::{error, info, warn}; use wasmtime::Engine; -use crate::addons::{AddOnHandle, AddOnsContext, RuntimeAddOn}; +use crate::addons::{AddOnHandle, AddOns, AddOnsContext, RuntimeAddOn}; use crate::engine_config::EngineConfig; use crate::host::component::{ BuilderContext, ComponentBuilder, Components, ComponentsBuilder, RuntimeTypes, }; -use crate::host::extension::Extension; +use crate::host::extension::{EventSources, Extension}; use crate::host::logs::LogPipeline; use crate::preset::Runtime; use crate::runtime::event_loop; pub use crate::supervisor::WasiClockOverride; use crate::supervisor::{self, Supervisor}; -/// Ambient inputs the imperative launcher reads: the task manager every -/// runtime task spawns through, and the loaded config. +/// Ambient inputs the launcher reads. pub struct LaunchContext<'a> { /// Owns task spawning and graceful shutdown for the run. pub tasks: TaskManager, @@ -45,12 +38,10 @@ pub struct LaunchContext<'a> { pub config: &'a EngineConfig, } -/// Upper bound on how long the top level blocks for the event loop's final -/// durable flush after shutdown is signalled before forcing exit. +/// Upper bound on the final durable-flush drain before shutdown forces exit. const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(10); -/// A running runtime: the event-loop task, the task manager, and add-on -/// handles. [`shutdown`](Self::shutdown) or dropping fires shutdown; +/// A running runtime. [`shutdown`](Self::shutdown) or dropping fires shutdown; /// [`wait`](Self::wait) blocks on the bounded drain. pub struct RuntimeHandle { event_loop: TaskHandle, @@ -73,9 +64,8 @@ impl RuntimeHandle { } /// Block until the loop stops (on its own, on shutdown, or on a critical - /// task ending), bounding the final durable flush; a drain past the - /// timeout forces exit. A `None` join reason means the task panicked or - /// was aborted. + /// task ending), bounding the final flush; a drain past the timeout forces + /// exit. pub async fn wait(self) -> anyhow::Result<()> { let RuntimeHandle { event_loop, @@ -127,8 +117,9 @@ fn finish_wait(joined: Option) -> anyhow::Result<()> { pub struct AssembledRuntime<'a, T: RuntimeTypes> { /// Shared backends threaded into every module store. pub components: Components, - /// Linker hooks and capability namespaces. - pub extensions: Vec>, + /// Extensions: namespaces, capabilities, linker hooks, services, and + /// provider kinds. + pub extensions: Vec>>, /// Cross-cutting facilities installed before the engine boots. pub add_ons: &'a [&'a dyn RuntimeAddOn], /// Single-module source override; `None` runs `[[modules]]`. @@ -198,7 +189,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { clocks, ) .await? - } else if !engine_cfg.modules.is_empty() { + } else if !engine_cfg.modules.is_empty() || !engine_cfg.adapters.is_empty() { Supervisor::boot( &engine, &linker, @@ -210,8 +201,8 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { .await? } else { anyhow::bail!( - "no modules to run - set a module source or declare [[modules]] entries \ - in engine.toml" + "no modules to run - set a module source or declare [[modules]] or \ + [[adapters]] entries in engine.toml" ); }; @@ -219,6 +210,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { let block_chains = supervisor.block_chains(); info!( modules = supervisor.module_count(), + adapters = supervisor.adapter_count(), alive, chains = block_chains.len(), "supervisor ready" @@ -263,10 +255,29 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { // the components. let logs = components.logs.clone(); let chain_log_subs = supervisor.chain_log_subscriptions(); + // Extension event sources open only for subscription kinds some + // loaded module declares; each extension gates further on its own + // service state and returns no stream when it has nothing to + // observe. + let subscribed = supervisor.extension_subscription_kinds(); + let mut reconnect_tasks = TaskSet::new(); + let mut extension_streams = Vec::new(); + { + let mut sources = EventSources::new( + engine_cfg, + supervisor.services(), + &subscribed, + &executor, + &mut reconnect_tasks, + ); + for ext in &extensions { + extension_streams.extend(ext.events(&mut sources)?); + } + } // No subscriptions: nothing to drive. Return a handle whose event loop // is already complete so `wait` resolves immediately. - if block_chains.is_empty() && chain_log_subs.is_empty() { + if block_chains.is_empty() && chain_log_subs.is_empty() && extension_streams.is_empty() { if supervisor.dead_modules_hold_subscriptions() { anyhow::bail!( "every declared [[subscription]] belongs to an init-failed module - \ @@ -287,7 +298,6 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { // Open per-chain block subscriptions + per-module chain-log // subscriptions through the executor, then drive them in the event // loop until shutdown. - let mut reconnect_tasks = TaskSet::new(); let block_streams = event_loop::open_block_streams( &components.chain, &block_chains, @@ -300,7 +310,6 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { &executor, &mut reconnect_tasks, ); - // The event-loop task holds the graceful guard until `run` returns // (after its final dispatch and cursor commit); shutdown ends the // loop between dispatches rather than cancelling it, so the drain @@ -311,6 +320,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { &mut supervisor, block_streams, chain_log_streams, + extension_streams, reconnect_tasks, graceful.into_future(), ) @@ -351,39 +361,42 @@ impl<'a> RuntimeBuilder<'a> { } } - /// Bind a [`Runtime`] preset that bundles the lattice, the component - /// builders, and the add-on set. Sugar over the type-state chain: an - /// embedder writes `RuntimeBuilder::new(cfg).runtime::().launch()`. - pub fn runtime(self) -> PresetBuilder<'a, R> { + /// Bind a `Default` [`Runtime`] preset by marker; sugar over + /// [`with_runtime`](Self::with_runtime). + pub fn runtime(self) -> PresetBuilder<'a, R> { + self.with_runtime(R::default()) + } + + /// Bind a [`Runtime`] preset by value, carrying pre-built backends into + /// the launch. + pub fn with_runtime(self, preset: R) -> PresetBuilder<'a, R> { PresetBuilder { config: self.config, + preset, extensions: Vec::new(), wasm: None, manifest: None, clocks: None, - _r: PhantomData, } } } -/// Terminal stage of the preset shortcut: the [`Runtime`] preset supplies the -/// lattice, the component builders, and the add-on set, leaving only the -/// optional extension hooks and module source before [`launch`](Self::launch). +/// Terminal stage of the preset shortcut, leaving only optional extension +/// hooks and the module source before [`launch`](Self::launch). pub struct PresetBuilder<'a, R: Runtime> { config: &'a EngineConfig, - extensions: Vec>, + preset: R, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, - _r: PhantomData R>, } impl<'a, R: Runtime> PresetBuilder<'a, R> { - /// Add extension linker hooks and capability namespaces on top of the - /// preset. The default preset carries none. + /// Append extensions on top of the preset's own. pub fn with_extensions( mut self, - extensions: impl IntoIterator>, + extensions: impl IntoIterator>>, ) -> Self { self.extensions.extend(extensions); self @@ -397,17 +410,41 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { self } - /// Override the per-store WASI wall and monotonic clocks. Every module - /// store, including the ones rebuilt on restart, reads these instead of - /// the ambient host clocks. Omitting it is behaviour-neutral. + /// Override the per-store WASI wall and monotonic clocks, including stores + /// rebuilt on restart. Omitting it leaves the ambient host clocks. pub fn with_wasi_clocks(mut self, clocks: WasiClockOverride) -> Self { self.clocks = Some(clocks); self } - /// Open the preset's backends and launch. Builds the [`Components`] bundle - /// from the preset's component builders, installs the preset's add-ons, - /// then drives [`LaunchRuntime::launch`] with a fresh [`TaskManager`]. + /// Override the preset's component builders before launch; `map` swaps one + /// seam while the preset's extensions and add-ons carry through. Mirror of + /// [`TypedBuilder::with_components`]. + pub fn with_components( + self, + map: impl FnOnce( + ComponentsBuilder, + ) -> ComponentsBuilder, + ) -> PresetComponentsBuilder<'a, R::Types, C, S, E, L> { + // Gather the preset's extensions and add-ons before `components` + // consumes the preset by value. + let mut extensions = self.preset.extensions(self.config); + extensions.extend(self.extensions); + let add_ons = self.preset.add_ons(); + let components = map(self.preset.components()); + PresetComponentsBuilder { + config: self.config, + extensions, + add_ons, + wasm: self.wasm, + manifest: self.manifest, + clocks: self.clocks, + components, + } + } + + /// Open the preset's backends and launch, driving [`LaunchRuntime::launch`] + /// with a fresh [`TaskManager`]. pub async fn launch(self) -> anyhow::Result { let tasks = TaskManager::new(); let executor = tasks.executor(); @@ -417,12 +454,69 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { data_dir: &data_dir, executor: &executor, }; - let components = R::components().build::(&build_ctx).await?; - + let mut extensions = self.preset.extensions(self.config); + extensions.extend(self.extensions); // `add_ons` owns the boxed add-ons; `add_on_refs` borrows into it and is // consumed by the launch call, so both must stay in scope for that call. - let add_ons = R::add_ons(); + let add_ons = self.preset.add_ons(); let add_on_refs: Vec<&dyn RuntimeAddOn> = add_ons.iter().map(|a| &**a).collect(); + let components = self + .preset + .components() + .build::(&build_ctx) + .await?; + + let runtime = AssembledRuntime { + components, + extensions, + add_ons: &add_on_refs, + wasm: self.wasm.as_deref(), + manifest: self.manifest.as_deref(), + clocks: self.clocks, + }; + let ctx = LaunchContext { + tasks, + config: self.config, + }; + runtime.launch(ctx).await + } +} + +/// A preset with its component builders overridden through +/// [`PresetBuilder::with_components`], leaving only [`launch`](Self::launch). +pub struct PresetComponentsBuilder<'a, T: RuntimeTypes, C, S, E, L> { + config: &'a EngineConfig, + extensions: Vec>>, + add_ons: AddOns, + wasm: Option, + manifest: Option, + clocks: Option, + components: ComponentsBuilder, +} + +impl PresetComponentsBuilder<'_, T, C, S, E, L> +where + T: RuntimeTypes, + C: ComponentBuilder, + S: ComponentBuilder, + E: ComponentBuilder, + L: ComponentBuilder, +{ + /// Open the overridden backends and launch, otherwise as + /// [`PresetBuilder::launch`]. + pub async fn launch(self) -> anyhow::Result { + let tasks = TaskManager::new(); + let executor = tasks.executor(); + let data_dir = self.config.engine.state_dir.clone(); + let build_ctx = BuilderContext { + config: self.config, + data_dir: &data_dir, + executor: &executor, + }; + // `add_ons` owns the boxed add-ons; `add_on_refs` borrows into it and is + // consumed by the launch call, so both must stay in scope for that call. + let add_on_refs: Vec<&dyn RuntimeAddOn> = self.add_ons.iter().map(|a| &**a).collect(); + let components = self.components.build::(&build_ctx).await?; let runtime = AssembledRuntime { components, @@ -444,7 +538,7 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { /// may be added before the component builders. pub struct TypedBuilder<'a, T: RuntimeTypes> { config: &'a EngineConfig, - extensions: Vec>, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, @@ -452,8 +546,11 @@ pub struct TypedBuilder<'a, T: RuntimeTypes> { } impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { - /// Add the extension linker hooks and capability namespaces. - pub fn with_extensions(mut self, extensions: impl IntoIterator>) -> Self { + /// Add the extensions. + pub fn with_extensions( + mut self, + extensions: impl IntoIterator>>, + ) -> Self { self.extensions.extend(extensions); self } @@ -466,19 +563,18 @@ impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { self } - /// Override the per-store WASI wall and monotonic clocks. Every module - /// store, including the ones rebuilt on restart, reads these instead of - /// the ambient host clocks. Omitting it is behaviour-neutral. + /// Override the per-store WASI wall and monotonic clocks, including stores + /// rebuilt on restart. Omitting it leaves the ambient host clocks. pub fn with_wasi_clocks(mut self, clocks: WasiClockOverride) -> Self { self.clocks = Some(clocks); self } /// Bind the component builders that open the backends at launch. - pub fn with_components( + pub fn with_components( self, - components: ComponentsBuilder, - ) -> ComponentsStage<'a, T, C, S, E> { + components: ComponentsBuilder, + ) -> ComponentsStage<'a, T, C, S, E, L> { ComponentsStage { config: self.config, extensions: self.extensions, @@ -492,19 +588,22 @@ impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { } /// The component builders are bound; the add-on set remains. -pub struct ComponentsStage<'a, T: RuntimeTypes, C, S, E> { +pub struct ComponentsStage<'a, T: RuntimeTypes, C, S, E, L> { config: &'a EngineConfig, - extensions: Vec>, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, - components: ComponentsBuilder, + components: ComponentsBuilder, _t: PhantomData T>, } -impl<'a, T: RuntimeTypes, C, S, E> ComponentsStage<'a, T, C, S, E> { +impl<'a, T: RuntimeTypes, C, S, E, L> ComponentsStage<'a, T, C, S, E, L> { /// Bind the cross-cutting add-on set installed before the engine boots. - pub fn with_add_ons(self, add_ons: &'a [&'a dyn RuntimeAddOn]) -> ReadyBuilder<'a, T, C, S, E> { + pub fn with_add_ons( + self, + add_ons: &'a [&'a dyn RuntimeAddOn], + ) -> ReadyBuilder<'a, T, C, S, E, L> { ReadyBuilder { config: self.config, extensions: self.extensions, @@ -519,26 +618,26 @@ impl<'a, T: RuntimeTypes, C, S, E> ComponentsStage<'a, T, C, S, E> { /// The assembly is complete; [`launch`](Self::launch) opens the backends and /// runs. -pub struct ReadyBuilder<'a, T: RuntimeTypes, C, S, E> { +pub struct ReadyBuilder<'a, T: RuntimeTypes, C, S, E, L> { config: &'a EngineConfig, - extensions: Vec>, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, - components: ComponentsBuilder, + components: ComponentsBuilder, add_ons: &'a [&'a dyn RuntimeAddOn], } -impl ReadyBuilder<'_, T, C, S, E> +impl ReadyBuilder<'_, T, C, S, E, L> where T: RuntimeTypes, C: ComponentBuilder, S: ComponentBuilder, E: ComponentBuilder, + L: ComponentBuilder, { - /// Open the backends and launch. Builds the [`Components`] bundle from the - /// bound builders, then drives [`LaunchRuntime::launch`] with a fresh - /// [`TaskManager`]. + /// Open the backends and launch, driving [`LaunchRuntime::launch`] with a + /// fresh [`TaskManager`]. pub async fn launch(self) -> anyhow::Result { let tasks = TaskManager::new(); let executor = tasks.executor(); @@ -572,15 +671,28 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; + use crate::addons::AddOns; use crate::engine_config::EngineConfig; - use crate::host::component::{LocalStoreBuilder, ProviderPoolBuilder}; - use crate::preset::CoreRuntime; - - /// The preset shortcut is exercised at runtime, not just compiled: the - /// component builders open the backends, the add-ons install, and the - /// launch reaches the supervisor boot, which bails because the default - /// config declares no modules. Locks the sugar path so a builder-chain - /// refactor cannot silently break it. + use crate::host::component::{LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder}; + use crate::host::state::HostState; + use crate::manifest::NamespaceCaps; + use crate::preset::{CoreRuntime, Runtime as RuntimePreset}; + use crate::test_utils::Prebuilt; + use wasmtime::component::Linker; + + /// Workspace root: the topmost ancestor with a `Cargo.toml`. + fn workspace_root() -> std::path::PathBuf { + let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + manifest + .ancestors() + .filter(|d| d.join("Cargo.toml").is_file()) + .last() + .unwrap_or(manifest) + .to_path_buf() + } + + /// The preset shortcut reaches the supervisor boot, which bails on the + /// default config's empty module set. #[tokio::test] async fn preset_launch_runs_the_build_path_then_bails_without_modules() { let dir = tempfile::tempdir().expect("tempdir"); @@ -598,17 +710,251 @@ mod tests { assert!(err.to_string().contains("no modules to run"), "{err}"); } - /// when every configured module fails `init`, launch must - /// abort with an operator-facing error instead of idling behind an - /// empty event loop. + /// Counts linker hook runs. + struct CountingExt { + namespace: &'static str, + prefix: &'static str, + linked: Arc, + } + + impl Extension for CountingExt { + fn namespace(&self) -> &'static str { + self.namespace + } + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: self.prefix, + ifaces: &[], + } + } + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + self.linked.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + /// A value-bound preset carrying its own extension. + struct ExtPreset { + linked: Arc, + } + + impl crate::sealed::SealedRuntime for ExtPreset {} + + impl RuntimePreset for ExtPreset { + type Types = CoreRuntime; + type ChainBuilder = ProviderPoolBuilder; + type StoreBuilder = LocalStoreBuilder; + type ExtBuilder = (); + type LogsBuilder = LogPipelineBuilder; + + fn components(self) -> ComponentsBuilder { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + } + + fn add_ons(&self) -> AddOns { + Vec::new() + } + + fn extensions(&self, _config: &EngineConfig) -> Vec>> { + vec![Arc::new(CountingExt { + namespace: "alpha", + prefix: "alpha:ext/", + linked: self.linked.clone(), + })] + } + } + + /// Preset extensions and appended extensions each link exactly once. + #[tokio::test] + async fn preset_extensions_and_appended_extensions_both_link() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut config = EngineConfig::default(); + config.engine.state_dir = dir.path().join("state"); + + let preset_linked = Arc::new(AtomicUsize::new(0)); + let appended_linked = Arc::new(AtomicUsize::new(0)); + let appended: Arc> = Arc::new(CountingExt { + namespace: "beta", + prefix: "beta:ext/", + linked: appended_linked.clone(), + }); + + let err = match RuntimeBuilder::new(&config) + .with_runtime(ExtPreset { + linked: preset_linked.clone(), + }) + .with_extensions([appended]) + .launch() + .await + { + Ok(_) => panic!("default config declares no modules; launch must bail"), + Err(err) => err, + }; + assert!(err.to_string().contains("no modules to run"), "{err}"); + assert_eq!(preset_linked.load(Ordering::SeqCst), 1, "preset extension"); + assert_eq!( + appended_linked.load(Ordering::SeqCst), + 1, + "appended extension" + ); + } + + /// A value-bound preset handing back an already-built backend. + struct PrebuiltLogsPreset { + logs: LogPipeline, + } + + impl crate::sealed::SealedRuntime for PrebuiltLogsPreset {} + + impl RuntimePreset for PrebuiltLogsPreset { + type Types = CoreRuntime; + type ChainBuilder = ProviderPoolBuilder; + type StoreBuilder = LocalStoreBuilder; + type ExtBuilder = (); + type LogsBuilder = Prebuilt; + + fn components( + self, + ) -> ComponentsBuilder> + { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + .with_logs(Prebuilt(self.logs)) + } + + fn add_ons(&self) -> AddOns { + Vec::new() + } + } + + /// A preset hands a pre-built pipeline through into the built bundle. + #[tokio::test] + async fn preset_hands_over_a_prebuilt_backend() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = EngineConfig::default(); + let tasks = TaskManager::new(); + let executor = tasks.executor(); + let build_ctx = BuilderContext { + config: &config, + data_dir: dir.path(), + executor: &executor, + }; + + let custom = LogPipeline::in_memory(config.limits.logs()); + let components = PrebuiltLogsPreset { + logs: custom.clone(), + } + .components() + .build::(&build_ctx) + .await + .expect("build from the preset's builders"); + + assert!( + Arc::ptr_eq(&components.logs.router(), &custom.router()), + "bundle carries the preset's pre-built pipeline", + ); + } + + /// A core-lattice preset with no add-ons, avoiding the process-global + /// Prometheus recorder (only one install succeeds per process). + struct NoAddOnCore; + + impl crate::sealed::SealedRuntime for NoAddOnCore {} + + impl RuntimePreset for NoAddOnCore { + type Types = CoreRuntime; + type ChainBuilder = ProviderPoolBuilder; + type StoreBuilder = LocalStoreBuilder; + type ExtBuilder = (); + type LogsBuilder = LogPipelineBuilder; + + fn components(self) -> ComponentsBuilder { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + } + + fn add_ons(&self) -> AddOns { + Vec::new() + } + } + + /// Counts builds. + struct CountingLogsBuilder(Arc); + + impl ComponentBuilder for CountingLogsBuilder { + type Output = LogPipeline; + async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(LogPipeline::in_memory(ctx.config.limits.logs())) + } + } + + /// `with_components` overrides a seam: the substituted logs builder runs + /// once, then the launch bails on the empty module set. + #[tokio::test] + async fn preset_with_components_overrides_a_seam() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut config = EngineConfig::default(); + config.engine.state_dir = dir.path().join("state"); + + let built = Arc::new(AtomicUsize::new(0)); + let seen = built.clone(); + let err = match RuntimeBuilder::new(&config) + .with_runtime(NoAddOnCore) + .with_components(move |c| c.with_logs(CountingLogsBuilder(seen))) + .launch() + .await + { + Ok(_) => panic!("default config declares no modules; launch must bail"), + Err(err) => err, + }; + assert!(err.to_string().contains("no modules to run"), "{err}"); + assert_eq!( + built.load(Ordering::SeqCst), + 1, + "overridden logs builder ran once", + ); + } + + /// Full preset-path launch with an overridden logs seam; skips when the + /// module fixture is not built (`just build-module`). + #[tokio::test] + async fn e2e_preset_with_components_launches_through_overridden_logs() { + let repo_root = workspace_root(); + let wasm = repo_root.join("target/wasm32-wasip2/release/example.wasm"); + if !wasm.exists() { + eprintln!( + "SKIP: {} not found - run `just build-module` to enable E2E tests", + wasm.display() + ); + return; + } + let manifest = repo_root.join("nexum/modules/example/module.toml"); + + let dir = tempfile::tempdir().expect("tempdir"); + let mut config = EngineConfig::default(); + config.engine.state_dir = dir.path().join("state"); + + let custom = LogPipeline::in_memory(config.limits.logs()); + let mut handle = RuntimeBuilder::new(&config) + .with_runtime(NoAddOnCore) + .with_module_source(Some(wasm), Some(manifest)) + .with_components(|c| c.with_logs(Prebuilt(custom.clone()))) + .launch() + .await + .expect("launch through the overridden logs seam"); + + assert!( + Arc::ptr_eq(&handle.logs().router(), &custom.router()), + "run reads the overridden pipeline", + ); + + handle.shutdown(); + handle.wait().await.expect("clean shutdown"); + } + + /// Every module failing `init` aborts launch instead of idling. #[tokio::test] async fn launch_bails_when_all_modules_fail_init() { - let wasm = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("crates dir") - .parent() - .expect("repo root") - .join("target/wasm32-wasip2/release/price_alert.wasm"); + let wasm = workspace_root().join("target/wasm32-wasip2/release/price_alert.wasm"); if !wasm.exists() { eprintln!( "SKIP: {} not found - build with `cargo build -p price-alert --target wasm32-wasip2 --release`", @@ -664,9 +1010,7 @@ every_n_blocks = "1" assert!(err.to_string().contains("failed initialisation"), "{err}"); } - /// The add-on set installs before the supervisor boots: a stub add-on's - /// `install` runs exactly once even though the launch bails on the - /// no-modules boot that follows. + /// Add-ons install before the supervisor boots, exactly once. #[tokio::test] async fn assembled_runtime_installs_add_ons_before_boot() { struct CountingAddOn(Arc); @@ -722,18 +1066,11 @@ every_n_blocks = "1" ); } - /// Full builder-path launch against the pre-built example module: the - /// handle exposes the shared log pipeline and the trigger-to-wait - /// handshake stops the run. Skips when the module fixture is not built - /// (`just build-module`). + /// Full builder-path launch against the example module; skips when the + /// fixture is not built (`just build-module`). #[tokio::test] async fn e2e_builder_launch_exposes_logs_and_stops_on_shutdown() { - let wasm = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("crates dir") - .parent() - .expect("repo root") - .join("target/wasm32-wasip2/release/example.wasm"); + let wasm = workspace_root().join("target/wasm32-wasip2/release/example.wasm"); if !wasm.exists() { eprintln!( "SKIP: {} not found - run `just build-module` to enable E2E tests", @@ -741,11 +1078,7 @@ every_n_blocks = "1" ); return; } - let manifest = wasm - .ancestors() - .nth(3) - .expect("repo root") - .join("modules/example/module.toml"); + let manifest = workspace_root().join("nexum/modules/example/module.toml"); let dir = tempfile::tempdir().expect("tempdir"); let mut config = EngineConfig::default(); @@ -796,8 +1129,8 @@ every_n_blocks = "1" .expect("clean completion resolves Ok"); } - /// Firing the shutdown trigger drives the event-loop task to completion - /// and `wait` returns once the graceful guard releases. + /// Firing the shutdown trigger drives the loop to completion and `wait` + /// returns. #[tokio::test] async fn runtime_handle_shutdown_trigger_drives_wait_to_return() { let tasks = TaskManager::new(); @@ -810,9 +1143,7 @@ every_n_blocks = "1" handle.wait().await.expect("wait returns after the trigger"); } - /// An event-loop task that stops abnormally (here: aborted, the same - /// join outcome a panic produces) surfaces the wrapped error from - /// `wait` instead of masking it as a clean stop. + /// An abnormally-stopped event loop surfaces an error from `wait`. #[tokio::test] async fn runtime_handle_wait_is_err_on_abnormal_stop() { let tasks = TaskManager::new(); @@ -828,8 +1159,7 @@ every_n_blocks = "1" assert!(err.to_string().contains("terminated abnormally"), "{err}"); } - /// dropping the handle without `wait` fires the shutdown signal, - /// so the detached event loop winds down and drains rather than leaking. + /// Dropping the handle without `wait` still drains the event loop. #[tokio::test] async fn dropping_handle_without_wait_drains_the_event_loop() { let tasks = TaskManager::new(); diff --git a/crates/nexum-runtime/src/engine_config.rs b/nexum/crates/nexum-runtime/src/engine_config.rs similarity index 64% rename from crates/nexum-runtime/src/engine_config.rs rename to nexum/crates/nexum-runtime/src/engine_config.rs index 9305f640..956f8f73 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/nexum/crates/nexum-runtime/src/engine_config.rs @@ -1,20 +1,9 @@ -//! Engine-side runtime configuration. +//! Engine-side runtime configuration (`engine.toml`): chain RPC +//! endpoints, local-store location, and per-module resource limits. +//! Distinct from a module's `module.toml` manifest. //! -//! Distinct from `module.toml` (module manifest): this file describes -//! the *engine*'s I/O wiring - chain RPC endpoints and the on-disk -//! location of the `local-store` database. Both are required for the -//! 0.2 reference engine to do anything other than print stubs. -//! -//! Lookup order: -//! -//! 1. `--engine-config ` CLI flag (future), or third positional -//! argument today; -//! 2. `engine.toml` in the current working directory; -//! 3. defaults - no chains configured, `state_dir = ./data`. -//! -//! A missing config is OK for the example module (it only logs); for -//! the chain-backed capabilities it surfaces as a `fault.unsupported` -//! so guests learn early. +//! Load order: `--engine-config` path, else `engine.toml` in the cwd, +//! else defaults (no chains, `state_dir = ./data`). use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -31,16 +20,96 @@ use crate::runtime::dispatch_rate::{ }; use crate::runtime::poison_policy::{POISON_MAX_FAILURES, POISON_WINDOW, PoisonPolicy}; +/// Default per-caller submission budget within [`DEFAULT_QUOTA_WINDOW`]. +pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; +/// Default sliding window the per-caller submission budget is counted over. +pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); +/// Default cap on receipts under status watch at once. +pub const DEFAULT_WATCH_MAX_ENTRIES: usize = 1024; +/// Default base window a healthy provider refreshes within; the give-up +/// deadline is the derived `grace`, not this directly. +pub const DEFAULT_WATCH_EXPIRY: Duration = Duration::from_secs(86_400); +/// Derived grace defaults to this many `expiry` windows. +pub const WATCH_GRACE_MULTIPLIER: u64 = 2; +/// Ceiling on the derived grace window. +pub const WATCH_GRACE_MAX: Duration = Duration::from_secs(86_400); + +/// Give-up window derived from `expiry`: `min(MULTIPLIER * expiry, MAX)`. +const fn derive_grace(expiry: Duration) -> Duration { + let scaled = expiry.as_secs().saturating_mul(WATCH_GRACE_MULTIPLIER); + let capped = if scaled < WATCH_GRACE_MAX.as_secs() { + scaled + } else { + WATCH_GRACE_MAX.as_secs() + }; + Duration::from_secs(capped) +} + +/// Per-caller submission quota toward providers. A submission and a +/// charged decode failure each consume one unit; the window slides. +/// Resolved from `[limits.quota]`. +#[derive(Debug, Clone, Copy)] +pub struct SubmitQuota { + /// Maximum charges a single caller may accrue within `window`. + pub max_charges: u32, + /// Sliding window the charges are counted across. + pub window: Duration, +} + +impl SubmitQuota { + /// Budget paired with the window it is counted over. + pub const fn new(max_charges: u32, window: Duration) -> Self { + Self { + max_charges, + window, + } + } +} + +impl Default for SubmitQuota { + fn default() -> Self { + Self::new(DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW) + } +} + +/// Bounds on a provider status-watch set: `max_entries` caps the +/// per-cadence poll fan-out, `grace` is the give-up deadline, `expiry` +/// the base window it derives from. Resolved from `[limits.watch]`. +#[derive(Debug, Clone, Copy)] +pub struct WatchLimit { + /// Maximum receipts under status watch at once. + pub max_entries: usize, + /// Base window a healthy provider refreshes the deadline within. + pub expiry: Duration, + /// Give-up deadline: how long a watch survives an unreachable provider + /// before unreported eviction. A reachable poll resets it; a resolve + /// failure or errored poll rides out against it. Derived unless set. + pub grace: Duration, +} + +impl WatchLimit { + /// Pair a cap with the base expiry; `grace` derives from `expiry`. + pub const fn new(max_entries: usize, expiry: Duration) -> Self { + Self::with_grace(max_entries, expiry, derive_grace(expiry)) + } + + /// As [`new`](Self::new) but with an explicit `grace`. + pub const fn with_grace(max_entries: usize, expiry: Duration, grace: Duration) -> Self { + Self { + max_entries, + expiry, + grace, + } + } +} + +impl Default for WatchLimit { + fn default() -> Self { + Self::new(DEFAULT_WATCH_MAX_ENTRIES, DEFAULT_WATCH_EXPIRY) + } +} + /// Errors surfaced by [`load_or_default`]. -/// -/// Library-side modules must not propagate `anyhow::Error`; the rust -/// idiomatic rubric reserves `anyhow` for `main.rs` and -/// `supervisor.rs` top-level dispatch. The variants carry the -/// upstream error via `#[from]` so the caller in `main.rs` (which -/// uses `anyhow`) gets a free conversion through `?`. -/// -/// `IntoStaticStr` exposes the snake_case variant name for metric -/// labels and structured-log `error_kind` fields. #[derive(Debug, Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] @@ -61,37 +130,30 @@ pub enum EngineConfigError { pub struct EngineConfig { #[serde(default)] pub engine: EngineSection, - /// Per-module wasmtime resource limits. Applies uniformly to every - /// module; per-module overrides land in 0.3. + /// Per-module wasmtime resource limits, applied uniformly. #[serde(default)] pub limits: ModuleLimits, - /// Per-chain RPC URLs keyed by EIP-155 chain id. Numeric TOML keys - /// (`[chains.11155111]`) stay canonical; named keys - /// (`[chains.sepolia]`) also parse, since the key string is handed - /// to `Chain`'s `FromStr`. `Chain` is not `Ord`, so this is a - /// `HashMap`; call sites that need deterministic output sort by - /// `Chain::id()`. + /// Per-chain RPC config keyed by EIP-155 chain id. Numeric + /// (`[chains.11155111]`) and named (`[chains.sepolia]`) keys both + /// parse via `Chain`'s `FromStr`. #[serde(default)] pub chains: HashMap, - /// Opaque `[extensions.]` tables. The engine never - /// interprets these; each extension parses its own table at the - /// composition root. + /// Opaque `[extensions.]` tables; the engine never interprets + /// these, each extension parses its own at the composition root. #[serde(default)] pub extensions: HashMap, - /// Modules the supervisor should boot. Each entry resolves a - /// `(component.wasm, module.toml)` pair on the local filesystem - /// for 0.2 - content-addressed resolution (Swarm / OCI / - /// `[[content.sources]]`) lands in 0.3 per - /// `docs/03-module-discovery.md`. + /// Modules the supervisor boots; each resolves a + /// `(component.wasm, module.toml)` pair. #[serde(default)] pub modules: Vec, + /// Provider components the supervisor boots alongside modules. Like a + /// module, but the operator, not the author, scopes its transport here. + #[serde(default)] + pub adapters: Vec, } -/// One `[[modules]]` table from `engine.toml`. -/// -/// Both fields are filesystem paths in 0.2. `manifest` defaults to -/// `module.toml` next to `path` if omitted, matching the bundle layout -/// in `docs/02-modules-events-packaging.md`. +/// One `[[modules]]` table. `manifest` defaults to a sibling +/// `module.toml`. #[derive(Debug, Deserialize)] pub struct ModuleEntry { /// Path to the compiled `.wasm` component. @@ -101,22 +163,39 @@ pub struct ModuleEntry { pub manifest: Option, } +/// One `[[adapters]]` table. `path`/`manifest` mirror [`ModuleEntry`]. +/// `http_allow` and `messaging_topics` are the operator's transport +/// grant: an empty `http_allow` denies all outbound HTTP, an empty +/// `messaging_topics` leaves messaging unscoped. +#[derive(Debug, Deserialize)] +pub struct AdapterEntry { + /// Path to the compiled `.wasm` adapter component. + pub path: std::path::PathBuf, + /// Path to the adapter's `module.toml`. Defaults to `/module.toml`. + #[serde(default)] + pub manifest: Option, + /// Outbound HTTP host allowlist: exact hostname or `*.suffix` wildcard. + #[serde(default)] + pub http_allow: Vec, + /// Messaging content topics this adapter may reach. + #[serde(default)] + pub messaging_topics: Vec, +} + #[derive(Debug, Deserialize)] pub struct EngineSection { #[serde(default = "default_state_dir")] pub state_dir: PathBuf, - /// `tracing_subscriber::EnvFilter`-compatible directive. Defaults to - /// `info` when absent; `RUST_LOG` overrides at process start. + /// `EnvFilter` directive; defaults to `info`, `RUST_LOG` overrides at + /// process start. #[serde(default = "default_log_level")] pub log_level: String, - /// Prometheus metrics exporter wiring. Absent table = - /// disabled (the engine still installs the recorder so call sites - /// stay live but no HTTP listener binds). + /// Prometheus exporter wiring. Absent = disabled (the recorder is still + /// installed so call sites stay live, but no HTTP listener binds). #[serde(default)] pub metrics: MetricsSection, - /// Concurrency for the chain-log poller's per-block `eth_getLogs` - /// during backfill; higher catches up faster at more node load. - /// `0` is treated as `1` by alloy. + /// Per-block `eth_getLogs` concurrency during chain-log backfill. `0` is + /// treated as `1`. #[serde(default = "default_log_backfill_concurrency")] pub log_backfill_concurrency: usize, } @@ -136,11 +215,8 @@ fn default_log_backfill_concurrency() -> usize { 16 } -/// `[engine.metrics]` config. When `enabled = true` the engine starts -/// a Prometheus HTTP exporter on `bind_addr` and serves `/metrics`. -/// -/// Default: disabled. Operators opt in explicitly so the M3 / M4 -/// runbook smoke runs do not bind a port unintentionally. +/// `[engine.metrics]`. When `enabled`, serves `/metrics` on `bind_addr` +/// via a Prometheus HTTP exporter. Default disabled. #[derive(Debug, Deserialize)] pub struct MetricsSection { #[serde(default)] @@ -165,14 +241,12 @@ fn default_metrics_bind() -> String { #[derive(Debug, Deserialize)] pub struct ChainConfig { - /// JSON-RPC endpoint. `ws://` and `wss://` engage alloy's pubsub - /// transport (required for `eth_subscribe`); `http://` and `https://` - /// engage the HTTP transport (request/response only). + /// JSON-RPC endpoint. `ws(s)://` engages pubsub (needed for + /// `eth_subscribe`); `http(s)://` is request/response only. pub rpc_url: String, - /// Per-request timeout for `chain::request` JSON-RPC calls, in - /// seconds. Does not apply to `eth_subscribe` streams or the log - /// poller (both long-lived by design). Default: 30 s. `0` is - /// rejected at boot - every call would time out immediately. + /// Per-request timeout for `chain::request` calls, seconds. Excludes + /// `eth_subscribe` streams and the log poller. Default 30. `0` is + /// rejected at boot. #[serde(default = "default_chain_request_timeout_secs")] pub request_timeout_secs: u64, } @@ -181,12 +255,10 @@ fn default_chain_request_timeout_secs() -> u64 { 30 } -/// Default fuel budget per `on_event` invocation (~1 billion WASM -/// instructions). +/// Default fuel budget per `on_event` invocation (~1e9 WASM instructions). const DEFAULT_FUEL_PER_EVENT: u64 = 1_000_000_000; -/// Default per-dispatch wall-clock deadline: the coarse backstop for a -/// dispatch parked in an unmetered host call. +/// Default per-dispatch wall-clock deadline. const DEFAULT_EVENT_DEADLINE: Duration = Duration::from_secs(120); /// Floor for the resolved dispatch deadline. @@ -198,86 +270,45 @@ const DEFAULT_MEMORY_LIMIT: usize = 64 * 1024 * 1024; /// Default per-module local-store byte quota (50 MiB). const DEFAULT_STATE_BYTES: u64 = 50 * 1024 * 1024; -/// Default ceiling on the guest-settable connect timeout. A TCP + TLS -/// connect that has not completed in 10 s is dead; anything longer just -/// parks a host task. +/// Default ceiling on the guest-settable connect timeout. const DEFAULT_HTTP_CONNECT_TIMEOUT_MAX: Duration = Duration::from_secs(10); -/// Default ceiling on the guest-settable first-byte timeout. Generous -/// enough for slow API endpoints without letting one request hold a -/// connection for minutes. +/// Default ceiling on the guest-settable first-byte timeout. const DEFAULT_HTTP_FIRST_BYTE_TIMEOUT_MAX: Duration = Duration::from_secs(30); /// Default ceiling on the guest-settable between-bytes timeout. const DEFAULT_HTTP_BETWEEN_BYTES_TIMEOUT_MAX: Duration = Duration::from_secs(30); -/// Default total deadline on one outgoing exchange, connect through -/// body streaming. Event-driven modules should never hold a request -/// across minutes; the per-phase timeouts above cannot bound a server -/// that trickles bytes forever, this does. +/// Default total deadline on one outgoing exchange, connect through body +/// streaming. const DEFAULT_HTTP_TOTAL_DEADLINE: Duration = Duration::from_secs(60); -/// Default cap on one incoming response body (16 MiB): a quarter of the -/// default module memory, so a single response cannot dominate the -/// guest heap that has to buffer it. +/// Default cap on one incoming response body (16 MiB). const DEFAULT_HTTP_RESPONSE_BODY_MAX: u64 = 16 * 1024 * 1024; +/// Default cap on one chain JSON-RPC response body (1 MiB). +const DEFAULT_CHAIN_RESPONSE_MAX_BYTES: usize = 1024 * 1024; + /// Ceiling for the `[limits.http]` millisecond knobs (24 h). const HTTP_LIMIT_MS_MAX: u64 = 86_400_000; -/// Default per-run log ring budget (256 KiB). Large enough to hold a -/// substantial tail of a run's output for post-mortem, small enough that -/// memory stays bounded at roughly `bytes_per_run * runs_retained * -/// modules`. Each record is charged its message bytes plus a fixed -/// per-record overhead, so a flood of empty lines cannot outgrow the -/// budget. The per-run ceiling is really `max(bytes_per_run, -/// MAX_LINE_BYTES)`: the ring never evicts its sole record, and the stdio -/// writer force-flushes an unterminated line at 1 MiB, so a newline-less -/// flood transiently holds one record up to that size (evicted as soon as -/// a newer record arrives). +/// Default per-run log ring budget (256 KiB). const DEFAULT_LOG_BYTES_PER_RUN: usize = 256 * 1024; -/// Default number of past runs retained per module (16). A crash-looping -/// module restarts repeatedly; keeping the last several runs gives -/// history for diagnosis without unbounded growth. +/// Default past runs retained per module (16). const DEFAULT_LOG_RUNS_RETAINED: usize = 16; -/// Saturate an operator-supplied millisecond knob into [1 ms, 24 h]: -/// zero would fail every request instantly, and huge values overflow -/// timer arithmetic. +/// Default provider status polling cadence (5 s). +const DEFAULT_STATUS_POLL_INTERVAL: Duration = Duration::from_secs(5); + +/// Saturate a millisecond knob into [1 ms, 24 h]. fn clamp_http_ms(ms: u64) -> Duration { Duration::from_millis(ms.clamp(1, HTTP_LIMIT_MS_MAX)) } -/// Per-module wasmtime resource limits. Every field is optional; -/// omitted values resolve to built-in defaults. -/// -/// ```toml -/// [limits] -/// fuel_per_event = 1_000_000_000 -/// event_deadline_secs = 120 -/// memory_bytes = 67_108_864 -/// state_bytes = 52_428_800 -/// -/// [limits.http] -/// connect_timeout_max_ms = 10_000 -/// first_byte_timeout_max_ms = 30_000 -/// between_bytes_timeout_max_ms = 30_000 -/// total_deadline_ms = 60_000 -/// response_body_max_bytes = 16_777_216 -/// -/// [limits.logs] -/// bytes_per_run = 262_144 -/// runs_retained = 16 -/// -/// [limits.poison] -/// max_failures = 5 -/// window_secs = 600 -/// -/// [limits.dispatch] -/// burst = 256 -/// refill_per_sec = 128 -/// ``` +/// Per-module wasmtime resource limits. Every field is optional; omitted +/// values resolve to built-in defaults. Sections are documented on their +/// own types. #[derive(Debug, Default, Deserialize)] pub struct ModuleLimits { /// Fuel budget granted per `on_event` invocation. @@ -286,18 +317,29 @@ pub struct ModuleLimits { pub event_deadline_secs: Option, /// Linear-memory cap in bytes per module store. pub memory_bytes: Option, - /// Local-store on-disk byte quota (prefix + key + value + per-entry - /// overhead) per module. + /// Local-store on-disk byte quota per module. pub state_bytes: Option, /// Outbound wasi:http limits. #[serde(default)] pub http: HttpLimitsSection, + /// Chain JSON-RPC response size limits. + #[serde(default)] + pub chain: ChainLimitsSection, /// Per-run log retention limits. #[serde(default)] pub logs: LogLimitsSection, /// Poison-pill quarantine thresholds. #[serde(default)] pub poison: PoisonLimitsSection, + /// Per-caller provider submission quota. + #[serde(default)] + pub quota: QuotaLimitsSection, + /// Provider status polling cadence. + #[serde(default)] + pub status_poll: StatusPollSection, + /// Status-watch set bounds. + #[serde(default)] + pub watch: WatchLimitsSection, /// Per-module dispatch rate-limit thresholds. #[serde(default)] pub dispatch: DispatchLimitsSection, @@ -314,20 +356,27 @@ impl ModuleLimits { self.memory_bytes.unwrap_or(DEFAULT_MEMORY_LIMIT) } + /// Resolved chain response size cap; a degenerate `0` saturates to 1 byte. + pub fn chain_response_max_bytes(&self) -> usize { + self.chain + .response_body_max_bytes + .map(|b| (b.max(1)) as usize) + .unwrap_or(DEFAULT_CHAIN_RESPONSE_MAX_BYTES) + } + /// Resolved local-store byte quota (override or default). pub fn state_bytes(&self) -> u64 { self.state_bytes.unwrap_or(DEFAULT_STATE_BYTES) } - /// Resolved per-dispatch wall-clock deadline; an override saturates - /// up to a 1 s floor. + /// Resolved per-dispatch deadline; an override saturates up to a 1 s floor. pub fn event_deadline(&self) -> Duration { self.event_deadline_secs .map(|secs| Duration::from_secs(secs).max(MIN_EVENT_DEADLINE)) .unwrap_or(DEFAULT_EVENT_DEADLINE) } - /// Resolved outbound HTTP limits (overrides or defaults). + /// Resolved outbound HTTP limits. pub fn http(&self) -> OutboundHttpLimits { OutboundHttpLimits { connect_timeout_max: self @@ -357,9 +406,7 @@ impl ModuleLimits { } } - /// Resolved log retention limits (overrides or defaults). Degenerate - /// zeroes saturate up to 1 so at least the newest record and run stay - /// retained; resolution never fails. + /// Resolved log retention limits; degenerate zeroes saturate up to 1. pub fn logs(&self) -> LogRetentionLimits { LogRetentionLimits { bytes_per_run: self @@ -375,10 +422,7 @@ impl ModuleLimits { } } - /// Resolved poison-pill thresholds (overrides or production - /// defaults). Degenerate zeroes saturate up to 1: a zero - /// `max_failures` would quarantine on the first trap, and a zero - /// `window` would prune every recorded failure before the check. + /// Resolved poison-pill thresholds; degenerate zeroes saturate up to 1. pub fn poison(&self) -> PoisonPolicy { PoisonPolicy::new( self.poison @@ -406,16 +450,54 @@ impl ModuleLimits { .unwrap_or(DEFAULT_DISPATCH_REFILL_PER_SEC), ) } + + /// Resolved status-poll cadence; a zero interval saturates up to 1 ms. + pub fn status_poll_interval(&self) -> Duration { + self.status_poll + .interval_ms + .map(|ms| Duration::from_millis(ms.max(1))) + .unwrap_or(DEFAULT_STATUS_POLL_INTERVAL) + } + + /// Resolved per-caller submission quota; a zero `max_charges` is + /// saturated up to 1 by the consuming service. + pub fn quota(&self) -> SubmitQuota { + SubmitQuota::new( + self.quota.max_charges.unwrap_or(DEFAULT_QUOTA_MAX_CHARGES), + self.quota + .window_secs + .map(|s| Duration::from_secs(s.max(1))) + .unwrap_or(DEFAULT_QUOTA_WINDOW), + ) + } + + /// Resolved status-watch bounds; zero `max_entries`/`expiry_secs` + /// saturate up to a usable minimum. `grace_secs` overrides the give-up + /// deadline, else it derives from `expiry` via [`WatchLimit::new`]. + pub fn watch(&self) -> WatchLimit { + let max_entries = self + .watch + .max_entries + .map(|n| n.max(1)) + .unwrap_or(DEFAULT_WATCH_MAX_ENTRIES); + let expiry = self + .watch + .expiry_secs + .map(|s| Duration::from_secs(s.max(1))) + .unwrap_or(DEFAULT_WATCH_EXPIRY); + match self.watch.grace_secs { + Some(secs) => { + WatchLimit::with_grace(max_entries, expiry, Duration::from_secs(secs.max(1))) + } + None => WatchLimit::new(max_entries, expiry), + } + } } -/// `[limits.http]` outbound wasi:http limits. Every field is optional; -/// omitted values resolve to built-in defaults, and millisecond values -/// saturate into [1 ms, 24 h]; degenerate values are clamped at resolve time. -/// -/// The three `*_timeout_max_ms` fields are ceilings on the matching -/// guest-settable `request-options` timeouts, not the timeouts -/// themselves: a guest value above the ceiling is clamped down, and an -/// unset guest value inherits the ceiling. +/// `[limits.http]` outbound limits. All optional; millisecond values +/// saturate into [1 ms, 24 h]. The `*_timeout_max_ms` fields are ceilings +/// on the matching guest-settable `request-options` timeouts: a higher +/// guest value is clamped down, an unset one inherits the ceiling. #[derive(Debug, Default, Deserialize)] pub struct HttpLimitsSection { /// Ceiling on the guest-settable connect timeout, in milliseconds. @@ -431,8 +513,15 @@ pub struct HttpLimitsSection { pub response_body_max_bytes: Option, } -/// Resolved outbound HTTP limits the wasi:http gate enforces per -/// request. Built by [`ModuleLimits::http`]. +/// `[limits.chain]` chain JSON-RPC response size limit. Optional; defaults +/// to 1 MiB. +#[derive(Debug, Default, Deserialize)] +pub struct ChainLimitsSection { + /// Cap on one chain JSON-RPC response body, in bytes. + pub response_body_max_bytes: Option, +} + +/// Resolved outbound HTTP limits the wasi:http gate enforces per request. #[derive(Debug, Clone, Copy)] pub struct OutboundHttpLimits { /// Ceiling on the guest-settable connect timeout. @@ -447,14 +536,8 @@ pub struct OutboundHttpLimits { pub response_body_max_bytes: u64, } -/// `[limits.logs]` per-run log retention knobs. Both optional; omitted -/// values resolve to built-in defaults and degenerate zeroes saturate up -/// to 1 at resolve time. -/// -/// Captured-line levels are fixed, not configurable: guest stdout is -/// recorded at info, stderr at warn, and a supervisor-synthesized panic -/// record at error. A guest panic's stderr copy therefore records at -/// warn while its host-interface and supervisor copies carry error. +/// `[limits.logs]` per-run retention knobs. Both optional; degenerate +/// zeroes saturate up to 1. #[derive(Debug, Default, Deserialize)] pub struct LogLimitsSection { /// Byte budget for one run's in-memory ring. @@ -463,14 +546,10 @@ pub struct LogLimitsSection { pub runs_retained: Option, } -/// `[limits.poison]` quarantine thresholds. Both optional; omitted -/// values resolve to the production defaults and degenerate zeroes -/// saturate up to 1 at resolve time via [`ModuleLimits::poison`]. -/// -/// A module that reaches `max_failures` traps within a sliding -/// `window_secs` is quarantined: the check fires at the threshold, not one -/// past it. The supervisor then stops dispatching to the module until an -/// operator-driven engine restart clears the state. +/// `[limits.poison]` quarantine thresholds. Both optional; degenerate +/// zeroes saturate up to 1. A module reaching `max_failures` traps within +/// a sliding `window_secs` is quarantined and no longer dispatched until +/// an operator-driven engine restart. #[derive(Debug, Default, Deserialize)] pub struct PoisonLimitsSection { /// Maximum traps within the window before a module is poisoned. @@ -479,6 +558,42 @@ pub struct PoisonLimitsSection { pub window_secs: Option, } +/// `[limits.quota]` per-caller submission budget. Both optional. A caller +/// (keyed by its namespace) may accrue at most `max_charges` within a +/// sliding `window_secs`; a charged decode failure counts the same. +#[derive(Debug, Default, Deserialize)] +pub struct QuotaLimitsSection { + /// Maximum submissions (plus charged decode failures) per caller in the + /// window. + pub max_charges: Option, + /// Sliding window the charges are counted across, in seconds. + pub window_secs: Option, +} + +/// `[limits.status_poll]` provider status polling cadence: how often the +/// consuming service polls each provider's `status` export for the +/// receipts it watches. Optional; a zero saturates up to 1 ms. +#[derive(Debug, Default, Deserialize)] +pub struct StatusPollSection { + /// Milliseconds between status poll sweeps. + pub interval_ms: Option, +} + +/// `[limits.watch]` status-watch set bounds. All optional; degenerate +/// zeroes saturate up to a usable minimum. The cap bounds the per-cadence +/// poll fan-out; at the cap a new watch is refused and logged, live +/// watches are never dropped. +#[derive(Debug, Default, Deserialize)] +pub struct WatchLimitsSection { + /// Maximum receipts under status watch at once. + pub max_entries: Option, + /// Base window seconds a healthy venue refreshes the deadline within. + pub expiry_secs: Option, + /// Give-up deadline seconds: how long a watch rides out an unreachable + /// venue before eviction. Omitted, it derives from `expiry_secs`. + pub grace_secs: Option, +} + /// `[limits.dispatch]` per-module dispatch rate-limit knobs. Both /// optional; omitted values resolve to the production defaults, and a /// degenerate zero saturates up to 1 via [`ModuleLimits::dispatch_rate`]. @@ -490,12 +605,11 @@ pub struct DispatchLimitsSection { pub refill_per_sec: Option, } -/// Resolved log retention limits the in-memory store enforces. Built by -/// [`ModuleLimits::logs`]. +/// Resolved log retention limits the in-memory store enforces. #[derive(Debug, Clone, Copy)] pub struct LogRetentionLimits { - /// Byte budget for one run's ring; the oldest records evict first, - /// but the newest record is never evicted to nothing. + /// Byte budget for one run's ring; the newest record is never evicted + /// to nothing. pub bytes_per_run: usize, /// Runs retained per module; the oldest run evicts first. pub runs_retained: usize, @@ -544,18 +658,9 @@ pub fn load_or_default(path: Option<&Path>) -> Result Result { let mut out = String::with_capacity(raw.len()); let bytes = raw.as_bytes(); @@ -607,10 +712,7 @@ fn is_valid_env_name(s: &str) -> bool { chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') } -/// `IntoStaticStr` exposes the snake_case variant name for the -/// `tracing::error!` / `metrics::counter!` call sites in `main.rs` -/// when an `engine.toml` substitution fails at boot, matching the -/// pattern used on every other engine-side error enum. +/// Errors from `${VAR}` substitution in `engine.toml`. #[derive(Debug, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] @@ -632,11 +734,9 @@ pub enum EnvVarError { Unclosed { offset: usize }, } -/// Blank the credential-bearing parts of a URL (userinfo, query, fragment, and -/// long API-key path segments) so it is safe to log. Parsing with [`url::Url`] -/// rather than string-splitting is what makes bare query flags (`?token`) and -/// fragments redact; an unparseable url yields a placeholder. Shared by every -/// call site that logs an RPC url. +/// Blank the credential-bearing parts of a URL (userinfo, query, fragment, +/// long API-key path segments) so it is safe to log. Unparseable input +/// yields a placeholder. pub fn redact_url(url: &str) -> String { let Ok(mut parsed) = url::Url::parse(url) else { return "".to_owned(); @@ -753,6 +853,42 @@ response_body_max_bytes = 1_024 assert_eq!(http.between_bytes_timeout_max, Duration::from_secs(30)); } + #[test] + fn chain_limits_default_when_absent() { + assert_eq!( + ModuleLimits::default().chain_response_max_bytes(), + 1024 * 1024, + ); + } + + #[test] + fn chain_limits_parse_with_override() { + let cfg: EngineConfig = toml::from_str( + r#" +[limits.chain] +response_body_max_bytes = 2_048 +"#, + ) + .expect("limits.chain parses"); + assert_eq!(cfg.limits.chain_response_max_bytes(), 2_048); + } + + #[test] + fn chain_limits_saturate_degenerate_zero() { + let cfg: EngineConfig = toml::from_str( + r#" +[limits.chain] +response_body_max_bytes = 0 +"#, + ) + .expect("limits.chain parses"); + assert_eq!( + cfg.limits.chain_response_max_bytes(), + 1, + "zero saturates to 1 so resolution never rejects an empty body", + ); + } + #[test] fn http_limits_saturate_degenerate_millisecond_values() { // Zero would fail every request instantly; u64::MAX would @@ -861,6 +997,47 @@ window_secs = 0 assert_eq!(poison.window, Duration::from_secs(1)); } + #[test] + fn adapters_parse_with_scoped_transport_grants() { + let cfg: EngineConfig = toml::from_str( + r#" +[[adapters]] +path = "providers/acme/acme_provider.wasm" +http_allow = ["api.acme.example", "*.acme.example"] +messaging_topics = ["/nexum/1/acme-orders/proto"] + +[[adapters]] +path = "adapters/bare/bare.wasm" +manifest = "adapters/bare/module.toml" +"#, + ) + .expect("adapters parse"); + assert_eq!(cfg.adapters.len(), 2); + let first = &cfg.adapters[0]; + assert_eq!( + first.path, + PathBuf::from("providers/acme/acme_provider.wasm") + ); + assert!(first.manifest.is_none(), "manifest defaults to sibling"); + assert_eq!(first.http_allow, vec!["api.acme.example", "*.acme.example"]); + assert_eq!(first.messaging_topics, vec!["/nexum/1/acme-orders/proto"]); + let second = &cfg.adapters[1]; + assert_eq!( + second.manifest.as_deref(), + Some(Path::new("adapters/bare/module.toml")) + ); + assert!( + second.http_allow.is_empty() && second.messaging_topics.is_empty(), + "unset scope grants default empty", + ); + } + + #[test] + fn adapters_default_empty_when_absent() { + let cfg = EngineConfig::default(); + assert!(cfg.adapters.is_empty()); + } + #[test] fn dispatch_rate_default_when_absent() { let policy = ModuleLimits::default().dispatch_rate(); @@ -899,6 +1076,58 @@ refill_per_sec = 0 assert_eq!(policy.refill_per_sec, 1); } + #[test] + fn watch_limits_default_when_absent() { + let watch = ModuleLimits::default().watch(); + assert_eq!(watch.max_entries, DEFAULT_WATCH_MAX_ENTRIES); + assert_eq!(watch.expiry, DEFAULT_WATCH_EXPIRY); + } + + #[test] + fn watch_limits_parse_with_overrides() { + let cfg: EngineConfig = toml::from_str( + r#" +[limits.watch] +max_entries = 32 +expiry_secs = 900 +"#, + ) + .expect("limits.watch parses"); + let watch = cfg.limits.watch(); + assert_eq!(watch.max_entries, 32); + assert_eq!(watch.expiry, Duration::from_secs(900)); + // Omitted grace_secs derives from expiry (min(2 * expiry, 24h)). + assert_eq!(watch.grace, Duration::from_secs(1800)); + + // An explicit grace_secs overrides the derivation. + let cfg: EngineConfig = toml::from_str( + r#" +[limits.watch] +expiry_secs = 900 +grace_secs = 120 +"#, + ) + .expect("limits.watch parses"); + assert_eq!(cfg.limits.watch().grace, Duration::from_secs(120)); + } + + #[test] + fn watch_limits_saturate_zero_up_to_one() { + // A zero cap would refuse every watch; a zero expiry would evict + // each watch before its first poll. Both saturate. + let cfg: EngineConfig = toml::from_str( + r#" +[limits.watch] +max_entries = 0 +expiry_secs = 0 +"#, + ) + .expect("limits.watch parses"); + let watch = cfg.limits.watch(); + assert_eq!(watch.max_entries, 1); + assert_eq!(watch.expiry, Duration::from_secs(1)); + } + #[test] fn extensions_tables_parse_opaquely() { let cfg: EngineConfig = toml::from_str( @@ -1007,7 +1236,6 @@ key = "value" assert_eq!(redact_url("not a url"), ""); } - // ----------------- env var substitution ----------------------- // // These tests stash + restore process env vars under unique names // so parallel `cargo test` runs don't trip on each other. diff --git a/nexum/crates/nexum-runtime/src/host/actor.rs b/nexum/crates/nexum-runtime/src/host/actor.rs new file mode 100644 index 00000000..62cb956c --- /dev/null +++ b/nexum/crates/nexum-runtime/src/host/actor.rs @@ -0,0 +1,102 @@ +//! Supervised host-actor primitive: one component instance the host holds and +//! others call. The store is refuelled before each guest call, traps project +//! onto a typed fault, and an [`ActorSlot`] mutex serialises calls so one +//! store never runs two at once. + +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Instant; + +use tokio::sync::Mutex as AsyncMutex; +use wasmtime::Store; + +use super::component::RuntimeTypes; +use super::state::HostState; + +/// One supervised actor behind its serialising mutex; concurrent callers +/// queue. +pub type ActorSlot = Arc>; + +/// Shared liveness of one supervised component; a trap marks it dead and +/// records when, for backoff from the death instant. Clone shares the flag, +/// starts alive. +#[derive(Clone, Debug, Default)] +pub struct Liveness(Arc>>); + +impl Liveness { + /// Whether the component is currently callable. + pub fn is_alive(&self) -> bool { + self.lock().is_none() + } + + /// When the component died, while it is dead. + pub fn dead_since(&self) -> Option { + *self.lock() + } + + /// Mark dead, keeping the first death instant if already dead. + pub fn mark_dead(&self) { + let mut died_at = self.lock(); + if died_at.is_none() { + *died_at = Some(Instant::now()); + } + } + + /// Mark the component alive again after a restart. + pub fn mark_alive(&self) { + *self.lock() = None; + } + + /// The flag, recovered from a poisoned lock. + fn lock(&self) -> MutexGuard<'_, Option> { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +/// A guest call failed outside the component's typed error space. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ActorFault { + /// The pre-call refuel failed; the guest was never entered. + #[error("refuel failed: {0}")] + Refuel(wasmtime::Error), + /// The guest trapped; carries the root cause only. + #[error("trapped: {}", .0.root_cause())] + Trap(wasmtime::Error), +} + +/// A supervised component store: refuelled before each guest call, with traps +/// projected onto [`ActorFault`] and recorded on [`Liveness`]. +pub struct SupervisedStore { + store: Store>, + fuel_per_call: u64, + liveness: Liveness, +} + +impl SupervisedStore { + /// Supervise an instantiated store with a per-call fuel budget, + /// reporting traps on `liveness`. + pub fn new(store: Store>, fuel_per_call: u64, liveness: Liveness) -> Self { + Self { + store, + fuel_per_call, + liveness, + } + } + + /// Refuel, then run one guest call; a trap marks liveness dead until + /// reinstantiated. + pub async fn call( + &mut self, + call: impl AsyncFnOnce(&mut Store>) -> wasmtime::Result, + ) -> Result { + self.store + .set_fuel(self.fuel_per_call) + .map_err(ActorFault::Refuel)?; + call(&mut self.store).await.map_err(|trap| { + self.liveness.mark_dead(); + ActorFault::Trap(trap) + }) + } +} diff --git a/crates/nexum-runtime/src/host/component/builder.rs b/nexum/crates/nexum-runtime/src/host/component/builder.rs similarity index 63% rename from crates/nexum-runtime/src/host/component/builder.rs rename to nexum/crates/nexum-runtime/src/host/component/builder.rs index 705896bf..02222c98 100644 --- a/crates/nexum-runtime/src/host/component/builder.rs +++ b/nexum/crates/nexum-runtime/src/host/component/builder.rs @@ -1,10 +1,6 @@ -//! Per-component builders: one seam for turning the loaded config plus a -//! resolved data directory into a runtime backend. -//! -//! Each core backend is wrapped as a [`ComponentBuilder`], and -//! [`ComponentsBuilder`] assembles the core seams (plus the lattice `Ext` -//! payload) into a [`Components`] bundle. The composition root names the -//! concrete builders once; boot drives them through this trait. +//! Per-component builders. Each core backend is a [`ComponentBuilder`]; +//! [`ComponentsBuilder`] assembles the core seams, the lattice `Ext` payload, +//! and the log pipeline into a [`Components`] bundle. use std::future::Future; use std::path::Path; @@ -16,9 +12,7 @@ use crate::host::local_store_redb::LocalStore; use crate::host::logs::LogPipeline; use crate::host::provider_pool::ProviderPool; -/// Shared inputs every component builder reads: the loaded engine config, -/// the resolved data directory backends open their files under, and the -/// executor blocking opens run on. +/// Shared inputs every component builder reads. pub struct BuilderContext<'a> { /// The loaded engine config. pub config: &'a crate::engine_config::EngineConfig, @@ -28,9 +22,7 @@ pub struct BuilderContext<'a> { pub executor: &'a TaskExecutor, } -/// Builds one runtime backend from the shared [`BuilderContext`]. The -/// `impl Future + Send` form lets a builder connect over the network -/// (the chain provider does) while staying usable from a spawned task. +/// Builds one runtime backend from the shared [`BuilderContext`]. pub trait ComponentBuilder { /// The backend this builder produces. type Output; @@ -81,10 +73,21 @@ impl ComponentBuilder for LocalStoreBuilder { } } -/// Names the component slot whose build failed. The leaf cause stays an -/// `anyhow::Error` because the backends fail for heterogeneous reasons -/// (I/O for the store, network for the chain). +/// Builds the default [`LogPipeline`]: the byte-bounded in-memory backend +/// sized from `[limits.logs]`. +pub struct LogPipelineBuilder; + +impl ComponentBuilder for LogPipelineBuilder { + type Output = LogPipeline; + + async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result { + Ok(LogPipeline::in_memory(ctx.config.limits.logs())) + } +} + +/// Names the component slot whose build failed. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum BuildError { /// The chain backend builder failed. #[error("build the chain backend: {0}")] @@ -95,6 +98,9 @@ pub enum BuildError { /// The extension payload builder failed. #[error("build the extension payload: {0}")] Ext(anyhow::Error), + /// The log pipeline builder failed. + #[error("build the log pipeline: {0}")] + Logs(anyhow::Error), } /// The empty extension payload: a no-op builder for a core-only lattice @@ -107,41 +113,56 @@ impl ComponentBuilder for () { } } -/// Assembles the core backend builders and the lattice `Ext` builder into -/// a [`Components`] bundle. The log pipeline is sized from `[limits.logs]` -/// and built here; the embedder retains its read handle by cloning -/// [`Components::logs`] after the build. -pub struct ComponentsBuilder { +/// Assembles the core, `Ext`, and log-pipeline builders into a [`Components`] +/// bundle; the logs slot defaults to [`LogPipelineBuilder`]. +pub struct ComponentsBuilder { /// Builds the chain backend ([`RuntimeTypes::Chain`]). pub chain: C, /// Builds the store backend ([`RuntimeTypes::Store`]). pub store: S, /// Builds the extension payload ([`RuntimeTypes::Ext`]). pub ext: E, + /// Builds the shared [`LogPipeline`]. + pub logs: L, } impl ComponentsBuilder { - /// Create a new [`ComponentsBuilder`]. + /// Create a new [`ComponentsBuilder`] with the default log pipeline. pub fn new(chain: C, store: S, ext: E) -> Self { - Self { chain, store, ext } + Self { + chain, + store, + ext, + logs: LogPipelineBuilder, + } + } +} + +impl ComponentsBuilder { + /// Replace the log pipeline builder. + pub fn with_logs(self, logs: L2) -> ComponentsBuilder { + ComponentsBuilder { + chain: self.chain, + store: self.store, + ext: self.ext, + logs, + } } - /// Drive each builder against `ctx`, then bundle the backends with a - /// fresh log pipeline. The builder outputs must match the lattice - /// seams: chain to [`RuntimeTypes::Chain`], store to - /// [`RuntimeTypes::Store`], ext to [`RuntimeTypes::Ext`]. A failing - /// sub-build returns the [`BuildError`] variant naming that slot. + /// Drive each builder against `ctx` and bundle the backends; a failing + /// sub-build returns the [`BuildError`] naming that slot. pub async fn build(self, ctx: &BuilderContext<'_>) -> Result, BuildError> where T: RuntimeTypes, C: ComponentBuilder, S: ComponentBuilder, E: ComponentBuilder, + L: ComponentBuilder, { let chain = self.chain.build(ctx).await.map_err(BuildError::Chain)?; let store = self.store.build(ctx).await.map_err(BuildError::Store)?; let ext = self.ext.build(ctx).await.map_err(BuildError::Ext)?; - let logs = LogPipeline::in_memory(ctx.config.limits.logs()); + let logs = self.logs.build(ctx).await.map_err(BuildError::Logs)?; Ok(Components { chain, store, @@ -157,11 +178,7 @@ mod tests { use crate::engine_config::EngineConfig; use crate::preset::CoreRuntime; - /// Drives the core component builders end-to-end against a real (empty) - /// config and a fresh data directory: chain pool, redb store, and the - /// log pipeline are opened at runtime, not just typechecked. Proves the - /// store builder creates the data directory and the assembly bundles a - /// live pipeline. + /// Opens the core backends end-to-end against a fresh data directory. #[tokio::test] async fn components_builder_opens_the_core_backends() { let dir = tempfile::tempdir().expect("tempdir"); @@ -189,4 +206,31 @@ mod tests { // The bundle carries a live in-memory log pipeline. let _ = &components.logs; } + + /// `with_logs` substitutes the log pipeline builder: the bundle carries + /// the exact pipeline the custom builder yields. + #[tokio::test] + async fn with_logs_substitutes_the_pipeline() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = EngineConfig::default(); + let tasks = nexum_tasks::TaskManager::new(); + let executor = tasks.executor(); + let ctx = BuilderContext { + config: &config, + data_dir: dir.path(), + executor: &executor, + }; + + let custom = LogPipeline::in_memory(config.limits.logs()); + let components = ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + .with_logs(crate::test_utils::Prebuilt(custom.clone())) + .build::(&ctx) + .await + .expect("build with a custom log pipeline"); + + assert!( + std::sync::Arc::ptr_eq(&components.logs.router(), &custom.router()), + "bundle carries the substituted pipeline", + ); + } } diff --git a/nexum/crates/nexum-runtime/src/host/component/chain.rs b/nexum/crates/nexum-runtime/src/host/component/chain.rs new file mode 100644 index 00000000..490736d7 --- /dev/null +++ b/nexum/crates/nexum-runtime/src/host/component/chain.rs @@ -0,0 +1,76 @@ +//! Chain backend seam: raw JSON-RPC dispatch plus block/chain-log +//! subscriptions, mirroring the inherent `ProviderPool` API. + +use std::future::Future; + +use alloy_chains::Chain; +use alloy_rpc_types_eth::Filter; + +use crate::host::provider_pool::{BlockStream, CanonicalLogStream, ProviderError, ProviderPool}; + +/// Permitted read surface, re-exported from `nexum-world`. +pub use nexum_world::ChainMethod; + +/// Async chain backend; methods mirror [`ProviderPool`]. +pub trait ChainProvider { + /// Open a `newHeads` block subscription on `chain`. + fn subscribe_blocks( + &self, + chain: Chain, + ) -> impl Future> + Send; + + /// Current head block number (`eth_blockNumber`). + fn block_number(&self, chain: Chain) + -> impl Future> + Send; + + /// Open a canonical (reorg-aware) `eth_getLogs` log poller on + /// `chain` from `start_block`. + fn watch_chain_logs( + &self, + chain: Chain, + filter: Filter, + start_block: u64, + ) -> Result; + + /// Raw JSON-RPC dispatch; `params_json` is the JSON params array. + fn request( + &self, + chain: Chain, + method: ChainMethod, + params_json: String, + ) -> impl Future> + Send; +} + +impl ChainProvider for ProviderPool { + fn subscribe_blocks( + &self, + chain: Chain, + ) -> impl Future> + Send { + ProviderPool::subscribe_blocks(self, chain) + } + + fn block_number( + &self, + chain: Chain, + ) -> impl Future> + Send { + ProviderPool::block_number(self, chain) + } + + fn watch_chain_logs( + &self, + chain: Chain, + filter: Filter, + start_block: u64, + ) -> Result { + ProviderPool::watch_chain_logs(self, chain, filter, start_block) + } + + fn request( + &self, + chain: Chain, + method: ChainMethod, + params_json: String, + ) -> impl Future> + Send { + ProviderPool::request(self, chain, method, params_json) + } +} diff --git a/crates/nexum-runtime/src/host/component/mod.rs b/nexum/crates/nexum-runtime/src/host/component/mod.rs similarity index 70% rename from crates/nexum-runtime/src/host/component/mod.rs rename to nexum/crates/nexum-runtime/src/host/component/mod.rs index 0adb15fa..80054506 100644 --- a/crates/nexum-runtime/src/host/component/mod.rs +++ b/nexum/crates/nexum-runtime/src/host/component/mod.rs @@ -1,8 +1,6 @@ -//! Backend component traits: the seam between the WIT host impls and -//! the concrete capability backends. Implemented here for the existing -//! pools; the runtime-generic `HostState` consumes them via generic -//! bounds (the async traits are not dyn-compatible by design). The -//! [`RuntimeTypes`] lattice ties the seams into one parameter. +//! Backend component traits: the seam between the WIT host impls and the +//! concrete capability backends, tied together by the [`RuntimeTypes`] +//! lattice. mod builder; mod chain; @@ -11,22 +9,20 @@ mod state; pub use builder::{ BuildError, BuilderContext, ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, - ProviderPoolBuilder, + LogPipelineBuilder, ProviderPoolBuilder, }; pub use chain::{ChainMethod, ChainProvider}; pub use runtime_types::{Handle, RuntimeTypes}; pub use state::{StateHandle, StateStore}; -/// Owned bundle of the shared backends the supervisor threads into -/// every module store. All members are cheap Arc-backed clones. +/// Owned bundle of shared backends threaded into every module store; cheap to +/// clone. pub struct Components { pub chain: T::Chain, pub store: T::Store, - /// Extension backends (the lattice `Ext` payload), threaded into - /// `HostState.ext` and reached by extensions through `ExtState`. + /// Extension backends (the lattice `Ext` payload). pub ext: T::Ext, - /// Shared log pipeline: capture points route through its router, and - /// the embedder reads runs and logs back off the same handle. + /// Shared log pipeline. pub logs: crate::host::logs::LogPipeline, } @@ -47,11 +43,12 @@ mod tests { use crate::host::local_store_redb::{LocalStore, ModuleStore}; use crate::host::provider_pool::ProviderPool; - /// Core-only lattice (no extension payload) so the trait bounds are - /// exercised without depending on any domain extension crate. + /// Core-only lattice (no extension payload). #[derive(Clone, Copy, Default)] struct CoreTypes; + impl crate::sealed::SealedRuntimeTypes for CoreTypes {} + impl RuntimeTypes for CoreTypes { type Chain = ProviderPool; type Store = LocalStore; diff --git a/nexum/crates/nexum-runtime/src/host/component/runtime_types.rs b/nexum/crates/nexum-runtime/src/host/component/runtime_types.rs new file mode 100644 index 00000000..70af46af --- /dev/null +++ b/nexum/crates/nexum-runtime/src/host/component/runtime_types.rs @@ -0,0 +1,19 @@ +//! The RuntimeTypes lattice: one trait naming the core backend seams plus the +//! pluggable [`RuntimeTypes::Ext`] slot, so every generic signature takes one +//! parameter. + +use crate::host::component::{ChainProvider, StateStore}; + +/// Core backend seams a runtime assembly provides, plus the extension slot +/// ([`Ext`](RuntimeTypes::Ext)). Sealed. +pub trait RuntimeTypes: crate::sealed::SealedRuntimeTypes + 'static { + /// JSON-RPC dispatch and subscriptions. + type Chain: ChainProvider + Clone + Send + Sync + 'static; + /// Process-wide store vending per-module handles. + type Store: StateStore + Clone + Send + Sync + 'static; + /// Extension state slot; `()` for an assembly with no extensions. + type Ext: Clone + Send + Sync + 'static; +} + +/// Per-module store handle of a lattice's Store member. +pub type Handle = <::Store as StateStore>::Handle; diff --git a/crates/nexum-runtime/src/host/component/state.rs b/nexum/crates/nexum-runtime/src/host/component/state.rs similarity index 66% rename from crates/nexum-runtime/src/host/component/state.rs rename to nexum/crates/nexum-runtime/src/host/component/state.rs index b3213f7f..587f0bce 100644 --- a/crates/nexum-runtime/src/host/component/state.rs +++ b/nexum/crates/nexum-runtime/src/host/component/state.rs @@ -5,7 +5,7 @@ // local_store_redb.rs. #![allow(clippy::result_large_err)] -use crate::host::local_store_redb::{LocalStore, ModuleStore, StorageError}; +use crate::host::local_store_redb::{LocalStore, ModuleStore, StorageError, WriteOp}; /// Process-wide state store that vends per-module handles. pub trait StateStore { @@ -29,6 +29,20 @@ pub trait StateHandle { fn delete(&self, key: &str) -> Result<(), StorageError>; /// Enumerate module-visible keys starting with `prefix`. fn list_keys(&self, prefix: &str) -> Result, StorageError>; + /// Whether `key` exists. + fn contains(&self, key: &str) -> Result { + Ok(self.get(key)?.is_some()) + } + /// Value byte length, `Ok(None)` when absent. + fn len(&self, key: &str) -> Result, StorageError> { + Ok(self.get(key)?.map(|v| v.len() as u64)) + } + /// Number of keys starting with `prefix`. + fn count(&self, prefix: &str) -> Result { + Ok(self.list_keys(prefix)?.len() as u64) + } + /// Apply `ops` as one atomic batch; caps op count and total value bytes. + fn apply(&self, ops: &[WriteOp]) -> Result<(), StorageError>; } impl StateStore for LocalStore { @@ -59,4 +73,20 @@ impl StateHandle for ModuleStore { fn list_keys(&self, prefix: &str) -> Result, StorageError> { ModuleStore::list_keys(self, prefix) } + + fn contains(&self, key: &str) -> Result { + ModuleStore::contains(self, key) + } + + fn len(&self, key: &str) -> Result, StorageError> { + ModuleStore::len(self, key) + } + + fn count(&self, prefix: &str) -> Result { + ModuleStore::count(self, prefix) + } + + fn apply(&self, ops: &[WriteOp]) -> Result<(), StorageError> { + ModuleStore::apply(self, ops) + } } diff --git a/crates/nexum-runtime/src/host/error.rs b/nexum/crates/nexum-runtime/src/host/error.rs similarity index 65% rename from crates/nexum-runtime/src/host/error.rs rename to nexum/crates/nexum-runtime/src/host/error.rs index 8bdb4f9b..22951a7b 100644 --- a/crates/nexum-runtime/src/host/error.rs +++ b/nexum/crates/nexum-runtime/src/host/error.rs @@ -1,57 +1,51 @@ -//! Small constructors and From conversions that build the WIT error -//! shapes: the chain interface's `chain-error` and the per-interface -//! `Fault` the store interfaces report. `fault_label` / `fault_message` -//! project a reported `Fault` into stable metric and log fields. +//! Constructors and `From` conversions building the WIT error shapes +//! (`chain-error`, `Fault`); `fault_label` / `fault_message` project a +//! `Fault` into metric and log fields. use crate::bindings::nexum::host::chain::{ChainError, RpcError}; use crate::bindings::nexum::host::types::{Fault, RateLimit}; use crate::host::local_store_redb::StorageError; use crate::host::provider_pool::ProviderError; -/// `Denied` chain fault for a request the host policy refused to -/// forward, such as a method outside the permitted read surface. +/// `Denied` chain fault for a request the host policy refused. pub(crate) fn chain_denied(detail: impl Into) -> ChainError { ChainError::Fault(Fault::Denied(detail.into())) } -/// Stable snake_case label for a [`Fault`], used as a metric label and -/// structured-log `kind` field. Mirrors the SDK `HostFault::label` -/// vocabulary. -pub(crate) fn fault_label(fault: &Fault) -> &'static str { +/// Stable snake_case label for a [`Fault`], for metric and log `kind` fields. +pub fn fault_label(fault: &Fault) -> &'static str { + use nexum_world::FaultLabel as Label; match fault { - Fault::Unsupported(_) => "unsupported", - Fault::Unavailable(_) => "unavailable", - Fault::Denied(_) => "denied", - Fault::RateLimited(_) => "rate_limited", - Fault::Timeout => "timeout", - Fault::InvalidInput(_) => "invalid_input", - Fault::Internal(_) => "internal", + Fault::Unsupported(_) => Label::Unsupported, + Fault::Unavailable(_) => Label::Unavailable, + Fault::Denied(_) => Label::Denied, + Fault::RateLimited(_) => Label::RateLimited, + Fault::Timeout => Label::Timeout, + Fault::InvalidInput(_) => Label::InvalidInput, + Fault::Internal(_) => Label::Internal, } + .into() } -/// Human-readable detail carried by a [`Fault`], for the log `message` -/// field. The payload-bearing cases carry their own detail; the two -/// payload-free cases render a fixed phrase. -pub(crate) fn fault_message(fault: &Fault) -> &str { +/// Human-readable detail carried by a [`Fault`], for the log `message` field. +pub fn fault_message(fault: &Fault) -> std::borrow::Cow<'_, str> { match fault { Fault::Unsupported(m) | Fault::Unavailable(m) | Fault::Denied(m) | Fault::InvalidInput(m) - | Fault::Internal(m) => m, - Fault::RateLimited(_) => "rate limited", - Fault::Timeout => "timeout", + | Fault::Internal(m) => std::borrow::Cow::Borrowed(m), + Fault::RateLimited(rl) => match rl.retry_after_ms { + Some(ms) => std::borrow::Cow::Owned(format!("rate limited, retry after {ms} ms")), + None => std::borrow::Cow::Borrowed("rate limited"), + }, + Fault::Timeout => std::borrow::Cow::Borrowed("timeout"), } } -/// Project a [`ProviderError`] into the chain `chain-error`. -/// -/// A structured JSON-RPC `ErrorResp` (the node returned a `code`, -/// typically `-32000` for an `eth_call` revert) becomes a -/// [`ChainError::Rpc`] carrying that code and any decoded revert bytes, -/// so the SDK revert classifier can dispatch the ComposableCoW -/// envelopes. Everything else - transport failures, an unknown chain, -/// bad params - becomes a shared [`Fault`]. +/// Project a [`ProviderError`] into `chain-error`: a structured JSON-RPC +/// `ErrorResp` becomes [`ChainError::Rpc`] with its code and revert bytes, +/// everything else a shared [`Fault`]. impl From for ChainError { fn from(err: ProviderError) -> Self { match err { @@ -97,10 +91,8 @@ impl From for ChainError { } } -/// Classify a transport-level RPC failure into a [`Fault`]. HTTP 429 -/// maps to `rate-limited`, 503 / a dropped backend to `unavailable`, -/// and a timed-out request to `timeout`; anything else defaults to -/// `unavailable`. +/// Classify a transport RPC failure: 429 to `rate-limited`, 503 or a dropped +/// backend to `unavailable`, a timeout to `timeout`, else `unavailable`. fn transport_fault(source: &alloy_transport::TransportError) -> Fault { use alloy_transport::TransportErrorKind; if let Some(kind) = source.as_transport_err() { @@ -128,13 +120,15 @@ fn transport_fault(source: &alloy_transport::TransportError) -> Fault { } } -/// The `local-store` interface is the failure domain, so the fault omits -/// the redundant subsystem tag. A quota breach is a policy `denied`; -/// anything else is an `internal` backend failure. +/// Project a [`StorageError`]: quota breach to `denied`, a per-batch cap to +/// `invalid-input`, else `internal`. impl From for Fault { fn from(err: StorageError) -> Self { match err { StorageError::QuotaExceeded { .. } => Fault::Denied(err.to_string()), + StorageError::ApplyOpsExceeded { .. } | StorageError::ApplyBytesExceeded { .. } => { + Fault::InvalidInput(err.to_string()) + } _ => Fault::Internal(err.to_string()), } } diff --git a/nexum/crates/nexum-runtime/src/host/extension.rs b/nexum/crates/nexum-runtime/src/host/extension.rs new file mode 100644 index 00000000..06761a08 --- /dev/null +++ b/nexum/crates/nexum-runtime/src/host/extension.rs @@ -0,0 +1,339 @@ +//! Extension seam: what one extension contributes to the host (namespace, +//! capabilities, linker hook, optional service, provider kind, event sources, +//! and manifest-section install predicates). + +use std::any::Any; +use std::collections::{BTreeMap, BTreeSet}; +use std::pin::Pin; +use std::sync::Arc; + +use async_trait::async_trait; +use futures::Stream; +use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; +use wasmtime::Store; +use wasmtime::component::{Component, Linker}; + +use crate::bindings::nexum::host::types::Event; +use crate::engine_config::EngineConfig; +use crate::host::actor::Liveness; +use crate::host::component::RuntimeTypes; +use crate::host::state::HostState; +use crate::manifest::{ExtensionSections, NamespaceCaps}; + +/// One runtime extension; a module importing its interface boots only if both +/// the linker entry and the capability namespace are registered. +pub trait Extension: Send + Sync + 'static { + /// Namespace this extension owns; keys its service in [`HostServices`]. + fn namespace(&self) -> &'static str; + + /// Capability namespace merged into enforcement. + fn capabilities(&self) -> NamespaceCaps; + + /// Add the extension's imports to a worker linker, after core interfaces + /// and before instantiation. + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; + + /// Host service this extension owns, published under its namespace. + fn service(&self) -> Option> { + None + } + + /// Provider kind this extension installs. + fn provider(&self) -> Option>> { + None + } + + /// Manifest section names this extension claims; an unclaimed non-core + /// section is refused at boot. + fn manifest_sections(&self) -> &'static [&'static str] { + &[] + } + + /// Admit one provider at install over its manifest sections; `Err` + /// refuses fail-fast. + fn admit_provider(&self, provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + let _ = (provider, sections); + Ok(()) + } + + /// Admit one worker at install over its and the loaded providers' + /// sections; `Err` refuses fail-fast. + fn admit_worker( + &self, + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], + ) -> anyhow::Result<()> { + let _ = (worker, sections, providers); + Ok(()) + } + + /// Subscription kinds this extension's event sources emit; an unknown + /// non-core kind is refused at boot. + fn subscriptions(&self) -> &'static [&'static str] { + &[] + } + + /// Open the extension's event sources after boot; the event loop merges + /// and dispatches them. + fn events(&self, sources: &mut EventSources<'_>) -> anyhow::Result> { + let _ = sources; + Ok(Vec::new()) + } +} + +/// Event dispatched to every module with a `[[subscription]]` of `kind` whose +/// filters match `attrs`. +pub struct ExtensionEvent { + /// Manifest subscription kind that routes this event. + pub kind: &'static str, + /// Routing attributes a subscription's filters match against. + pub attrs: Vec<(&'static str, String)>, + /// The host event delivered to each matching module. + pub event: Event, +} + +/// A stream of extension events the event loop merges and drives. +pub type ExtensionEventStream = Pin + Send>>; + +/// Launch inputs for [`Extension::events`]. +pub struct EventSources<'a> { + /// The loaded engine config. + pub config: &'a EngineConfig, + /// Extension-owned services, as booted. + pub services: &'a HostServices, + /// Extension subscription kinds declared by at least one module. + pub subscribed: &'a BTreeSet, + executor: &'a TaskExecutor, + tasks: &'a mut TaskSet, +} + +impl<'a> EventSources<'a> { + /// Bundle the launch inputs for one [`Extension::events`] pass. + pub fn new( + config: &'a EngineConfig, + services: &'a HostServices, + subscribed: &'a BTreeSet, + executor: &'a TaskExecutor, + tasks: &'a mut TaskSet, + ) -> Self { + Self { + config, + services, + subscribed, + executor, + tasks, + } + } + + /// Spawn an event-source task; it must end when its stream's receiver + /// drops. + pub fn spawn(&mut self, task: impl Future + Send + 'static) { + self.tasks.push(self.executor.spawn(async move { + task.await; + TaskExit::ReceiverGone + })); + } +} + +/// Type-erased host service an extension owns, downcast at the call site. +pub trait HostService: Any + Send + Sync + 'static {} + +/// A provider component kind; the host holds an instance behind the owning +/// extension's serialized service. +#[async_trait] +pub trait ProviderKind: Send + Sync + 'static { + /// Manifest kind this provider answers for. + fn kind(&self) -> &'static str; + + /// Adds the provider's imports to a provider linker. + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; + + /// Instantiate and install one provider; [`Installed::Dead`] is a failed + /// guest `init`, `Err` a boot error. + async fn install( + &self, + instance: ProviderInstance<'_, T>, + service: &Arc, + ) -> anyhow::Result; +} + +/// One provider instance ready to install. +pub struct ProviderInstance<'a, T: RuntimeTypes> { + /// Compiled provider component. + pub component: &'a Component, + /// Linker carrying the kind's imports plus the WASI base. + pub linker: &'a Linker>, + /// Store the instance runs in; the kind takes ownership. + pub store: Store>, + /// Manifest `[config]` handed to the guest `init`. + pub config: Vec<(String, String)>, + /// The provider's extension-owned manifest sections. + pub sections: &'a ExtensionSections, + /// Fuel budget applied before each routed guest call. + pub fuel_per_call: u64, + /// Shared liveness the instance reports traps on. + pub liveness: Liveness, +} + +/// One loaded provider as [`Extension::admit_worker`] sees it. +#[derive(Clone, Debug)] +pub struct ProviderManifest { + /// The provider's namespace (its manifest name). + pub name: String, + /// Registered kind spelling. + pub kind: &'static str, + /// The provider's extension-owned manifest sections. + pub sections: ExtensionSections, +} + +/// Outcome of one provider install. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Installed { + /// `init` succeeded; the instance is installed and routable. + Live, + /// `init` returned a fault; the instance is loaded but not routable. + Dead, +} + +/// Downcast a type-erased service to `S`. `None` when the type differs. +pub fn downcast_service(service: &Arc) -> Option> { + let service = Arc::clone(service); + let erased: Arc = service; + erased.downcast().ok() +} + +/// Immutable per-namespace service map, built once at boot. +#[derive(Clone, Default)] +pub struct HostServices(Arc>>); + +impl std::fmt::Debug for HostServices { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_set().entries(self.0.keys()).finish() + } +} + +impl HostServices { + /// Collect each extension's service under its namespace; refuses a + /// duplicate. + pub fn from_extensions( + extensions: &[Arc>], + ) -> anyhow::Result { + let mut map = BTreeMap::new(); + for ext in extensions { + let Some(service) = ext.service() else { + continue; + }; + let namespace = ext.namespace(); + if map.insert(namespace, service).is_some() { + anyhow::bail!("duplicate extension service namespace {namespace}"); + } + } + Ok(Self(Arc::new(map))) + } + + /// Service under `namespace` downcast to `S`; `None` if absent or + /// mismatched. + pub fn get(&self, namespace: &str) -> Option> { + downcast_service(self.0.get(namespace)?) + } + + /// The raw type-erased service under `namespace`. + pub fn raw(&self, namespace: &str) -> Option<&Arc> { + self.0.get(namespace) + } + + /// Publish `service` under `namespace`, refusing a duplicate. + pub fn with_service( + self, + namespace: &'static str, + service: Arc, + ) -> anyhow::Result { + let mut map = Arc::unwrap_or_clone(self.0); + if map.insert(namespace, service).is_some() { + anyhow::bail!("duplicate extension service namespace {namespace}"); + } + Ok(Self(Arc::new(map))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::supervisor::TestTypes; + + struct Registry(u64); + impl HostService for Registry {} + + struct Clockwork; + impl HostService for Clockwork {} + + struct ServiceExt { + namespace: &'static str, + service: Option>, + } + + impl Extension for ServiceExt { + fn namespace(&self) -> &'static str { + self.namespace + } + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: "test:ext/", + ifaces: &[], + } + } + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + Ok(()) + } + fn service(&self) -> Option> { + self.service.as_ref().map(Arc::clone) + } + } + + fn ext( + namespace: &'static str, + service: Arc, + ) -> Arc> { + Arc::new(ServiceExt { + namespace, + service: Some(service), + }) + } + + /// A registered service comes back under its namespace, downcast to + /// its concrete type; a wrong type or an absent namespace is `None`. + #[test] + fn get_downcasts_by_namespace() { + let services = + HostServices::from_extensions(&[ext("acme", Arc::new(Registry(7)))]).expect("build"); + + let registry = services.get::("acme").expect("registered"); + assert_eq!(registry.0, 7); + assert!(services.get::("acme").is_none()); + assert!(services.get::("absent").is_none()); + assert!(services.raw("acme").is_some()); + } + + /// A serviceless extension contributes nothing to the map. + #[test] + fn serviceless_extension_is_absent() { + let serviceless: Arc> = Arc::new(ServiceExt { + namespace: "quiet", + service: None, + }); + let services = HostServices::from_extensions(&[serviceless]).expect("build"); + assert!(services.raw("quiet").is_none()); + } + + /// Two services under one namespace refuse to build. + #[test] + fn duplicate_namespace_is_refused() { + let err = HostServices::from_extensions(&[ + ext("acme", Arc::new(Registry(1))), + ext("acme", Arc::new(Clockwork)), + ]) + .expect_err("duplicate namespace"); + assert!(err.to_string().contains("acme"), "{err}"); + } +} diff --git a/crates/nexum-runtime/src/host/http.rs b/nexum/crates/nexum-runtime/src/host/http.rs similarity index 84% rename from crates/nexum-runtime/src/host/http.rs rename to nexum/crates/nexum-runtime/src/host/http.rs index ec74e6c8..57e00595 100644 --- a/crates/nexum-runtime/src/host/http.rs +++ b/nexum/crates/nexum-runtime/src/host/http.rs @@ -1,10 +1,7 @@ -//! wasi:http outgoing gate: every guest request funnels through -//! [`HttpGate::send_request`], which enforces the per-module -//! `[capabilities.http].allow` list, clamps the guest-settable timeouts -//! to the engine's `[limits.http]` maxima, and bounds the exchange with -//! a total deadline plus a response-body cap before handing the request -//! to the backend. The host does not follow redirects, so each hop is a -//! fresh guest request that re-enters this gate. +//! wasi:http outgoing gate. [`HttpGate::send_request`] enforces the +//! per-module `[capabilities.http].allow` list, clamps guest timeouts to the +//! `[limits.http]` maxima, and bounds the exchange with a total deadline and +//! response-body cap. Redirects are not followed; each hop re-enters the gate. use std::future::Future; use std::pin::Pin; @@ -26,8 +23,7 @@ use super::state::HostState; use crate::engine_config::OutboundHttpLimits; use crate::manifest::host_allowed; -/// Per-module outbound HTTP policy: the manifest allowlist, the -/// engine's outbound limits, and the module name for log attribution. +/// Per-module outbound HTTP policy. pub struct HttpGate { module: String, allowlist: Vec, @@ -35,8 +31,7 @@ pub struct HttpGate { } impl HttpGate { - /// Gate for `module` with its `[capabilities.http].allow` entries - /// and the engine's `[limits.http]` outbound limits. + /// Gate for `module` with its allowlist and outbound limits. pub fn new( module: impl Into, allowlist: Vec, @@ -74,11 +69,8 @@ impl WasiHttpHooks for HttpGate { } } -/// Clamp the guest-settable timeouts to the engine maxima. Guest values -/// above a maximum are lowered, never rejected. The linked handler -/// substitutes its own fixed default for unset request-options before -/// this hook runs, so an unset timeout also clamps down: each maximum -/// doubles as the effective default. +/// Clamp guest timeouts to the engine maxima, lowering never rejecting; each +/// maximum doubles as the effective default for an unset timeout. fn clamp(mut config: OutgoingRequestConfig, limits: &OutboundHttpLimits) -> OutgoingRequestConfig { config.connect_timeout = config.connect_timeout.min(limits.connect_timeout_max); config.first_byte_timeout = config.first_byte_timeout.min(limits.first_byte_timeout_max); @@ -88,15 +80,10 @@ fn clamp(mut config: OutgoingRequestConfig, limits: &OutboundHttpLimits) -> Outg config } -/// Dispatch through the default backend, bounded by the engine's total -/// deadline and response-body cap. The `timeout_at` covers connect, -/// TLS, request write, and response headers; the same deadline instant -/// is armed inside the [`CappedBody`] wrapping the response body, so a -/// consuming guest gets `ConnectionReadTimeout` mid-body. The deadline -/// is unconditional: the connection driver is raced against it in its -/// own task and aborted when it fires, so a guest that parks the -/// response without ever reading the body cannot hold the socket past -/// the deadline. +/// Dispatch through the default backend under the total deadline and body +/// cap. The deadline is unconditional: it covers headers and, via +/// [`CappedBody`], the body, and the raced connection driver is aborted when +/// it fires even if the guest never reads the response. fn send_with_limits( request: http::Request, config: OutgoingRequestConfig, @@ -132,12 +119,9 @@ fn send_with_limits( HostFutureIncomingResponse::pending(handle) } -/// Response-body wrapper enforcing the size cap and the total deadline -/// while the guest streams the body. -/// -/// Exceeding the cap yields `HttpResponseBodySize(cap)`; the deadline -/// firing mid-body yields `ConnectionReadTimeout`, the code the backend -/// uses for its own read-phase timeouts. +/// Response-body wrapper enforcing the size cap and total deadline. Over-cap +/// yields `HttpResponseBodySize(cap)`; the deadline firing yields +/// `ConnectionReadTimeout`. struct CappedBody { inner: HyperIncomingBody, /// Bytes still admissible under the cap. @@ -197,18 +181,10 @@ impl Body for CappedBody { } } -/// Allowlist decision for one outgoing request URI. -/// -/// Matching is host-only: ports and scheme are ignored (the handler -/// admits only http/https before this point), and comparison is -/// case-insensitive with exact or `*.suffix` wildcard semantics per -/// [`host_allowed`]. IPv6 literals keep their brackets, so allowlist -/// entries use the bracketed form. -/// -/// The check is name-based and precedes resolution: the connection -/// re-resolves the name, so there is no IP pinning and no defence -/// against DNS rebinding or names resolving to internal addresses. -/// The operator vouches for the names they allowlist. +/// Allowlist decision for one request URI. Host-only, case-insensitive, exact +/// or `*.suffix` per [`host_allowed`]; IPv6 literals stay bracketed. +/// Name-based and pre-resolution, so there is no IP pinning or DNS-rebinding +/// defence. fn admit(uri: &http::Uri, allowlist: &[String]) -> Result<(), ErrorCode> { let Some(host) = uri.host() else { return Err(ErrorCode::HttpRequestUriInvalid); @@ -268,26 +244,53 @@ mod tests { #[test] fn exact_host_passes() { - assert!(admit(&uri("https://api.cow.fi/v1/x"), &allow(&["api.cow.fi"])).is_ok()); - assert!(admit(&uri("http://api.cow.fi/"), &allow(&["api.cow.fi"])).is_ok()); + assert!( + admit( + &uri("https://api.acme.example/v1/x"), + &allow(&["api.acme.example"]) + ) + .is_ok() + ); + assert!( + admit( + &uri("http://api.acme.example/"), + &allow(&["api.acme.example"]) + ) + .is_ok() + ); } #[test] fn off_list_host_is_denied() { - assert!(denied("https://evil.example/", &["api.cow.fi"])); - assert!(denied("https://api.cow.fi.evil.example/", &["api.cow.fi"])); + assert!(denied("https://evil.example/", &["api.acme.example"])); + assert!(denied( + "https://api.acme.example.evil.example/", + &["api.acme.example"] + )); } #[test] fn empty_allowlist_denies_everything() { - assert!(denied("https://api.cow.fi/", &[])); + assert!(denied("https://api.acme.example/", &[])); assert!(denied("http://127.0.0.1/", &[])); } #[test] fn matching_is_case_insensitive() { - assert!(admit(&uri("https://API.COW.FI/"), &allow(&["api.cow.fi"])).is_ok()); - assert!(admit(&uri("https://api.cow.fi/"), &allow(&["API.COW.FI"])).is_ok()); + assert!( + admit( + &uri("https://API.ACME.EXAMPLE/"), + &allow(&["api.acme.example"]) + ) + .is_ok() + ); + assert!( + admit( + &uri("https://api.acme.example/"), + &allow(&["API.ACME.EXAMPLE"]) + ) + .is_ok() + ); } #[test] @@ -301,7 +304,10 @@ mod tests { #[test] fn exact_entry_does_not_match_subdomains() { - assert!(denied("https://sub.api.cow.fi/", &["api.cow.fi"])); + assert!(denied( + "https://sub.api.acme.example/", + &["api.acme.example"] + )); } #[test] @@ -321,16 +327,18 @@ mod tests { #[test] fn ports_do_not_affect_matching() { - let list = allow(&["api.cow.fi"]); - assert!(admit(&uri("https://api.cow.fi:8443/v1"), &list).is_ok()); - assert!(admit(&uri("http://api.cow.fi:80/v1"), &list).is_ok()); - assert!(denied("https://evil.example:443/", &["api.cow.fi"])); + let list = allow(&["api.acme.example"]); + assert!(admit(&uri("https://api.acme.example:8443/v1"), &list).is_ok()); + assert!(admit(&uri("http://api.acme.example:80/v1"), &list).is_ok()); + assert!(denied("https://evil.example:443/", &["api.acme.example"])); // A port spelled in the allowlist entry never matches: entries // are hosts, not authorities. - assert!(denied("https://api.cow.fi:8443/", &["api.cow.fi:8443"])); + assert!(denied( + "https://api.acme.example:8443/", + &["api.acme.example:8443"] + )); } - // ----------------- SSRF-style bypass regressions (#57) --------- // // `http::Uri` resolves the authority per RFC 3986 before `admit` // ever sees a host string, so these are regression guards on the @@ -449,14 +457,14 @@ mod tests { for scheme in ["http", "https"] { assert!( admit( - &uri(&format!("{scheme}://api.cow.fi/")), - &allow(&["api.cow.fi"]) + &uri(&format!("{scheme}://api.acme.example/")), + &allow(&["api.acme.example"]) ) .is_ok() ); assert!(denied( &format!("{scheme}://evil.example/"), - &["api.cow.fi"] + &["api.acme.example"] )); } } @@ -464,7 +472,7 @@ mod tests { #[test] fn uri_without_authority_is_invalid_not_denied() { assert!(matches!( - admit(&uri("/relative/path"), &allow(&["api.cow.fi"])), + admit(&uri("/relative/path"), &allow(&["api.acme.example"])), Err(ErrorCode::HttpRequestUriInvalid) )); } @@ -491,7 +499,7 @@ mod tests { #[tokio::test] async fn send_request_denies_off_list_host_with_http_request_denied() { - let mut gate = HttpGate::new("test-module", allow(&["api.cow.fi"]), limits()); + let mut gate = HttpGate::new("test-module", allow(&["api.acme.example"]), limits()); let Err(err) = gate.send_request(request("http://evil.example/x"), config()) else { panic!("off-list host must be denied"); }; @@ -512,8 +520,6 @@ mod tests { ); } - // ----------------- timeout clamping ---------------------------- - fn config_with(timeout: Duration) -> OutgoingRequestConfig { OutgoingRequestConfig { use_tls: false, @@ -560,17 +566,13 @@ mod tests { assert_eq!(clamped.between_bytes_timeout, Duration::from_secs(5)); } - // ----------------- deadline + body cap ------------------------- - /// A detached executor for test-server tasks. fn test_executor() -> nexum_tasks::TaskExecutor { nexum_tasks::TaskManager::new().executor() } - /// One-connection loopback server: reads the request, writes - /// `response`, then either closes or holds the socket open so the - /// client sees a stall instead of EOF. Panic-free: any IO failure - /// just ends the task and the client side times out. + /// One-connection loopback server; `hold_open` stalls instead of sending + /// EOF. async fn spawn_server(response: Vec, hold_open: bool) -> std::net::SocketAddr { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await diff --git a/crates/nexum-runtime/src/host/impls/chain.rs b/nexum/crates/nexum-runtime/src/host/impls/chain.rs similarity index 70% rename from crates/nexum-runtime/src/host/impls/chain.rs rename to nexum/crates/nexum-runtime/src/host/impls/chain.rs index 3fb67286..59aa8133 100644 --- a/crates/nexum-runtime/src/host/impls/chain.rs +++ b/nexum/crates/nexum-runtime/src/host/impls/chain.rs @@ -1,4 +1,4 @@ -//! `nexum:host/chain`: raw JSON-RPC dispatch over alloy. +//! `nexum:host/chain`: raw JSON-RPC dispatch. use std::time::Instant; @@ -10,12 +10,8 @@ use crate::host::component::{ChainMethod, ChainProvider, RuntimeTypes}; use crate::host::error::chain_denied; use crate::host::state::HostState; -/// Resolve a guest method string into the permitted read surface. -/// -/// Signing-adjacent and mutating methods have no [`ChainMethod`] -/// variant, so they are rejected here structurally rather than by an -/// ad-hoc name check; the result is a `Denied` chain fault. Every entry -/// of a batch request routes through this same resolver. +/// Resolve a guest method string into the permitted read surface; an unknown +/// or mutating method is a `Denied` fault. fn resolve_method(method: &str) -> Result { ChainMethod::try_from(method).map_err(|_| { chain_denied(format!( @@ -24,6 +20,39 @@ fn resolve_method(method: &str) -> Result { }) } +/// Error if `body` exceeds `cap` bytes, checked before the copy into the +/// guest. +fn check_response_cap( + body: &str, + cap: usize, + chain_id: u64, + method: &str, +) -> Result<(), ChainError> { + if body.len() > cap { + tracing::warn!( + chain_id, + method, + body_bytes = body.len(), + cap_bytes = cap, + "chain response exceeds size cap - rejecting before guest copy" + ); + metrics::counter!( + "shepherd_chain_response_capped_total", + "chain_id" => chain_id.to_string(), + "method" => method.to_owned(), + ) + .increment(1); + return Err(ChainError::Fault( + crate::bindings::nexum::host::types::Fault::InvalidInput(format!( + "chain response ({} bytes) exceeds the configured cap ({} bytes)", + body.len(), + cap, + )), + )); + } + Ok(()) +} + impl nexum::host::chain::Host for HostState { async fn request( &mut self, @@ -57,7 +86,11 @@ impl nexum::host::chain::Host for HostState { .chain .request(chain, method, params) .await - .map_err(ChainError::from); + .map_err(ChainError::from) + .and_then(|body| { + check_response_cap(&body, self.chain_response_max_bytes, chain_id, name)?; + Ok(body) + }); tracing::trace!(elapsed_ms = ?start.elapsed(), "chain::request done"); let outcome = if result.is_ok() { "ok" } else { "err" }; metrics::counter!( @@ -70,16 +103,8 @@ impl nexum::host::chain::Host for HostState { result } - /// Dispatch a batch of requests, one `RpcResult` per entry in order. - /// - /// The outer `ChainError` is reserved for a failure that stops the - /// host producing any results at all; this host has no such path, so - /// it always returns `Ok`. A per-entry failure (a denied - /// method, a node revert, a transport fault) surfaces as that entry's - /// `RpcResult::Err`. This impl folds each entry independently, so a - /// failure leaves its neighbours intact; a different host could instead - /// short-circuit the batch, so SDK consumers match on each entry, not - /// on the batch call. + /// Dispatch a batch, one `RpcResult` per entry in order. Per-entry + /// failures are independent; the outer `ChainError` is never returned. async fn request_batch( &mut self, chain_id: u64, @@ -90,10 +115,44 @@ impl nexum::host::chain::Host for HostState { // per-chain timeout, so the worst-case blocking time for a batch // is N x request_timeout_secs. tracing::debug!(chain_id, count = requests.len(), "chain::request-batch"); + let cap = self.chain_response_max_bytes; let mut out = Vec::with_capacity(requests.len()); + // The per-entry cap (inside `request`) bounds each body; this + // running total bounds the aggregate `Vec` lowered into + // guest memory in one go, so a wide batch of individually-legal + // bodies cannot saturate the guest heap either - the exact failure + // the guidance in #154 (block-range chunking via request-batch) + // would otherwise re-introduce. + let mut total_bytes: usize = 0; for req in requests { + let method = req.method.clone(); match nexum::host::chain::Host::request(self, chain_id, req.method, req.params).await { - Ok(s) => out.push(nexum::host::chain::RpcResult::Ok(s)), + Ok(s) => { + total_bytes = total_bytes.saturating_add(s.len()); + if total_bytes > cap { + tracing::warn!( + chain_id, + method = %method, + total_bytes, + cap_bytes = cap, + "chain batch aggregate exceeds size cap - rejecting entry before guest copy" + ); + metrics::counter!( + "shepherd_chain_response_capped_total", + "chain_id" => chain_id.to_string(), + "method" => method, + ) + .increment(1); + out.push(nexum::host::chain::RpcResult::Err(ChainError::Fault( + crate::bindings::nexum::host::types::Fault::InvalidInput(format!( + "batch aggregate ({total_bytes} bytes) exceeds the configured \ + cap ({cap} bytes)", + )), + ))); + } else { + out.push(nexum::host::chain::RpcResult::Ok(s)); + } + } Err(e) => out.push(nexum::host::chain::RpcResult::Err(e)), } } @@ -110,10 +169,7 @@ mod tests { use crate::host::provider_pool::ProviderError; use alloy_transport::TransportErrorKind; - /// Helper: build a synthetic transport-level [`TransportError`]. - /// Transport-level errors carry no structured JSON-RPC `ErrorResp`, - /// so they project to a [`ChainError::Fault`] rather than a - /// [`ChainError::Rpc`]. + /// Build a synthetic transport-level [`TransportError`]. fn transport_err(msg: &str) -> alloy_transport::TransportError { TransportErrorKind::custom_str(msg) } @@ -283,4 +339,26 @@ mod tests { )); assert!(resolved[2].is_ok()); } + + // ── response size cap tests (#154) ── + + #[test] + fn response_at_cap_is_accepted() { + let body = "x".repeat(10); + assert!( + check_response_cap(&body, 10, 1, "eth_call").is_ok(), + "body exactly at cap should pass" + ); + } + + #[test] + fn response_over_cap_returns_invalid_input() { + let body = "x".repeat(11); + let err = + check_response_cap(&body, 10, 1, "eth_call").expect_err("over-cap body should fail"); + assert!( + matches!(err, ChainError::Fault(Fault::InvalidInput(_))), + "expected InvalidInput fault, got {err:?}" + ); + } } diff --git a/crates/nexum-runtime/src/host/impls/identity.rs b/nexum/crates/nexum-runtime/src/host/impls/identity.rs similarity index 80% rename from crates/nexum-runtime/src/host/impls/identity.rs rename to nexum/crates/nexum-runtime/src/host/impls/identity.rs index bbea4d44..392ed552 100644 --- a/crates/nexum-runtime/src/host/impls/identity.rs +++ b/nexum/crates/nexum-runtime/src/host/impls/identity.rs @@ -1,6 +1,6 @@ -//! `nexum:host/identity`: deferred to 0.3 (keystore / KMS backend). -//! `accounts()` returns an empty roster so guests can probe-then-skip; -//! signing returns `unsupported`. +//! `nexum:host/identity`: unimplemented stub. `accounts()` returns an +//! empty roster so guests can probe-then-skip; signing returns +//! `unsupported`. use crate::bindings::nexum; use crate::bindings::nexum::host::types::Fault; diff --git a/nexum/crates/nexum-runtime/src/host/impls/local_store.rs b/nexum/crates/nexum-runtime/src/host/impls/local_store.rs new file mode 100644 index 00000000..e78bdbb4 --- /dev/null +++ b/nexum/crates/nexum-runtime/src/host/impls/local_store.rs @@ -0,0 +1,51 @@ +//! `nexum:host/local-store`: redb backend with host-side namespacing. + +use crate::bindings::nexum; +use crate::bindings::nexum::host::local_store::{KeyValue, WriteOp}; +use crate::bindings::nexum::host::types::Fault; +use crate::host::component::{RuntimeTypes, StateHandle}; +use crate::host::local_store_redb; +use crate::host::state::HostState; + +impl nexum::host::local_store::Host for HostState { + async fn get(&mut self, key: String) -> Result>, Fault> { + self.store.get(&key).map_err(Fault::from) + } + + async fn set(&mut self, key: String, value: Vec) -> Result<(), Fault> { + self.store.set(&key, &value).map_err(Fault::from) + } + + async fn delete(&mut self, key: String) -> Result<(), Fault> { + self.store.delete(&key).map_err(Fault::from) + } + + async fn list_keys(&mut self, prefix: String) -> Result, Fault> { + self.store.list_keys(&prefix).map_err(Fault::from) + } + + async fn contains(&mut self, key: String) -> Result { + self.store.contains(&key).map_err(Fault::from) + } + + async fn len(&mut self, key: String) -> Result, Fault> { + self.store.len(&key).map_err(Fault::from) + } + + async fn count(&mut self, prefix: String) -> Result { + self.store.count(&prefix).map_err(Fault::from) + } + + async fn apply(&mut self, ops: Vec) -> Result<(), Fault> { + let ops: Vec = ops + .into_iter() + .map(|op| match op { + WriteOp::Set(KeyValue { key, value }) => { + local_store_redb::WriteOp::Set { key, value } + } + WriteOp::Delete(key) => local_store_redb::WriteOp::Delete { key }, + }) + .collect(); + self.store.apply(&ops).map_err(Fault::from) + } +} diff --git a/crates/nexum-runtime/src/host/impls/logging.rs b/nexum/crates/nexum-runtime/src/host/impls/logging.rs similarity index 81% rename from crates/nexum-runtime/src/host/impls/logging.rs rename to nexum/crates/nexum-runtime/src/host/impls/logging.rs index 37be16c8..12197013 100644 --- a/crates/nexum-runtime/src/host/impls/logging.rs +++ b/nexum/crates/nexum-runtime/src/host/impls/logging.rs @@ -1,6 +1,5 @@ -//! `nexum:host/logging`: constructs a `HostInterface` [`LogRecord`] from -//! the guest's `log` call and hands it to the shared router, which tags -//! it with the run and fans it to the tracing consumer and the store. +//! `nexum:host/logging`: builds a [`LogRecord`] from the guest's `log` call +//! and routes it. use tracing_core::Level; diff --git a/nexum/crates/nexum-runtime/src/host/impls/messaging.rs b/nexum/crates/nexum-runtime/src/host/impls/messaging.rs new file mode 100644 index 00000000..3851c710 --- /dev/null +++ b/nexum/crates/nexum-runtime/src/host/impls/messaging.rs @@ -0,0 +1,83 @@ +//! `nexum:host/messaging`: stub. `publish` reports `unsupported`, `query` +//! returns empty; the per-store topic scope is still enforced ahead of the +//! stub. + +use crate::bindings::nexum; +use crate::bindings::nexum::host::types::Fault; +use crate::host::component::RuntimeTypes; +use crate::host::state::HostState; + +/// Whether `topic` falls within `scope`. Empty scope admits everything; +/// otherwise a topic matches a scope entry exactly or as a `/`-bounded path +/// prefix, so a grant never leaks into a longer sibling segment. +fn topic_in_scope(topic: &str, scope: &[String]) -> bool { + if scope.is_empty() { + return true; + } + scope.iter().any(|allowed| { + if topic == allowed { + return true; + } + let prefix = allowed.strip_suffix('/').unwrap_or(allowed); + topic + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/')) + }) +} + +impl nexum::host::messaging::Host for HostState { + async fn publish(&mut self, content_topic: String, _payload: Vec) -> Result<(), Fault> { + if !topic_in_scope(&content_topic, &self.messaging_topics) { + return Err(Fault::Denied(format!( + "content topic {content_topic:?} outside this component's messaging scope" + ))); + } + Err(Fault::Unsupported("Waku backend deferred to 0.3".into())) + } + + async fn query( + &mut self, + _content_topic: String, + _start_time: Option, + _end_time: Option, + _limit: Option, + ) -> Result, Fault> { + Ok(vec![]) + } +} + +#[cfg(test)] +mod tests { + use super::topic_in_scope; + + #[test] + fn empty_scope_admits_everything() { + assert!(topic_in_scope("/nexum/1/anything/proto", &[])); + } + + #[test] + fn exact_topic_is_admitted() { + let scope = vec!["/nexum/1/acme-orders/proto".to_owned()]; + assert!(topic_in_scope("/nexum/1/acme-orders/proto", &scope)); + assert!(!topic_in_scope("/nexum/1/other/proto", &scope)); + } + + #[test] + fn prefix_scope_admits_the_family_but_not_a_sibling() { + let scope = vec!["/nexum/1/".to_owned()]; + assert!(topic_in_scope("/nexum/1/acme-orders/proto", &scope)); + assert!(topic_in_scope("/nexum/1/twap/proto", &scope)); + // A sibling namespace stays out. + assert!(!topic_in_scope("/nexum/2/acme-orders/proto", &scope)); + } + + #[test] + fn prefix_boundary_is_a_path_segment_not_a_substring() { + // A scope entry without a trailing slash still bounds on the path + // separator, so it cannot leak into a longer sibling segment. + let scope = vec!["/nexum/1/acme".to_owned()]; + assert!(topic_in_scope("/nexum/1/acme", &scope)); + assert!(topic_in_scope("/nexum/1/acme/orders", &scope)); + assert!(!topic_in_scope("/nexum/1/acme-orders/proto", &scope)); + } +} diff --git a/nexum/crates/nexum-runtime/src/host/impls/mod.rs b/nexum/crates/nexum-runtime/src/host/impls/mod.rs new file mode 100644 index 00000000..b86cf5e8 --- /dev/null +++ b/nexum/crates/nexum-runtime/src/host/impls/mod.rs @@ -0,0 +1,10 @@ +//! `Host` trait impls for [`crate::host::state::HostState`], one file per WIT +//! interface: dispatch glue to the backends in [`crate::host`]. + +mod chain; +mod identity; +mod local_store; +mod logging; +mod messaging; +mod remote_store; +mod types; diff --git a/crates/nexum-runtime/src/host/impls/remote_store.rs b/nexum/crates/nexum-runtime/src/host/impls/remote_store.rs similarity index 90% rename from crates/nexum-runtime/src/host/impls/remote_store.rs rename to nexum/crates/nexum-runtime/src/host/impls/remote_store.rs index f2c8661f..1894484f 100644 --- a/crates/nexum-runtime/src/host/impls/remote_store.rs +++ b/nexum/crates/nexum-runtime/src/host/impls/remote_store.rs @@ -1,4 +1,5 @@ -//! `nexum:host/remote-store`: deferred to 0.3 (Swarm backend). +//! `nexum:host/remote-store`: unimplemented stub; every call returns the +//! unsupported fault. use crate::bindings::nexum; use crate::bindings::nexum::host::types::Fault; diff --git a/crates/nexum-runtime/src/host/impls/types.rs b/nexum/crates/nexum-runtime/src/host/impls/types.rs similarity index 100% rename from crates/nexum-runtime/src/host/impls/types.rs rename to nexum/crates/nexum-runtime/src/host/impls/types.rs diff --git a/crates/nexum-runtime/src/host/local_store_redb.rs b/nexum/crates/nexum-runtime/src/host/local_store_redb.rs similarity index 51% rename from crates/nexum-runtime/src/host/local_store_redb.rs rename to nexum/crates/nexum-runtime/src/host/local_store_redb.rs index eec7c4ef..a6c2516c 100644 --- a/crates/nexum-runtime/src/host/local_store_redb.rs +++ b/nexum/crates/nexum-runtime/src/host/local_store_redb.rs @@ -1,10 +1,9 @@ -//! `nexum:host/local-store` backend. +//! `nexum:host/local-store` backend: a single redb file under +//! `EngineConfig.engine.state_dir`. //! -//! Single redb file under `EngineConfig.engine.state_dir`. Each module is -//! namespaced host-side by a fixed 32-byte prefix `keccak256(module_name)` -//! prepended to every key, so modules sharing a key string see disjoint -//! data and cannot forge a key into another's range. keccak256 matches ENS -//! node derivation (ADR-0003). +//! Every key is prefixed host-side by `keccak256(module_name)`, so modules +//! sharing a key string see disjoint data and cannot forge into another's +//! range. #![allow(clippy::result_large_err)] @@ -20,37 +19,53 @@ const TABLE: TableDefinition<'static, &[u8], &[u8]> = TableDefinition::new("nexu #[cfg(test)] const PREFIX_LEN: usize = 32; -/// Fixed per-entry redb page/B-tree overhead charged on top of prefix + key -/// + value so the quota bounds on-disk bytes, not logical payload. +/// Fixed per-entry overhead charged with prefix+key+value so the quota bounds +/// on-disk bytes, not logical payload. const ENTRY_OVERHEAD: u64 = 32; -/// Process-wide handle to the local-store redb database. Cheap to -/// clone. Use [`LocalStore::module`] to obtain a [`ModuleStore`] -/// handle with a pre-computed namespace prefix. +/// Cap on ops per [`ModuleStore::apply`] batch. +pub const MAX_APPLY_OPS: usize = 1024; + +/// Cap on total set-value bytes per [`ModuleStore::apply`] batch. +pub const MAX_APPLY_VALUE_BYTES: u64 = 4 * 1024 * 1024; + +/// One write in a [`ModuleStore::apply`] batch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WriteOp { + /// Insert or overwrite `key` with `value`. + Set { + /// Module-visible key. + key: String, + /// Value bytes. + value: Vec, + }, + /// Delete `key`; a missing key is a no-op. + Delete { + /// Module-visible key. + key: String, + }, +} + +/// Process-wide handle to the local-store redb database; cheap to clone. #[derive(Debug, Clone)] pub struct LocalStore { db: Arc, - /// Per-namespace live-byte counter, shared across every handle of a - /// namespace, so [`ModuleStore::set`] is O(1) rather than re-scanning - /// the namespace on each write. Lazily seeded by one range scan. + /// Per-namespace live-byte counter, lazily seeded, keeping writes O(1). counters: Arc, u64>>>, } -/// Per-module handle carrying the pre-computed 32-byte keccak256 -/// namespace prefix. +/// Per-module handle carrying the pre-computed keccak256 namespace prefix. #[derive(Debug, Clone)] pub struct ModuleStore { db: Arc, prefix: Vec, counters: Arc, u64>>>, - /// On-disk byte quota for this namespace, enforced in - /// [`ModuleStore::set`]. `None` is unlimited. + /// On-disk byte quota for this namespace; `None` is unlimited. quota_bytes: Option, } impl LocalStore { - /// Open (or create) the redb file at `path`. Materialises the shared - /// table so subsequent read transactions never hit `TableDoesNotExist`. + /// Open or create the redb file at `path`, initialising the shared table. pub fn open(path: impl AsRef) -> Result { let db = Database::create(path).map_err(StorageError::Open)?; { @@ -64,9 +79,7 @@ impl LocalStore { }) } - /// Return a [`ModuleStore`] with the keccak256 prefix pre-computed. - /// Rejects the empty string so callers can rely on a non-trivial - /// prefix. + /// [`ModuleStore`] for `namespace`; rejects the empty string. pub fn module(&self, namespace: &str) -> Result { if namespace.is_empty() { return Err(StorageError::InvalidNamespace( @@ -84,16 +97,14 @@ impl LocalStore { } impl ModuleStore { - /// Cap this handle's namespace at `quota_bytes` of on-disk footprint - /// (prefix + key + value + fixed overhead, summed across its keys). - /// Writes past the cap are rejected with [`StorageError::QuotaExceeded`]. + /// Cap this handle's namespace at `quota_bytes` on-disk; over-cap writes + /// return [`StorageError::QuotaExceeded`]. pub fn with_quota(mut self, quota_bytes: u64) -> Self { self.quota_bytes = Some(quota_bytes); self } - /// Fetch a value for `key`. Returns `Ok(None)` when no entry - /// exists; the module never observes the prefix. + /// Value for `key`, `Ok(None)` when absent. pub fn get(&self, key: &str) -> Result>, StorageError> { let full = self.build_key(key); let txn = self.db.begin_read().map_err(StorageError::Txn)?; @@ -105,9 +116,49 @@ impl ModuleStore { Ok(value) } - /// Insert or overwrite. Under a quota, charges on-disk cost (prefix, key, - /// value, overhead) and rejects an over-quota write untouched. The commit - /// is fsync-durable. + /// Whether `key` exists, without copying the value out. + pub fn contains(&self, key: &str) -> Result { + let full = self.build_key(key); + let txn = self.db.begin_read().map_err(StorageError::Txn)?; + let table = txn.open_table(TABLE).map_err(StorageError::Table)?; + Ok(table + .get(full.as_slice()) + .map_err(StorageError::Storage)? + .is_some()) + } + + /// Value byte length for `key`, `Ok(None)` when absent. + pub fn len(&self, key: &str) -> Result, StorageError> { + let full = self.build_key(key); + let txn = self.db.begin_read().map_err(StorageError::Txn)?; + let table = txn.open_table(TABLE).map_err(StorageError::Table)?; + Ok(table + .get(full.as_slice()) + .map_err(StorageError::Storage)? + .map(|v| v.value().len() as u64)) + } + + /// Number of module-visible keys starting with `prefix`. + pub fn count(&self, prefix: &str) -> Result { + let full_prefix = self.build_key(prefix); + let txn = self.db.begin_read().map_err(StorageError::Txn)?; + let table = txn.open_table(TABLE).map_err(StorageError::Table)?; + let mut count = 0u64; + for entry in table + .range(full_prefix.as_slice()..) + .map_err(StorageError::Storage)? + { + let (k, _v) = entry.map_err(StorageError::Storage)?; + if !k.value().starts_with(&full_prefix) { + break; + } + count += 1; + } + Ok(count) + } + + /// Insert or overwrite; fsync-durable. An over-quota write is rejected + /// untouched. pub fn set(&self, key: &str, value: &[u8]) -> Result<(), StorageError> { let full = self.build_key(key); let txn = self.db.begin_write().map_err(StorageError::Txn)?; @@ -152,14 +203,103 @@ impl ModuleStore { Ok(()) } - /// On-disk footprint charged for one entry: prefix + key + value + a - /// fixed per-entry overhead. + /// Apply `ops` atomically, fsync-durable; later ops on a key win. An + /// over-quota batch or one past [`MAX_APPLY_OPS`] / + /// [`MAX_APPLY_VALUE_BYTES`] is rejected before commit. + pub fn apply(&self, ops: &[WriteOp]) -> Result<(), StorageError> { + if ops.len() > MAX_APPLY_OPS { + return Err(StorageError::ApplyOpsExceeded { + ops: ops.len(), + cap: MAX_APPLY_OPS, + }); + } + let value_bytes: u64 = ops + .iter() + .map(|op| match op { + WriteOp::Set { value, .. } => value.len() as u64, + WriteOp::Delete { .. } => 0, + }) + .sum(); + if value_bytes > MAX_APPLY_VALUE_BYTES { + return Err(StorageError::ApplyBytesExceeded { + bytes: value_bytes, + cap: MAX_APPLY_VALUE_BYTES, + }); + } + let txn = self.db.begin_write().map_err(StorageError::Txn)?; + let mut counters = self.counters.lock().unwrap_or_else(|e| e.into_inner()); + let track = self.quota_bytes.is_some() || counters.contains_key(&self.prefix); + let mut projected = 0u64; + { + let mut table = txn.open_table(TABLE).map_err(StorageError::Table)?; + if track { + // Net whole-batch footprint: each touched key's on-disk cost + // is released once and its post-batch cost charged once. + let mut finals: HashMap<&str, Option> = HashMap::new(); + for op in ops { + match op { + WriteOp::Set { key, value } => finals.insert(key, Some(value.len())), + WriteOp::Delete { key } => finals.insert(key, None), + }; + } + let used = match counters.get(&self.prefix) { + Some(&u) => u, + None => self.used_bytes(&table)?, + }; + let mut released = 0u64; + let mut charged = 0u64; + for (key, value_len) in &finals { + let full = self.build_key(key); + released += table + .get(full.as_slice()) + .map_err(StorageError::Storage)? + .map(|v| self.entry_cost(key.len(), v.value().len())) + .unwrap_or(0); + charged += value_len + .map(|len| self.entry_cost(key.len(), len)) + .unwrap_or(0); + } + projected = used.saturating_sub(released) + charged; + if let Some(quota) = self.quota_bytes + && projected > quota + { + // Returning aborts the write transaction: nothing lands. + return Err(StorageError::QuotaExceeded { + needed: projected, + quota, + }); + } + } + for op in ops { + match op { + WriteOp::Set { key, value } => { + let full = self.build_key(key); + table + .insert(full.as_slice(), value.as_slice()) + .map_err(StorageError::Storage)?; + } + WriteOp::Delete { key } => { + let full = self.build_key(key); + table + .remove(full.as_slice()) + .map_err(StorageError::Storage)?; + } + } + } + } + txn.commit().map_err(StorageError::Commit)?; + if track { + counters.insert(self.prefix.clone(), projected); + } + Ok(()) + } + + /// On-disk footprint of one entry: prefix + key + value + overhead. fn entry_cost(&self, key_len: usize, value_len: usize) -> u64 { self.prefix.len() as u64 + ENTRY_OVERHEAD + key_len as u64 + value_len as u64 } - /// Seed the namespace footprint by summing [`Self::entry_cost`] over its - /// prefix range. Run once per namespace; the counter is then incremental. + /// Seed the namespace footprint by scanning its prefix range once. fn used_bytes( &self, table: &impl ReadableTable<&'static [u8], &'static [u8]>, @@ -204,9 +344,7 @@ impl ModuleStore { Ok(()) } - /// Enumerate keys whose raw key (post-prefix) starts with - /// `prefix`. Returns only the module-visible key strings; the - /// host strips the namespace prefix. + /// Module-visible keys whose post-prefix key starts with `prefix`. pub fn list_keys(&self, prefix: &str) -> Result, StorageError> { let full_prefix = self.build_key(prefix); let txn = self.db.begin_read().map_err(StorageError::Txn)?; @@ -242,6 +380,7 @@ impl ModuleStore { /// Errors surfaced by [`LocalStore`] and [`ModuleStore`]. #[derive(Debug, Error)] +#[non_exhaustive] pub enum StorageError { #[error("open redb: {0}")] Open(#[source] redb::DatabaseError), @@ -262,6 +401,20 @@ pub enum StorageError { /// The module's byte quota. quota: u64, }, + #[error("apply batch has {ops} ops but the cap is {cap}")] + ApplyOpsExceeded { + /// Ops in the rejected batch. + ops: usize, + /// Per-batch op cap. + cap: usize, + }, + #[error("apply batch carries {bytes} value B but the cap is {cap} B")] + ApplyBytesExceeded { + /// Total set-value bytes in the rejected batch. + bytes: u64, + /// Per-batch value-byte cap. + cap: u64, + }, } #[cfg(test)] diff --git a/crates/nexum-runtime/src/host/local_store_redb/tests.rs b/nexum/crates/nexum-runtime/src/host/local_store_redb/tests.rs similarity index 67% rename from crates/nexum-runtime/src/host/local_store_redb/tests.rs rename to nexum/crates/nexum-runtime/src/host/local_store_redb/tests.rs index 1191d8d1..cbc37c2a 100644 --- a/crates/nexum-runtime/src/host/local_store_redb/tests.rs +++ b/nexum/crates/nexum-runtime/src/host/local_store_redb/tests.rs @@ -66,6 +66,47 @@ fn list_keys_strips_namespace_prefix() { assert!(keys.iter().all(|k| k.starts_with("posted:"))); } +#[test] +fn contains_answers_without_the_value() { + let (_dir, store) = fresh(); + let ms = store.module("twap").unwrap(); + ms.set("k", b"v").unwrap(); + assert!(ms.contains("k").unwrap()); + assert!(!ms.contains("missing").unwrap()); + ms.delete("k").unwrap(); + assert!(!ms.contains("k").unwrap()); +} + +#[test] +fn len_reports_value_bytes_or_none() { + let (_dir, store) = fresh(); + let ms = store.module("twap").unwrap(); + ms.set("empty", b"").unwrap(); + ms.set("k", b"abcde").unwrap(); + assert_eq!(ms.len("empty").unwrap(), Some(0)); + assert_eq!(ms.len("k").unwrap(), Some(5)); + assert_eq!(ms.len("missing").unwrap(), None); +} + +#[test] +fn count_matches_list_keys_and_respects_namespaces() { + let (_dir, store) = fresh(); + let a = store.module("a").unwrap(); + let b = store.module("b").unwrap(); + a.set("posted:1", b"x").unwrap(); + a.set("posted:2", b"y").unwrap(); + a.set("other", b"z").unwrap(); + b.set("posted:9", b"w").unwrap(); + assert_eq!(a.count("posted:").unwrap(), 2); + assert_eq!(a.count("").unwrap(), 3); + assert_eq!(a.count("nope:").unwrap(), 0); + assert_eq!(b.count("posted:").unwrap(), 1); + assert_eq!( + a.count("posted:").unwrap(), + a.list_keys("posted:").unwrap().len() as u64 + ); +} + #[test] fn rejects_empty_namespace() { let (_dir, store) = fresh(); @@ -207,9 +248,133 @@ fn quota_counts_across_short_lived_handles_of_one_namespace() { assert!(matches!(err, StorageError::QuotaExceeded { .. })); } -// --------------------------------------------------------------------------- +// Atomic apply batches (#609). + +fn set_op(key: &str, value: &[u8]) -> WriteOp { + WriteOp::Set { + key: key.into(), + value: value.to_vec(), + } +} + +fn delete_op(key: &str) -> WriteOp { + WriteOp::Delete { key: key.into() } +} + +#[test] +fn apply_mixed_batch_commits_atomically() { + let (_dir, store) = fresh(); + let ms = store.module("m").unwrap(); + ms.set("stale", b"old").unwrap(); + ms.set("keep", b"as-is").unwrap(); + ms.apply(&[ + set_op("fresh", b"new"), + set_op("stale", b"overwritten"), + delete_op("keep"), + delete_op("missing"), + ]) + .unwrap(); + assert_eq!(ms.get("fresh").unwrap().as_deref(), Some(&b"new"[..])); + assert_eq!( + ms.get("stale").unwrap().as_deref(), + Some(&b"overwritten"[..]) + ); + assert!(ms.get("keep").unwrap().is_none()); + assert!(ms.get("missing").unwrap().is_none()); +} + +#[test] +fn apply_over_quota_batch_lands_nothing() { + let (_dir, store) = fresh(); + // Room for the seeded entry plus one small one, but not the batch's two. + let quota = cost("seed", b"v") + cost("a", b"1"); + let ms = store.module("m").unwrap().with_quota(quota); + ms.set("seed", b"v").unwrap(); + let err = ms + .apply(&[set_op("a", b"1"), set_op("b", b"2")]) + .unwrap_err(); + match err { + StorageError::QuotaExceeded { needed, quota: q } => { + assert_eq!(needed, quota + cost("b", b"2")); + assert_eq!(q, quota); + } + other => panic!("expected QuotaExceeded, got {other:?}"), + } + // All-or-nothing: even the op that fit on its own must not have landed. + assert!(ms.get("a").unwrap().is_none()); + assert!(ms.get("b").unwrap().is_none()); + assert_eq!(ms.get("seed").unwrap().as_deref(), Some(&b"v"[..])); +} + +#[test] +fn apply_over_op_count_batch_rejected_untouched() { + let (_dir, store) = fresh(); + let ms = store.module("m").unwrap(); + let ops: Vec = (0..=MAX_APPLY_OPS) + .map(|i| set_op(&format!("k{i}"), b"v")) + .collect(); + let err = ms.apply(&ops).unwrap_err(); + match err { + StorageError::ApplyOpsExceeded { ops: n, cap } => { + assert_eq!(n, MAX_APPLY_OPS + 1); + assert_eq!(cap, MAX_APPLY_OPS); + } + other => panic!("expected ApplyOpsExceeded, got {other:?}"), + } + assert!(ms.get("k0").unwrap().is_none()); + assert_eq!(ms.count("").unwrap(), 0); +} + +#[test] +fn apply_over_value_bytes_batch_rejected_untouched() { + let (_dir, store) = fresh(); + let ms = store.module("m").unwrap(); + let big = vec![0u8; MAX_APPLY_VALUE_BYTES as usize]; + let err = ms + .apply(&[set_op("a", &big), set_op("b", b"1")]) + .unwrap_err(); + match err { + StorageError::ApplyBytesExceeded { bytes, cap } => { + assert_eq!(bytes, MAX_APPLY_VALUE_BYTES + 1); + assert_eq!(cap, MAX_APPLY_VALUE_BYTES); + } + other => panic!("expected ApplyBytesExceeded, got {other:?}"), + } + assert!(ms.get("a").unwrap().is_none()); + assert!(ms.get("b").unwrap().is_none()); +} + +#[test] +fn apply_quota_charges_net_batch_footprint() { + let (_dir, store) = fresh(); + // Quota holds exactly one entry: the set alone would bust it, but the + // batch's delete releases the seeded bytes first, so the net fits. + let quota = cost("old", b"12345"); + let ms = store.module("m").unwrap().with_quota(quota); + ms.set("old", b"12345").unwrap(); + assert!(ms.set("new", b"12345").is_err()); + ms.apply(&[delete_op("old"), set_op("new", b"12345")]) + .unwrap(); + assert!(ms.get("old").unwrap().is_none()); + assert_eq!(ms.get("new").unwrap().as_deref(), Some(&b"12345"[..])); + // The counter carried the net footprint: a refill of the freed slot fits. + ms.apply(&[delete_op("new"), set_op("old", b"12345")]) + .unwrap(); +} + +#[test] +fn apply_quota_projects_the_last_op_per_key() { + let (_dir, store) = fresh(); + // The oversized first write on "k" is superseded within the batch; only + // the final small value is charged, so the batch fits a tight quota. + let quota = cost("k", b"ok"); + let ms = store.module("m").unwrap().with_quota(quota); + ms.apply(&[set_op("k", &vec![0u8; 1024]), set_op("k", b"ok")]) + .unwrap(); + assert_eq!(ms.get("k").unwrap().as_deref(), Some(&b"ok"[..])); +} + // Concurrent access tests: real parallelism via the blocking pool. -// --------------------------------------------------------------------------- fn blocking_executor() -> nexum_tasks::TaskExecutor { nexum_tasks::TaskManager::new().executor() diff --git a/crates/nexum-runtime/src/host/logs/mod.rs b/nexum/crates/nexum-runtime/src/host/logs/mod.rs similarity index 67% rename from crates/nexum-runtime/src/host/logs/mod.rs rename to nexum/crates/nexum-runtime/src/host/logs/mod.rs index 74e0760f..ddb68535 100644 --- a/crates/nexum-runtime/src/host/logs/mod.rs +++ b/nexum/crates/nexum-runtime/src/host/logs/mod.rs @@ -1,25 +1,14 @@ //! Typed module-log pipeline. //! -//! Three capture points construct [`LogRecord`]s and hand them to one -//! [`LogRouter`]: the `nexum:host/logging` glue (`HostInterface`), the -//! per-store stdout/stderr pipes ([`StdioStream`], `Stdout`/`Stderr`), -//! and the supervisor's death path (`Panic`). The router fans each -//! record to exactly two consumers: a host `tracing` event (so the -//! operator console and OTLP stacks stay live) and the retention store. -//! `tracing` is a consumer of the pipeline, not its transport. +//! Three capture points build [`LogRecord`]s for one [`LogRouter`]: the +//! `nexum:host/logging` glue, the per-store stdout/stderr pipes, and the +//! supervisor death path. The router fans each record to a host `tracing` +//! event and the retention store. [`LogPipeline`] is the shared handle, +//! carrying the write side and the store's read side. //! -//! [`LogPipeline`] is the shared handle: it rides the host state so -//! every capture point reaches the router, and it exposes the store's -//! read side to the embedding surface for run listing and log paging. -//! -//! One guest panic deliberately yields three records, distinguishable -//! by source: the guest panic hook writes to stderr (`Stderr`, warn) -//! and then reports over the host logging call (`HostInterface`, -//! error), and the supervisor synthesizes a death record (`Panic`, -//! error) once the trap surfaces. The redundancy is kept because the -//! channels survive different failure modes: stderr capture works even -//! if the sink's host call traps, and the supervisor record covers a -//! guest with no hook installed at all. +//! One guest panic yields three records distinguished by [`LogSource`] +//! (stderr, host logging call, supervisor death), redundancy covering +//! channels that survive different failure modes. mod stdio; mod store; @@ -33,8 +22,7 @@ use tracing_core::Level; pub use stdio::StdioStream; pub use store::{InMemoryRunLogStore, LogPage, RunLogStore, RunMeta}; -/// Identity of one module run. Minted at every instantiation; a restart -/// increments `seq`, so each run is a distinct retention key. +/// Identity of one module run; a restart increments `seq`, keying retention. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RunId { /// Module namespace this run belongs to. @@ -46,8 +34,7 @@ pub struct RunId { } impl RunId { - /// Mint a run for `module` at sequence `seq`, stamping the current - /// wall-clock instant. + /// Mint a run for `module` at sequence `seq`. pub fn new(module: impl Into>, seq: u64) -> Self { Self { module: module.into(), @@ -57,10 +44,11 @@ impl RunId { } } -/// Which capture point produced a record. The snake_case name is the -/// `source` field on the host tracing event. +/// Which capture point produced a record; the snake_case name is the tracing +/// `source` field. #[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum LogSource { /// The `nexum:host/logging` glue: an explicit guest `log` call. HostInterface, @@ -88,7 +76,7 @@ pub struct LogRecord { } impl LogRecord { - /// Build a record stamped at the current wall-clock instant. + /// Record stamped at the current instant. pub fn now(run: RunId, source: LogSource, level: Level, message: String) -> Self { Self { run, @@ -105,19 +93,16 @@ impl LogRecord { } } -/// Fixed per-record charge on top of the message bytes, approximating -/// the host memory a retained record occupies (run key, timestamps, -/// `String` header, ring slot). Without it a flood of empty messages -/// would grow the ring far past the `[limits.logs]` byte budget. +/// Fixed per-record charge added to message bytes so empty messages still +/// count against the `[limits.logs]` byte budget. const RECORD_OVERHEAD: usize = 128; /// Fans every captured record to a host `tracing` event and the /// retention store. pub struct LogRouter { store: Arc, - /// Woken after each append so a consumer can await new output instead - /// of polling. `notify_waiters` wakes only armed waiters, so a reader - /// must arm before it reads (see [`LogPipeline::appended`]). + /// Woken after each append; a reader must arm before reading (see + /// [`LogPipeline::appended`]). appended: Arc, } @@ -142,11 +127,7 @@ impl LogRouter { } } -/// Emit one record as a host tracing event at its own level, carrying -/// the module, run sequence, and source. `tracing`'s macros require a -/// static level per call site, and `Level` is a set of associated -/// consts rather than a matchable enum, so dispatch through an equality -/// ladder over the five tiers. +/// Emit one record as a host tracing event at its own level. fn emit_tracing(record: &LogRecord) { let module = &*record.run.module; let run = record.run.seq; @@ -165,9 +146,7 @@ fn emit_tracing(record: &LogRecord) { } } -/// Shared log pipeline threaded into every module store. Cheap to clone -/// (one `Arc`); the write side is [`router`](Self::router) and the read -/// side is [`list_runs`](Self::list_runs) / [`read`](Self::read). +/// Shared log pipeline threaded into every module store; cheap to clone. #[derive(Clone)] pub struct LogPipeline { router: Arc, @@ -181,8 +160,8 @@ impl LogPipeline { } } - /// Pipeline over the default byte-bounded in-memory backend, sized by - /// the resolved `[limits.logs]` knobs. + /// Pipeline over the byte-bounded in-memory backend, sized by + /// `[limits.logs]`. pub fn in_memory(limits: crate::engine_config::LogRetentionLimits) -> Self { Self::new(Arc::new(InMemoryRunLogStore::new(limits))) } @@ -192,9 +171,8 @@ impl LogPipeline { self.router.clone() } - /// A notify woken after each append, for awaiting new output without - /// polling. Arm a `notified()` future (and `enable()` it) before reading - /// so an append between the read and the await is not lost. + /// Notify woken after each append; arm a `notified()` future before + /// reading so an append is not lost. pub fn appended(&self) -> Arc { self.router.appended.clone() } @@ -216,8 +194,7 @@ mod tests { use super::*; - /// Store that both counts appends and forwards to an inner ring, so - /// the fan-out test can prove the retention consumer saw the record. + /// Store that records appends so the fan-out test can inspect them. struct CountingStore { appended: Mutex>, } diff --git a/crates/nexum-runtime/src/host/logs/stdio.rs b/nexum/crates/nexum-runtime/src/host/logs/stdio.rs similarity index 83% rename from crates/nexum-runtime/src/host/logs/stdio.rs rename to nexum/crates/nexum-runtime/src/host/logs/stdio.rs index 251e6753..9e91a9e8 100644 --- a/crates/nexum-runtime/src/host/logs/stdio.rs +++ b/nexum/crates/nexum-runtime/src/host/logs/stdio.rs @@ -1,7 +1,6 @@ -//! Per-store stdout/stderr capture: a [`StdoutStream`] that line-buffers -//! the guest's byte stream and routes each complete line as a -//! [`LogRecord`]. Installed in place of `inherit_stdio`, so guest output -//! is tagged with its run and source rather than merged onto host stdio. +//! Per-store stdout/stderr capture: a [`StdoutStream`] line-buffering guest +//! output and routing each line as a [`LogRecord`] tagged with its run and +//! source. use std::io; use std::pin::Pin; @@ -15,15 +14,12 @@ use tracing_core::Level; use super::{LogRecord, LogRouter, LogSource, RunId}; -/// Upper bound on an in-flight line held without a newline. A guest that -/// floods a stream without ever terminating a line cannot grow host -/// memory without limit: the buffer is force-flushed as one record once -/// it crosses this size. +/// Cap on an unterminated in-flight line; crossing it force-flushes the +/// buffer as one record. const MAX_LINE_BYTES: usize = 1 << 20; -/// Per-store stdout or stderr sink handed to `WasiCtxBuilder`. Each call -/// to [`StdoutStream::async_stream`] yields a fresh line-splitting writer -/// bound to the same run and source. +/// Per-store stdout or stderr sink; each [`StdoutStream::async_stream`] yields +/// a line-splitting writer bound to the run and source. pub struct StdioStream { router: Arc, run: RunId, @@ -58,10 +54,8 @@ impl StdoutStream for StdioStream { } } -/// Line-splitting writer: buffers raw bytes and emits one record per -/// newline. Cutting only at `\n` (never a UTF-8 continuation byte) means -/// a multi-byte code point split across writes is always reassembled in -/// the buffer before the line is decoded. +/// Line-splitting writer: one record per newline. Cutting only at `\n` +/// reassembles multi-byte code points split across writes. struct LineWriter { router: Arc, run: RunId, @@ -88,8 +82,8 @@ impl LineWriter { } } - /// Emit any buffered partial line. Idempotent: the buffer is taken, - /// so a shutdown flush and the drop guard never double-emit. + /// Emit any buffered partial line; idempotent, so shutdown and drop never + /// double-emit. fn flush_remainder(&mut self) { if self.buf.is_empty() { return; @@ -99,8 +93,7 @@ impl LineWriter { } } -/// Level a captured line carries: stdout is informational, stderr is a -/// warning. Documented alongside the `[limits.logs]` knobs. +/// Level for a captured line: stdout INFO, stderr WARN. fn level_for(source: LogSource) -> Level { match source { LogSource::Stderr => Level::WARN, @@ -108,8 +101,7 @@ fn level_for(source: LogSource) -> Level { } } -/// Decode one line's bytes and route it, dropping a trailing `\r` (so -/// CRLF output is clean) and skipping empties. +/// Decode and route one line, dropping a trailing `\r` and skipping empties. fn route_line(router: &LogRouter, run: &RunId, source: LogSource, bytes: &[u8]) { let bytes = bytes.strip_suffix(b"\r").unwrap_or(bytes); if bytes.is_empty() { @@ -163,8 +155,7 @@ mod tests { use super::*; use crate::host::logs::{LogPipeline, LogRecord, LogSource, RunId, RunLogStore}; - /// Capturing store that records every appended message so a test can - /// assert the exact line boundaries the writer produced. + /// Store recording every appended message for assertions. #[derive(Default)] struct CaptureStore { records: Mutex>, diff --git a/crates/nexum-runtime/src/host/logs/store.rs b/nexum/crates/nexum-runtime/src/host/logs/store.rs similarity index 93% rename from crates/nexum-runtime/src/host/logs/store.rs rename to nexum/crates/nexum-runtime/src/host/logs/store.rs index e953d01e..a17c72e4 100644 --- a/crates/nexum-runtime/src/host/logs/store.rs +++ b/nexum/crates/nexum-runtime/src/host/logs/store.rs @@ -1,6 +1,5 @@ -//! Retention store for captured log records: the trait the pipeline -//! writes through and reads back, plus the default byte-bounded -//! in-memory backend (one ring per run, a retained-runs cap per module). +//! Retention store for captured log records: the [`RunLogStore`] trait and +//! the default byte-bounded in-memory backend. use std::collections::HashMap; use std::collections::VecDeque; @@ -14,12 +13,10 @@ use crate::engine_config::LogRetentionLimits; /// A page of a run's retained records plus the cursor to resume from. #[derive(Debug, Clone, Default)] pub struct LogPage { - /// Records with sequence at or after the requested cursor, oldest - /// first. May be shorter than the caller expects when older records - /// have been evicted from the ring. + /// Records at or after the cursor, oldest first; may be short after + /// eviction. pub records: Vec, - /// Cursor to pass on the next [`RunLogStore::read`] to continue after - /// the last returned record. + /// Cursor for the next [`RunLogStore::read`]. pub next_cursor: u64, } @@ -40,17 +37,15 @@ pub struct RunMeta { pub last_ts: Option, } -/// Retention backend the [`LogRouter`](super::LogRouter) appends to and -/// the embedding surface reads from. Appends are best-effort and -/// infallible: a full ring evicts rather than erroring. +/// Retention backend for captured records; appends are infallible, a full +/// ring evicts. pub trait RunLogStore: Send + Sync { /// Retain one record, registering its run on first sighting. fn append(&self, record: LogRecord); /// Runs recorded for `module`, oldest retained first. fn list_runs(&self, module: &str) -> Vec; - /// Page a run's retained records from `cursor` (0 for the start). - /// An unknown or evicted run yields an empty page with - /// `next_cursor` 0, silently resetting a poller to the start. + /// Page a run's records from `cursor`; an unknown or evicted run yields an + /// empty page with `next_cursor` 0. fn read(&self, run: &RunId, cursor: u64) -> LogPage; } diff --git a/nexum/crates/nexum-runtime/src/host/mod.rs b/nexum/crates/nexum-runtime/src/host/mod.rs new file mode 100644 index 00000000..a6c06933 --- /dev/null +++ b/nexum/crates/nexum-runtime/src/host/mod.rs @@ -0,0 +1,19 @@ +//! Host-side backends for the `nexum:host` interfaces, plus the per-module +//! [`state::HostState`] and the WIT `Host` trait impls. +//! +//! [`provider_pool`] and [`local_store_redb`] are the capability backends; +//! [`component`] is the backend-trait seam; [`extension`] wires in domain +//! extensions; [`actor`] supervises provider instances; [`http`] gates +//! outgoing wasi:http; [`logs`] is the module-log pipeline; [`error`] projects +//! backend errors into the WIT `chain-error` / `Fault` shapes. + +pub mod actor; +pub mod component; +pub mod error; +pub mod extension; +pub mod http; +mod impls; +pub mod local_store_redb; +pub mod logs; +pub mod provider_pool; +pub mod state; diff --git a/crates/nexum-runtime/src/host/provider_pool.rs b/nexum/crates/nexum-runtime/src/host/provider_pool.rs similarity index 81% rename from crates/nexum-runtime/src/host/provider_pool.rs rename to nexum/crates/nexum-runtime/src/host/provider_pool.rs index 23230d4f..0540c6af 100644 --- a/crates/nexum-runtime/src/host/provider_pool.rs +++ b/nexum/crates/nexum-runtime/src/host/provider_pool.rs @@ -1,16 +1,10 @@ -//! `nexum:host/chain` backend. +//! `nexum:host/chain` backend: per-chain provider opened from the engine +//! config at boot. //! -//! Per-chain alloy provider, opened from the engine config at boot. -//! `request` is a raw JSON-RPC dispatch: the host hands `(method, -//! params)` straight to alloy's transport and returns the result body -//! verbatim. The method is a typed [`ChainMethod`], so only the -//! permitted read surface can reach the transport; params are passed -//! through without re-encoding. -//! -//! Transports: -//! - `ws://` / `wss://` - `WsConnect`; block following pushes `newHeads`. -//! - `http://` / `https://` - alloy's HTTP transport; block following polls -//! `eth_getBlockByNumber`, mirroring the `eth_getLogs` log poller. +//! `request` is a raw JSON-RPC dispatch over a typed [`ChainMethod`], so only +//! the permitted read surface reaches the transport; params pass through +//! unencoded and the result body returns verbatim. WS/WSS push `newHeads`; +//! HTTP polls `eth_getBlockByNumber`. use std::borrow::Cow; use std::collections::HashMap; @@ -34,27 +28,19 @@ use tracing::info; use crate::engine_config::EngineConfig; use crate::host::component::ChainMethod; -/// Fallback head re-poll cadence for chains alloy has no block-time hint -/// for (custom / dev nets). Known chains derive the interval from -/// [`Chain::average_blocktime_hint`] so the block and log pollers track the -/// chain's block time rather than a one-size-fits-all constant: polling much -/// faster than the block time just burns RPC calls on empty ranges, polling -/// much slower adds latency. +/// Head re-poll cadence for chains without a block-time hint; known chains +/// derive it from [`Chain::average_blocktime_hint`]. const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2); -/// Transport retry-layer parameters. `watch_canonical_logs_from` surfaces -/// RPC errors to the caller and ends the stream on the first one unless -/// the transport retries it (per alloy's own guidance on that builder). -/// This layer heals transient blips below the poller, so a momentary node -/// hiccup does not force a re-open - and a re-open is exactly where a gap -/// could reappear. +/// Transport retry-layer parameters; heal transient RPC blips below the +/// poller so a node hiccup does not force a re-open. const RPC_MAX_RETRIES: u32 = 10; const RPC_RETRY_BACKOFF_MS: u64 = 300; -/// Compute-units-per-second budget the retry layer paces rate-limited -/// nodes against; generous because this pool is read-only and low-QPS. +/// Compute-units-per-second budget for rate-limited nodes; generous, this +/// pool is read-only and low-QPS. const RPC_RETRY_CUPS: u64 = 100; -/// The transport retry layer applied to every provider in the pool. +/// Transport retry layer applied to every provider in the pool. fn retry_layer() -> RetryBackoffLayer { RetryBackoffLayer::new(RPC_MAX_RETRIES, RPC_RETRY_BACKOFF_MS, RPC_RETRY_CUPS) } @@ -64,26 +50,21 @@ fn retry_layer() -> RetryBackoffLayer { struct ChainEndpoint { provider: DynProvider, timeout: Duration, - /// WS/IPC transport: `subscribe_blocks` pushes `newHeads`. HTTP has no - /// pubsub, so block following polls `eth_getBlockByNumber` instead. + /// WS/IPC drives block following by pubsub; HTTP polls. supports_pubsub: bool, } -/// Pool of alloy providers keyed by chain. +/// Providers keyed by chain. #[derive(Debug, Clone)] pub struct ProviderPool { providers: Arc>, - /// In-flight `eth_getLogs` request groups the canonical log poller - /// runs while backfilling a gap. Paces catch-up throughput against - /// node load; `0` is clamped to `1` by alloy. + /// In-flight `eth_getLogs` groups during gap backfill; `0` clamps to `1`. log_backfill_concurrency: usize, } impl ProviderPool { - /// Open one provider per chain in `cfg.chains`. WebSocket URLs - /// engage alloy's pubsub transport; HTTP URLs use the HTTP - /// transport. Connection failures propagate to the caller; the - /// engine treats them as fatal at boot. + /// Open one provider per chain in `cfg.chains`; connection failures + /// propagate and are fatal at boot. pub async fn from_config(cfg: &EngineConfig) -> Result { let mut providers: HashMap = HashMap::new(); // Sort by numeric id so the boot logs are deterministic @@ -140,8 +121,7 @@ impl ProviderPool { }) } - /// Empty pool - used by tests. Every `request` call returns - /// `UnknownChain`. + /// Empty pool; every `request` returns `UnknownChain`. #[cfg(test)] pub fn empty() -> Self { Self { @@ -150,9 +130,8 @@ impl ProviderPool { } } - /// Follow new canonical block headers on `chain`. WS pushes them via - /// `eth_subscribe(newHeads)`; HTTP polls `eth_getBlockByNumber` at the - /// chain's block time, yielding the same [`BlockStream`] either way. + /// Follow canonical block headers on `chain`: WS via + /// `eth_subscribe(newHeads)`, HTTP by polling at the chain's block time. pub async fn subscribe_blocks(&self, chain: Chain) -> Result { let ep = self .providers @@ -209,9 +188,7 @@ impl ProviderPool { Ok(Box::pin(stream)) } - /// Current head block number (`eth_blockNumber`). Used as the - /// canonical log poller's `start_block` so a fresh subscription - /// begins at the tip instead of replaying history. + /// Current head block number (`eth_blockNumber`). pub async fn block_number(&self, chain: Chain) -> Result { let ep = self .providers @@ -228,14 +205,9 @@ impl ProviderPool { }) } - /// Open a canonical (reorg-aware) log stream on `chain` from - /// `start_block`. Backed by alloy's `eth_getLogs` block-range poller - /// rather than `eth_subscribe(logs)`, so it works over HTTP as well - /// as WS and recovers events by re-querying the gap rather than - /// silently dropping them across a reconnect. Each yielded item is - /// one canonical block's matching logs (a possibly-empty batch); - /// reorg rollbacks surface as a batch whose logs carry - /// `removed == true`. + /// Canonical (reorg-aware) log stream on `chain` from `start_block`. Each + /// item is one block's matching logs (possibly empty); reorg rollbacks + /// carry `removed == true`. pub fn watch_chain_logs( &self, chain: Chain, @@ -285,10 +257,7 @@ impl ProviderPool { Ok(Box::pin(stream)) } - /// Raw JSON-RPC dispatch. `method` is a permitted read-surface - /// method; `params_json` must be the JSON encoding of the params - /// array (e.g. `"[\"0x...\",\"latest\"]"`), as produced by the - /// SDK's `chain::request` glue. + /// Raw JSON-RPC dispatch; `params_json` is the JSON-encoded params array. pub async fn request( &self, chain: Chain, @@ -353,16 +322,12 @@ impl ProviderPool { /// Boxed stream of `newHeads`-style block headers. pub type BlockStream = Pin> + Send>>; -/// Boxed stream of canonical per-block log batches from -/// [`ProviderPool::watch_chain_logs`]. Each item is one canonical -/// block's matching logs; reorg rollbacks carry `removed == true`. +/// Boxed canonical per-block log stream; reorg rollbacks carry +/// `removed == true`. pub type CanonicalLogStream = Pin, ProviderError>> + Send>>; -/// Errors surfaced by [`ProviderPool`]. -/// -/// `IntoStaticStr` produces the snake_case variant name as -/// `&'static str` for metric labels and structured-log fields; the -/// per-variant Display still carries the detail via `thiserror`. +/// Errors surfaced by [`ProviderPool`]. Variant names serialize snake_case as +/// `&'static str` for metric labels. #[derive(Debug, Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] @@ -388,7 +353,7 @@ pub enum ProviderError { #[source] source: url::ParseError, }, - /// The guest-supplied JSON params did not parse. + /// Guest-supplied JSON params did not parse. #[error("invalid params JSON for `{method}`: {source}")] InvalidParams { /// RPC method name. @@ -397,39 +362,27 @@ pub enum ProviderError { #[source] source: serde_json::Error, }, - /// `request_timeout_secs = 0` in the engine config: every call would - /// time out before it even starts. Rejected at boot. + /// `request_timeout_secs = 0`; rejected at boot. #[error("chain {chain}: request_timeout_secs must not be 0")] ZeroTimeout { /// Chain with the misconfigured timeout. chain: Chain, }, - /// The RPC node did not respond within the configured per-request - /// timeout. Surfaces to the guest as a `timeout` fault; the module - /// decides whether to retry. + /// RPC node did not respond within the per-request timeout. #[error("rpc `{method}` timed out")] Timeout { /// RPC method name. method: String, }, - /// The node returned an error for the dispatched call. - /// - /// When the underlying alloy `RpcError` carries a JSON-RPC - /// `ErrorResp` payload (the normal shape for `eth_call` reverts) - /// the structured `code` and `data` fields are propagated; for - /// transport-side failures both are `None`. + /// Node returned an error for the dispatched call. JSON-RPC `ErrorResp` + /// payloads propagate `code`/`data`; transport failures leave both `None`. #[error("rpc `{method}` failed: {source}")] Rpc { /// RPC method name. method: String, - /// JSON-RPC error code from `ErrorResp.code`. `None` when - /// the failure was transport-level (no structured response). + /// `ErrorResp.code`, `None` for transport-level failures. code: Option, - /// Decoded `ErrorResp.data` payload - for `eth_call` reverts - /// this is the abi-encoded revert body, hex-decoded from the - /// upstream JSON string once here (consumed directly by - /// `shepherd_sdk::cow::decode_revert`). `None` when the failure - /// was transport-level or the payload was not a hex string. + /// Decoded `ErrorResp.data` (abi-encoded revert body), else `None`. data: Option>, /// Transport-side typed error. #[source] diff --git a/crates/nexum-runtime/src/host/state.rs b/nexum/crates/nexum-runtime/src/host/state.rs similarity index 55% rename from crates/nexum-runtime/src/host/state.rs rename to nexum/crates/nexum-runtime/src/host/state.rs index 32723fb8..b6128f6e 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/nexum/crates/nexum-runtime/src/host/state.rs @@ -1,8 +1,5 @@ -//! Per-instance host state and its WASI view. -//! -//! One [`HostState`] is created per module, lives inside the wasmtime -//! `Store`, and is the receiver every `Host` trait impl in -//! `super::impls` is implemented for. +//! Per-module host state, held in the wasmtime `Store` and the receiver for +//! every `Host` impl in `super::impls`. use std::sync::Arc; @@ -11,12 +8,11 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; use wasmtime_wasi_http::WasiHttpCtx; use super::component::{Handle, RuntimeTypes}; +use super::extension::HostServices; use super::http::HttpGate; use super::logs::{LogRouter, RunId}; -/// Per-module host state, generic over the [`RuntimeTypes`] lattice -/// binding the backend seams. The composition root supplies the -/// concrete assembly. +/// Per-module host state, generic over the [`RuntimeTypes`] lattice. pub struct HostState { pub wasi: WasiCtx, pub table: ResourceTable, @@ -27,20 +23,25 @@ pub struct HostState { /// Per-module allowlist gate every wasi:http outgoing request /// passes through. pub http_gate: HttpGate, - /// Identity of this store's run: module namespace plus the restart - /// sequence. Tags every captured log record. The namespace identity - /// for storage is baked into `store`'s prefix. + /// Content topics this store may publish to; empty is unscoped. An + /// out-of-scope publish is refused before the backend. + pub messaging_topics: Vec, + /// Identity of this store's run; tags every captured log record. pub run: RunId, /// Shared log pipeline the `nexum:host/logging` glue routes through. pub log_router: Arc, - /// Extension backends (the lattice `Ext` payload). Reached generically - /// by an extension's `Host` impl through [`ExtState`]. + /// Extension backends (the lattice `Ext` payload), reached via + /// [`ExtState`]. pub ext: T::Ext, - /// `chain` backend - per-chain alloy `DynProvider` pool. + /// `chain` backend: per-chain provider pool. pub chain: T::Chain, - /// `local-store` backend - per-module handle with pre-computed - /// keccak256 namespace prefix. + /// Cap on a chain JSON-RPC response body; larger responses are rejected. + pub chain_response_max_bytes: usize, + /// `local-store` backend: per-module handle with keccak256 prefix. pub store: Handle, + /// Extension-owned host services, keyed by namespace; a provider store + /// carries an empty map. + pub services: HostServices, } // `WasiView: Send`, so the backends must be `Send` too; the lattice @@ -54,13 +55,8 @@ impl WasiView for HostState { } } -/// Generic access to the extension state slot of a host state. -/// -/// An extension crate implements its bindgen-local `Host` trait for the -/// foreign `HostState` (orphan-legal: the trait is local to the -/// extension) and reaches its own payload through this accessor, without -/// naming the concrete lattice `T`. The extension then bounds the payload -/// on its own trait to extract its backend. +/// Generic access to the extension payload of a host state, without naming +/// the concrete lattice `T`. pub trait ExtState { /// The extension payload type (the lattice `Ext` member). type Ext; diff --git a/crates/nexum-runtime/src/lib.rs b/nexum/crates/nexum-runtime/src/lib.rs similarity index 52% rename from crates/nexum-runtime/src/lib.rs rename to nexum/crates/nexum-runtime/src/lib.rs index 1d200b90..22fef9e7 100644 --- a/crates/nexum-runtime/src/lib.rs +++ b/nexum/crates/nexum-runtime/src/lib.rs @@ -1,6 +1,9 @@ -//! Nexum runtime: a wasmtime-based host for WASM Component Model -//! modules, usable as an embeddable library. The bundled binary is a -//! thin consumer of the same public surface. +//! Wasmtime-based host for WASM Component Model modules, embeddable as a +//! library; the bundled binary is a thin consumer of the same surface. +//! +//! Settlement-domain-agnostic: no domain symbol or WIT reference, `nexum:host` +//! stays a leaf WIT package, no crate edge reaches a domain crate. Enforced in +//! CI by the zero-leak script under `scripts/`. #![cfg_attr(not(test), warn(unused_crate_dependencies))] @@ -12,6 +15,14 @@ use alloy_rpc_client as _; use alloy_transport as _; use alloy_transport_ws as _; +/// Sealing markers for [`preset::Runtime`] and +/// [`host::component::RuntimeTypes`]: implement alongside the trait. +#[doc(hidden)] +pub mod sealed { + pub trait SealedRuntimeTypes {} + pub trait SealedRuntime {} +} + pub mod addons; pub mod bindings; pub mod bootstrap; diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/nexum/crates/nexum-runtime/src/manifest/capabilities.rs similarity index 71% rename from crates/nexum-runtime/src/manifest/capabilities.rs rename to nexum/crates/nexum-runtime/src/manifest/capabilities.rs index a47b3f6a..ccea7c18 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/nexum/crates/nexum-runtime/src/manifest/capabilities.rs @@ -1,24 +1,19 @@ -//! Capability enforcement: cross-checks the component's WIT imports -//! against the `[capabilities]` block declared in `module.toml`. +//! Capability enforcement: cross-checks a component's WIT imports against +//! its `[capabilities]` declarations. //! -//! The set of recognised capabilities is not fixed: the core namespace is -//! built in, and each runtime extension contributes its own namespace at -//! the composition root via [`CapabilityRegistry::register`]. An extension -//! interface is enforceable only once its namespace is registered. -//! -//! The WASI surface is gated the same way: io/clocks/random and all of -//! `wasi:cli` are ambient, `wasi:sockets` and `wasi:filesystem` are opt-in -//! via the `wasi-*` capabilities, and any other `wasi:` interface is -//! refused fail-closed. +//! The core `nexum:host` namespace is built in; each extension registers +//! its own via [`CapabilityRegistry::register`]. `wasi:http` is gated by +//! the `http` capability, `wasi:sockets` and `wasi:filesystem` by `wasi-*`; +//! io/clocks/random and `wasi:cli` are ambient; any other `wasi:` interface +//! is refused fail-closed. use std::collections::HashSet; use super::error::{CapabilityError, CapabilityViolation}; use super::types::{CORE_CAPABILITIES, LoadedManifest}; -/// One WIT namespace prefix plus the interface names under it that count as -/// capabilities. Core registers `nexum:host/`; an extension registers its -/// own (e.g. `shepherd:cow/`). +/// A WIT namespace prefix plus the interface names under it that are +/// capabilities. #[derive(Clone, Copy)] pub struct NamespaceCaps { /// Interface-name prefix, e.g. `"nexum:host/"`. @@ -33,19 +28,31 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: CORE_CAPABILITIES, }; -/// Import prefix of the wasi:http package. Every interface under it -/// (outgoing-handler, types, ...) is gated by the single -/// [`HTTP_CAPABILITY`] declaration. +/// Interfaces a provider world links: the scoped transport only. `http` is +/// gated by the registry, as in the core set. +pub const PROVIDER_CAPABILITIES: &[&str] = &[ + nexum_world::Cap::Chain.as_str(), + nexum_world::Cap::Messaging.as_str(), +]; + +/// The provider namespace: `nexum:host/` scoped to the transport +/// interfaces, so a provider declaring a core-only interface (e.g. +/// `local-store`) is rejected as unknown. +pub const PROVIDER_NAMESPACE: NamespaceCaps = NamespaceCaps { + prefix: "nexum:host/", + ifaces: PROVIDER_CAPABILITIES, +}; + +/// Import prefix of the `wasi:http` package; every interface under it is +/// gated by [`HTTP_CAPABILITY`]. const WASI_HTTP_PREFIX: &str = "wasi:http/"; /// Capability name a module declares to import any `wasi:http/*` /// interface; the per-module `[capabilities.http].allow` list scopes it. -const HTTP_CAPABILITY: &str = "http"; +const HTTP_CAPABILITY: &str = nexum_world::Cap::Http.as_str(); -/// Gated WASI capability names. Declaring one grants the matching `wasi:` -/// interface group; see [`classify_wasi`]. `wasi:io`, `wasi:clocks`, -/// `wasi:random` and all of `wasi:cli` (environment included; the host -/// populates it empty) are ambient and need no declaration. +/// Gated WASI capability names; declaring one grants the matching `wasi:` +/// interface group. See [`classify_wasi`]. const WASI_CAPABILITIES: &[&str] = &["wasi-sockets", "wasi-filesystem"]; /// A `wasi:` import (other than `wasi:http`) classified against the gate. @@ -77,8 +84,8 @@ fn classify_wasi(import_name: &str) -> WasiGate { } } -/// Registry of capability namespaces recognised by enforcement. Built from -/// the core namespace plus every registered extension. +/// Capability namespaces recognised by enforcement: the core namespace plus +/// every registered extension. #[derive(Clone)] pub struct CapabilityRegistry { namespaces: Vec, @@ -91,20 +98,28 @@ impl Default for CapabilityRegistry { } impl CapabilityRegistry { - /// The registry with only the core namespace. + /// The registry with the core `nexum:host/` namespace. pub fn core() -> Self { Self { namespaces: vec![CORE_NAMESPACE], } } + /// The registry a provider validates against: the scoped transport plus + /// `http`. A provider manifest declaring a core-only capability (e.g. + /// `local-store`) fails as unknown. + pub fn provider() -> Self { + Self { + namespaces: vec![PROVIDER_NAMESPACE], + } + } + /// Add an extension's namespace. pub fn register(&mut self, ns: NamespaceCaps) { self.namespaces.push(ns); } /// Whether `name` is a capability under any registered namespace. - /// Used to validate declared capability names in a manifest. pub fn is_known(&self, name: &str) -> bool { name == HTTP_CAPABILITY || WASI_CAPABILITIES.contains(&name) @@ -122,22 +137,10 @@ impl CapabilityRegistry { .join(", ") } - /// Map a WIT import name to a capability name, or `None` for - /// non-capability imports. - /// - /// Returns `Some(iface)` only for interfaces under a registered - /// namespace, plus `Some("http")` for anything under `wasi:http/`; - /// type-only packages like `nexum:host/types` and the remaining - /// `wasi:*` namespaces fall through to `None` so they do not need a - /// manifest declaration. - /// - /// Examples: - /// - `"nexum:host/chain@0.2.0"` -> `Some("chain")` - /// - `"shepherd:cow/cow-api@0.2.0"` -> `Some("cow-api")` once the cow - /// namespace is registered - /// - `"wasi:http/outgoing-handler@0.2.12"` -> `Some("http")` - /// - `"nexum:host/types@0.2.0"` -> `None` (type-only, not a capability) - /// - `"wasi:io/streams@0.2.0"` -> `None` + /// Map a WIT import name to a capability name. `Some(iface)` for an + /// interface under a registered namespace, `Some("http")` for anything + /// under `wasi:http/`, and `None` for a non-capability import (type-only + /// packages, ungated `wasi:*`). pub fn wit_import_to_cap<'a>(&self, import_name: &'a str) -> Option<&'a str> { let without_version = import_name.split('@').next().unwrap_or(import_name); if without_version.starts_with(WASI_HTTP_PREFIX) { @@ -154,16 +157,11 @@ impl CapabilityRegistry { } } -/// Check that every capability-bearing WIT import of the component is covered -/// by the module's manifest declarations. Call after loading the component, -/// before instantiation. -/// -/// The WASI surface is gated fail-closed. With `[capabilities]` absent -/// (0.1-fallback) the registry surface stays permissive and load warns. -/// -/// `component_imports` is the name part of each import from -/// `component.component_type().imports(&engine)`. `registry` carries the -/// core namespace plus any extension namespaces. +/// Check that every capability-bearing WIT import is covered by the +/// manifest's declarations; call after loading the component, before +/// instantiation. The WASI surface is gated fail-closed even in the +/// 0.1-fallback (no `[capabilities]`), where the registry surface stays +/// permissive. `component_imports` are the import name parts. pub fn enforce_capabilities<'a>( loaded: &LoadedManifest, component_imports: impl Iterator, @@ -222,13 +220,13 @@ mod tests { use super::*; use crate::manifest::types::{CapabilitiesSection, Manifest}; - /// A registry with the cow extension namespace registered, mirroring - /// what the composition root assembles. - fn registry_with_cow() -> CapabilityRegistry { + /// A registry with one extension namespace registered, mirroring + /// what a composition root assembles. + fn registry_with_ext() -> CapabilityRegistry { let mut r = CapabilityRegistry::core(); r.register(NamespaceCaps { - prefix: "shepherd:cow/", - ifaces: &["cow-api"], + prefix: "test:acme/", + ifaces: &["acme-api"], }); r } @@ -236,9 +234,9 @@ mod tests { #[test] fn wit_import_to_cap_nexum_host() { let r = CapabilityRegistry::core(); - assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.2.0"), Some("chain")); + assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.1.0"), Some("chain")); assert_eq!( - r.wit_import_to_cap("nexum:host/local-store@0.2.0"), + r.wit_import_to_cap("nexum:host/local-store@0.1.0"), Some("local-store") ); } @@ -267,21 +265,21 @@ mod tests { } #[test] - fn wit_import_to_cap_shepherd_cow_needs_registration() { - // Core registry does not recognise the cow namespace. + fn wit_import_to_cap_extension_needs_registration() { + // Core registry does not recognise an extension namespace. let core = CapabilityRegistry::core(); - assert_eq!(core.wit_import_to_cap("shepherd:cow/cow-api@0.2.0"), None); + assert_eq!(core.wit_import_to_cap("test:acme/acme-api@0.1.0"), None); // Once registered, it resolves. - let r = registry_with_cow(); + let r = registry_with_ext(); assert_eq!( - r.wit_import_to_cap("shepherd:cow/cow-api@0.2.0"), - Some("cow-api") + r.wit_import_to_cap("test:acme/acme-api@0.1.0"), + Some("acme-api") ); } #[test] fn wit_import_to_cap_non_http_wasi_is_none() { - let r = registry_with_cow(); + let r = registry_with_ext(); assert_eq!(r.wit_import_to_cap("wasi:io/streams@0.2.0"), None); assert_eq!(r.wit_import_to_cap("wasi:cli/stdin@0.2.0"), None); assert_eq!(r.wit_import_to_cap("wasi:sockets/tcp@0.2.0"), None); @@ -314,21 +312,21 @@ mod tests { fn enforce_passes_when_caps_absent() { // 0.1-fallback: no capabilities section -> all imports allowed let loaded = manifest_no_caps(); - let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; - let r = registry_with_cow(); + let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] fn enforce_passes_when_all_imports_declared() { - let loaded = manifest_with_caps(&["chain", "cow-api"], &["http"]); + let loaded = manifest_with_caps(&["chain", "acme-api"], &["http"]); let imports = [ - "nexum:host/chain@0.2.0", - "shepherd:cow/cow-api@0.2.0", + "nexum:host/chain@0.1.0", + "test:acme/acme-api@0.1.0", "wasi:http/outgoing-handler@0.2.12", "wasi:io/streams@0.2.0", // non-http wasi is always skipped ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } @@ -336,10 +334,10 @@ mod tests { fn enforce_rejects_wasi_http_import_without_declaration() { let loaded = manifest_with_caps(&["chain"], &[]); let imports = [ - "nexum:host/chain@0.2.0", + "nexum:host/chain@0.1.0", "wasi:http/outgoing-handler@0.2.12", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); let err = enforce_capabilities(&loaded, imports.into_iter(), &r).unwrap_err(); let CapabilityError::Undeclared(v) = err else { panic!("expected undeclared: {err:?}") @@ -357,7 +355,7 @@ mod tests { "wasi:http/outgoing-handler@0.2.12", "wasi:http/types@0.2.12", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } } @@ -366,8 +364,8 @@ mod tests { fn enforce_rejects_undeclared_import() { let loaded = manifest_with_caps(&["chain"], &[]); // module imports remote-store but didn't declare it - let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; - let r = registry_with_cow(); + let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; + let r = registry_with_ext(); let err = enforce_capabilities(&loaded, imports.into_iter(), &r).unwrap_err(); let CapabilityError::Undeclared(v) = err else { panic!("expected undeclared: {err:?}") @@ -378,11 +376,51 @@ mod tests { #[test] fn enforce_optional_caps_are_also_allowed() { let loaded = manifest_with_caps(&["chain"], &["remote-store"]); - let imports = ["nexum:host/chain@0.2.0", "nexum:host/remote-store@0.2.0"]; - let r = registry_with_cow(); + let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } + #[test] + fn provider_registry_knows_only_scoped_transport() { + // The scoped transport plus http are known; the core-only + // interfaces a provider must not reach are not, so a manifest + // declaring them fails validation as unknown. + let r = CapabilityRegistry::provider(); + assert!(r.is_known("chain")); + assert!(r.is_known("messaging")); + assert!(r.is_known("http")); + assert!(!r.is_known("local-store")); + assert!(!r.is_known("remote-store")); + assert!(!r.is_known("identity")); + assert!(!r.is_known("logging")); + } + + #[test] + fn provider_registry_maps_transport_imports_but_not_core_only() { + let r = CapabilityRegistry::provider(); + assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.1.0"), Some("chain")); + assert_eq!( + r.wit_import_to_cap("nexum:host/messaging@0.1.0"), + Some("messaging") + ); + assert_eq!( + r.wit_import_to_cap("wasi:http/outgoing-handler@0.2.12"), + Some("http") + ); + // A core-only interface is not a recognised provider capability. + assert_eq!(r.wit_import_to_cap("nexum:host/local-store@0.1.0"), None); + } + + #[test] + fn provider_manifest_declaring_a_core_only_cap_is_unknown() { + // The load path validates declared names against the registry; an + // provider declaring `local-store` must surface as unknown. + let r = CapabilityRegistry::provider(); + assert!(!r.is_known("local-store")); + assert!(r.known_names().split(", ").all(|n| n != "local-store")); + } + #[test] fn ambient_wasi_needs_no_declaration() { let loaded = manifest_with_caps(&["logging"], &[]); @@ -399,14 +437,14 @@ mod tests { "wasi:cli/terminal-stdout@0.2.6", "wasi:cli/environment@0.2.6", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] fn undeclared_gated_wasi_is_refused() { let loaded = manifest_with_caps(&["logging"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); for (import, cap) in [ ("wasi:sockets/tcp@0.2.6", "wasi-sockets"), ("wasi:filesystem/types@0.2.6", "wasi-filesystem"), @@ -429,14 +467,14 @@ mod tests { "wasi:filesystem/types@0.2.6", "wasi:filesystem/preopens@0.2.6", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] fn declaring_one_gated_cap_does_not_grant_another() { let loaded = manifest_with_caps(&["wasi-filesystem"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); assert!( enforce_capabilities(&loaded, ["wasi:filesystem/types@0.2.6"].into_iter(), &r).is_ok() ); @@ -448,7 +486,7 @@ mod tests { // Even with an unrelated gated cap declared, an unrecognised wasi: // namespace is denied outright. let loaded = manifest_with_caps(&["wasi-sockets"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); let err = enforce_capabilities(&loaded, ["wasi:nn/tensor@0.2.0"].into_iter(), &r).unwrap_err(); assert!(matches!(err, CapabilityError::UnknownWasi { .. })); @@ -458,7 +496,7 @@ mod tests { fn wasi_gate_ignores_version_suffix() { let declared = manifest_with_caps(&["wasi-sockets"], &[]); let none = manifest_with_caps(&["logging"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&declared, ["wasi:sockets/tcp"].into_iter(), &r).is_ok()); assert!( enforce_capabilities(&declared, ["wasi:sockets/tcp@0.2.6"].into_iter(), &r).is_ok() @@ -471,9 +509,9 @@ mod tests { // No [capabilities] section -> 0.1-fallback: registry imports pass, // but the WASI surface is still gated fail-closed. let loaded = manifest_no_caps(); - let r = registry_with_cow(); + let r = registry_with_ext(); assert!( - enforce_capabilities(&loaded, ["nexum:host/remote-store@0.2.0"].into_iter(), &r) + enforce_capabilities(&loaded, ["nexum:host/remote-store@0.1.0"].into_iter(), &r) .is_ok() ); assert!(enforce_capabilities(&loaded, ["wasi:io/streams@0.2.6"].into_iter(), &r).is_ok()); @@ -486,7 +524,7 @@ mod tests { #[test] fn wasi_capability_names_are_known() { - let r = registry_with_cow(); + let r = registry_with_ext(); for cap in ["wasi-sockets", "wasi-filesystem"] { assert!(r.is_known(cap), "{cap} missing from known set"); assert!(r.known_names().split(", ").any(|n| n == cap)); diff --git a/crates/nexum-runtime/src/manifest/error.rs b/nexum/crates/nexum-runtime/src/manifest/error.rs similarity index 64% rename from crates/nexum-runtime/src/manifest/error.rs rename to nexum/crates/nexum-runtime/src/manifest/error.rs index 73e1ac4f..e961c8eb 100644 --- a/crates/nexum-runtime/src/manifest/error.rs +++ b/nexum/crates/nexum-runtime/src/manifest/error.rs @@ -3,11 +3,7 @@ use strum::IntoStaticStr; use thiserror::Error; -/// Errors returned while loading or validating a manifest. -/// -/// `IntoStaticStr` exposes the snake_case variant name as a -/// `&'static str` for the manifest-loader's `tracing::warn!` / -/// `metrics::counter!` call sites. +/// Errors from loading or validating a manifest. #[derive(Debug, Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] @@ -18,18 +14,16 @@ pub enum ParseError { /// Manifest file was not valid TOML. #[error("manifest: parse: {0}")] Toml(#[from] toml::de::Error), - /// `[capabilities].required` or `.optional` listed a capability - /// the engine does not recognise. `known` is the comma-joined set of - /// core plus registered-extension capabilities at validation time. + /// A declared capability the engine does not recognise. #[error("manifest: unknown capability {name:?} in [capabilities] (known: {known})")] UnknownCapability { - /// The unrecognised capability name. + /// The unrecognised name. name: String, /// Comma-joined recognised capability names. known: String, }, - /// `[module].name` is not a single safe path component; it must not - /// contain `/`, `\`, or `..` so it cannot escape the state directory. + /// `[module].name` contains `/`, `\`, or `..`, so it could escape the + /// state directory. #[error("manifest: [module].name {0:?} must not contain '/', '\\', or '..'")] InvalidModuleName(String), } @@ -41,16 +35,15 @@ pub enum ParseError { [capabilities].required or [capabilities].optional" )] pub struct CapabilityViolation { - /// Capability name (e.g. `"remote-store"`). + /// Capability name. pub capability: String, - /// Full WIT import name as it appeared in the component (e.g. - /// `"nexum:host/remote-store@0.2.0"`). + /// Full WIT import name. pub wit_import: String, } -/// Error returned when a component's WIT imports exceed its declared -/// capabilities. +/// A component's WIT imports exceed its declared capabilities. #[derive(Debug, Error)] +#[non_exhaustive] pub enum CapabilityError { /// A gated import was not declared in `[capabilities]`. #[error(transparent)] diff --git a/crates/nexum-runtime/src/manifest/load.rs b/nexum/crates/nexum-runtime/src/manifest/load.rs similarity index 64% rename from crates/nexum-runtime/src/manifest/load.rs rename to nexum/crates/nexum-runtime/src/manifest/load.rs index af9f148c..3f26ee68 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/nexum/crates/nexum-runtime/src/manifest/load.rs @@ -1,9 +1,5 @@ -//! Parse `module.toml` from disk, validate, and emit operator-visible -//! warnings. -//! -//! Also exposes the host-matching helper the wasi:http gate uses to -//! enforce the manifest's `[capabilities.http].allow` list at request -//! time. +//! Parse and validate `module.toml`, plus the host-matching helper the +//! wasi:http gate uses to enforce `[capabilities.http].allow`. use std::path::Path; @@ -13,10 +9,9 @@ use super::capabilities::CapabilityRegistry; use super::error::ParseError; use super::types::{LoadedManifest, Manifest}; -/// Read `module.toml` from `path`, parse, validate, and emit a deprecation -/// warning if `[capabilities]` is absent (0.1-compat fallback). Declared -/// capability names are validated against `registry`, so extension -/// capabilities are recognised only once their namespace is registered. +/// Read, parse, and validate `module.toml`; declared capability names are +/// checked against `registry`. Warns if `[capabilities]` is absent +/// (0.1-compat fallback). pub fn load(path: &Path, registry: &CapabilityRegistry) -> Result { let raw = std::fs::read_to_string(path)?; let manifest: Manifest = toml::from_str(&raw)?; @@ -75,8 +70,7 @@ pub fn load(path: &Path, registry: &CapabilityRegistry) -> Result LoadedManifest { warn!( target: "manifest", @@ -91,9 +85,9 @@ pub fn fallback_manifest() -> LoadedManifest { } } -/// Reject a `[module].name` that is not a single safe path component, so a -/// hostile name cannot escape the state directory wherever it is used as one. -/// An empty name is allowed; the runtime falls back to `module`. +/// Reject a `[module].name` that is not a single safe path component, so it +/// cannot escape the state directory. An empty name is allowed (the runtime +/// falls back to `module`). fn validate_module_name(name: &str) -> Result<(), ParseError> { if name.contains('/') || name.contains('\\') || name.contains("..") { return Err(ParseError::InvalidModuleName(name.to_owned())); @@ -101,11 +95,10 @@ fn validate_module_name(name: &str) -> Result<(), ParseError> { Ok(()) } -/// Check whether `host` matches any pattern in the allowlist. Patterns are -/// either exact (`api.example.com`) or `*.suffix` wildcards which match -/// any subdomain of `suffix` (but not `suffix` itself). Matching is -/// case-insensitive and host-only: no scheme, no port, and IPv6 literals -/// keep their brackets. +/// Whether `host` matches any allowlist pattern: exact, or a `*.suffix` +/// wildcard matching any subdomain of `suffix` but not `suffix` itself. +/// Case-insensitive and host-only (no scheme or port; IPv6 literals keep +/// their brackets). pub fn host_allowed(host: &str, allowlist: &[String]) -> bool { let host = host.to_ascii_lowercase(); allowlist.iter().any(|pat| { @@ -172,30 +165,102 @@ event_signature = "0x00000000000000000000000000000000000000000000000000000000dea } #[test] - fn load_rejects_the_retired_log_kind() { + fn load_parses_the_retired_log_kind_as_an_extension_kind() { // The chain-event kind is `chain-log`; a stale `kind = "log"` - // fails to parse with an unknown-variant error naming the valid - // set so a not-yet-migrated manifest surfaces clearly at load. + // parses as an extension kind and boot refuses it against the + // extension vocabulary, so a not-yet-migrated manifest still + // surfaces clearly rather than silently dropping events. let toml = r#" [module] name = "stale" [[subscription]] kind = "log" +chain_id = "1" +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert!(matches!( + &manifest.subscriptions[0], + Subscription::Extension { kind, .. } if kind == "log" + )); + } + + #[test] + fn load_parses_extension_subscriptions_with_string_filters() { + let toml = r#" +[module] +name = "watcher" + +[[subscription]] +kind = "acme-status" + +[[subscription]] +kind = "acme-status" +scope = "primary" +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert!(matches!( + &manifest.subscriptions[0], + Subscription::Extension { kind, filters } if kind == "acme-status" && filters.is_empty() + )); + assert!(matches!( + &manifest.subscriptions[1], + Subscription::Extension { kind, filters } + if kind == "acme-status" && filters.get("scope").is_some_and(|v| v == "primary") + )); + } + + /// A non-string filter value on an extension kind is refused at parse. + #[test] + fn load_rejects_a_non_string_extension_filter() { + let toml = r#" +[module] +name = "watcher" + +[[subscription]] +kind = "acme-status" +scope = 7 +"#; + let err = toml::from_str::(toml).expect_err("non-string filter"); + assert!(err.to_string().contains("must be a string"), "{err}"); + } + + /// A non-core top-level section parses into the opaque extension map. + #[test] + fn load_parses_extension_sections_opaquely() { + let toml = r#" +[module] +name = "keeper" + +[venue] +body_version = 2 + +[[subscription]] +kind = "block" chain_id = 1 "#; - let err = toml::from_str::(toml).expect_err("stale kind rejected"); - let msg = err.to_string(); - assert!( - msg.contains("chain-log"), - "error names the valid set: {msg}" - ); - assert!( - !msg.contains("unknown field"), - "kind is the discriminator: {msg}" + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert_eq!(manifest.module.name, "keeper"); + assert_eq!(manifest.subscriptions.len(), 1); + assert_eq!(manifest.extensions.len(), 1); + let venue = manifest.extensions.get("venue").expect("venue section"); + assert_eq!( + venue.get("body_version").and_then(toml::Value::as_integer), + Some(2), ); } + /// A manifest without extension sections carries an empty map. + #[test] + fn load_defaults_to_no_extension_sections() { + let toml = r#" +[module] +name = "plain" +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert!(manifest.extensions.is_empty()); + } + #[test] fn load_parses_cron_subscription() { let toml = r#" @@ -270,6 +335,54 @@ enabled = true assert_eq!(config.get("enabled").map(String::as_str), Some("true")); } + #[test] + fn component_kind_defaults_to_the_worker() { + use crate::manifest::types::ComponentKind; + let manifest: Manifest = toml::from_str( + r#" +[module] +name = "plain" +"#, + ) + .expect("parse"); + assert_eq!(manifest.module.kind, ComponentKind::Worker); + } + + #[test] + fn component_kind_carries_a_provider_spelling() { + use crate::manifest::types::ComponentKind; + let manifest: Manifest = toml::from_str( + r#" +[module] +name = "acme" +kind = "acme-provider" +"#, + ) + .expect("parse"); + assert_eq!( + manifest.module.kind, + ComponentKind::Provider("acme-provider".to_owned()), + ); + } + + /// An unknown spelling parses as a provider kind for boot to refuse. + #[test] + fn component_kind_keeps_an_unregistered_spelling_for_boot_to_refuse() { + use crate::manifest::types::ComponentKind; + let manifest: Manifest = toml::from_str( + r#" +[module] +name = "bad" +kind = "gadget" +"#, + ) + .expect("parse"); + assert_eq!( + manifest.module.kind, + ComponentKind::Provider("gadget".to_owned()), + ); + } + #[test] fn resources_section_parses() { let toml = r#" @@ -322,9 +435,9 @@ max_state_bytes = 52428800 #[test] fn host_allowed_exact_and_wildcard() { - let allow = vec!["api.cow.fi".to_string(), "*.discord.com".to_string()]; - assert!(host_allowed("api.cow.fi", &allow)); - assert!(!host_allowed("evil.api.cow.fi", &allow)); + let allow = vec!["api.acme.example".to_string(), "*.discord.com".to_string()]; + assert!(host_allowed("api.acme.example", &allow)); + assert!(!host_allowed("evil.api.acme.example", &allow)); assert!(host_allowed("foo.discord.com", &allow)); assert!(host_allowed("a.b.discord.com", &allow)); assert!(!host_allowed("discord.com", &allow)); @@ -333,17 +446,20 @@ max_state_bytes = 52428800 #[test] fn host_allowed_is_case_insensitive_both_ways() { - let upper = vec!["API.COW.FI".to_string()]; - let lower = vec!["api.cow.fi".to_string()]; - assert!(host_allowed("api.cow.fi", &upper)); - assert!(host_allowed("Api.Cow.Fi", &lower)); + let upper = vec!["API.ACME.EXAMPLE".to_string()]; + let lower = vec!["api.acme.example".to_string()]; + assert!(host_allowed("api.acme.example", &upper)); + assert!(host_allowed("Api.Acme.Example", &lower)); } #[test] fn host_allowed_matches_hosts_not_authorities() { // Entries are bare hosts; a port or userinfo in a pattern can // never match a host string. - let allow = vec!["api.cow.fi:8443".to_string(), "u@api.cow.fi".to_string()]; - assert!(!host_allowed("api.cow.fi", &allow)); + let allow = vec![ + "api.acme.example:8443".to_string(), + "u@api.acme.example".to_string(), + ]; + assert!(!host_allowed("api.acme.example", &allow)); } } diff --git a/nexum/crates/nexum-runtime/src/manifest/mod.rs b/nexum/crates/nexum-runtime/src/manifest/mod.rs new file mode 100644 index 00000000..7a76828f --- /dev/null +++ b/nexum/crates/nexum-runtime/src/manifest/mod.rs @@ -0,0 +1,22 @@ +//! `module.toml` parser and capability enforcement. +//! +//! `load` parses and validates a manifest; `capabilities` cross-checks a +//! component's WIT imports against its declared `[capabilities]`; `types` +//! holds the serde shapes and `LoadedManifest`; `error` the error types. +//! A manifest with no `[capabilities]` section falls back to all-required, +//! with a deprecation warning. + +mod capabilities; +mod error; +mod load; +mod types; + +pub(crate) use capabilities::enforce_capabilities; +pub use capabilities::{CapabilityRegistry, NamespaceCaps}; +pub(crate) use load::{fallback_manifest, host_allowed, load}; +pub use types::ExtensionSections; +pub(crate) use types::{ComponentKind, LoadedManifest, ResourceSection, Subscription}; +// CapabilityViolation, ParseError, and the *Section structs are +// reachable through these functions' return / argument types; +// consumers that need to name them directly do so via +// `crate::manifest::error::*` or `::types::*`. diff --git a/nexum/crates/nexum-runtime/src/manifest/types.rs b/nexum/crates/nexum-runtime/src/manifest/types.rs new file mode 100644 index 00000000..ab5306a0 --- /dev/null +++ b/nexum/crates/nexum-runtime/src/manifest/types.rs @@ -0,0 +1,258 @@ +//! Serde shapes: `Manifest`, its sections, and `LoadedManifest`. + +use std::collections::BTreeMap; +use std::fmt; + +use serde::Deserialize; +use serde::de::Error as _; + +/// Core capability names: the `nexum:host` interfaces linked into every +/// module. `http` is gated separately (it gates `wasi:http/*`), and +/// extensions register their own namespaces. +pub const CORE_CAPABILITIES: &[&str] = &nexum_world::CORE_IFACES; + +#[derive(Debug, Deserialize, Default)] +pub struct Manifest { + #[serde(default)] + pub module: ModuleSection, + #[serde(default)] + pub capabilities: Option, + #[serde(default)] + pub config: toml::Table, + /// Event subscriptions wired before `_init`. `block` and `chain-log` + /// are dispatched; `cron` is parsed and ignored. + #[serde(default, rename = "subscription")] + pub subscriptions: Vec, + /// Extension-owned sections (every non-core top-level key), parsed + /// opaquely and routed to the wired extensions; a section no extension + /// claims is refused at boot. + #[serde(flatten)] + pub extensions: ExtensionSections, +} + +/// Extension-owned manifest sections, keyed by top-level name. Opaque +/// to the runtime; each claiming extension parses its own. +pub type ExtensionSections = BTreeMap; + +/// One `[[subscription]]` table. The `kind` field discriminates; an +/// unknown kind parses as [`Subscription::Extension`] and is validated at +/// boot against the wired extensions' declared kinds. +#[derive(Debug, Clone)] +pub enum Subscription { + /// New-block events; one subscription per chain id, fanned out to every + /// module watching that chain. + Block { + /// EVM chain id. + chain_id: u64, + }, + /// Chain-log events matching `address` + topic-0; one subscription per + /// entry, tagged with the owning module. + ChainLog { + /// EVM chain id. + chain_id: u64, + /// Contract address as `0x`-prefixed 20-byte hex. Optional. + address: Option, + /// Topic-0 filter as `0x`-prefixed 32-byte hex; absent matches + /// every event from the address(es). + event_signature: Option, + /// Persist a durable per-subscription cursor and re-open from just + /// after the last dispatched block instead of head. Delivery is + /// then at-least-once; the module must tolerate redelivery. + resume: bool, + /// Backfill cap for a `resume` subscription, in blocks. `None` + /// backfills the whole gap; set it only for a consumer that + /// tolerates dropping the oldest missed blocks. + max_lookback: Option, + }, + /// Cron-scheduled tick; parsed but not dispatched (the supervisor + /// warns). + Cron { + /// Standard 5-field cron expression. + #[allow(dead_code)] + schedule: String, + }, + /// An extension-owned event kind. Delivered when the kind matches and + /// every filter pair is present in the event's attributes. + Extension { + /// The extension-declared subscription kind. + kind: String, + /// Attribute filters; empty admits every event of the kind. + filters: BTreeMap, + }, +} + +/// Core subscription kinds parsed by shape; others fall through to +/// [`Subscription::Extension`]. +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +enum CoreSubscription { + Block { + chain_id: u64, + }, + #[serde(rename = "chain-log")] + ChainLog { + chain_id: u64, + #[serde(default)] + address: Option, + #[serde(default)] + event_signature: Option, + #[serde(default)] + resume: bool, + #[serde(default)] + max_lookback: Option, + }, + Cron { + schedule: String, + }, +} + +impl From for Subscription { + fn from(sub: CoreSubscription) -> Self { + match sub { + CoreSubscription::Block { chain_id } => Self::Block { chain_id }, + CoreSubscription::ChainLog { + chain_id, + address, + event_signature, + resume, + max_lookback, + } => Self::ChainLog { + chain_id, + address, + event_signature, + resume, + max_lookback, + }, + CoreSubscription::Cron { schedule } => Self::Cron { schedule }, + } + } +} + +impl<'de> Deserialize<'de> for Subscription { + fn deserialize>(deserializer: D) -> Result { + let table = toml::Table::deserialize(deserializer)?; + let Some(kind) = table.get("kind").and_then(toml::Value::as_str) else { + return Err(D::Error::missing_field("kind")); + }; + match kind { + "block" | "chain-log" | "cron" => toml::Value::Table(table.clone()) + .try_into::() + .map(Into::into) + .map_err(D::Error::custom), + _ => { + let kind = kind.to_owned(); + let mut filters = BTreeMap::new(); + for (key, value) in table { + if key == "kind" { + continue; + } + let Some(value) = value.as_str() else { + return Err(D::Error::custom(format!( + "subscription filter `{key}` must be a string" + ))); + }; + filters.insert(key, value.to_owned()); + } + Ok(Self::Extension { kind, filters }) + } + } + } +} + +#[derive(Debug, Deserialize, Default)] +#[allow(dead_code)] // version + component parsed for future 0.3 hash-verification. +pub struct ModuleSection { + #[serde(default)] + pub name: String, + #[serde(default)] + pub version: String, + #[serde(default)] + pub component: String, + /// Component kind; defaults to the worker (`event-module`), a provider + /// names its registered kind. + #[serde(default)] + pub kind: ComponentKind, + /// Per-module resource overrides; each unset field inherits the engine + /// `[limits]` default. + #[serde(default)] + pub resources: ResourceSection, +} + +/// The worker kind's manifest spelling. +pub const WORKER_KIND: &str = "event-module"; + +/// Component kind a manifest declares: the worker, or a provider spelling +/// an extension registers. Defaults to the worker; an unregistered spelling +/// is refused at boot. +#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)] +#[serde(from = "String")] +pub enum ComponentKind { + /// Event-driven worker (`event-module`). + #[default] + Worker, + /// A provider, named by its manifest spelling. + Provider(String), +} + +impl From for ComponentKind { + fn from(kind: String) -> Self { + if kind == WORKER_KIND { + Self::Worker + } else { + Self::Provider(kind) + } + } +} + +impl fmt::Display for ComponentKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Worker => f.write_str(WORKER_KIND), + Self::Provider(kind) => f.write_str(kind), + } + } +} +/// `[module.resources]` overrides; each unset field keeps the engine +/// `[limits]` default. +#[derive(Debug, Deserialize, Default)] +pub struct ResourceSection { + /// Linear-memory cap, in bytes. + #[serde(default)] + pub max_memory_bytes: Option, + /// Fuel granted per event dispatch. + #[serde(default)] + pub max_fuel_per_event: Option, + /// Local-store byte quota (key + value bytes). + #[serde(default)] + pub max_state_bytes: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub struct CapabilitiesSection { + #[serde(default)] + pub required: Vec, + #[serde(default)] + pub optional: Vec, + #[serde(default)] + pub http: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub struct HttpSection { + #[serde(default)] + pub allow: Vec, +} + +/// Loaded + validated manifest, plus the data the engine needs to +/// instantiate a module. +#[derive(Debug)] +pub struct LoadedManifest { + pub manifest: Manifest, + /// Hosts wasi:http outgoing requests may target. Each entry is + /// either an exact hostname or a `*.suffix` wildcard. + pub http_allowlist: Vec, + /// `[config]` flattened to `(key, stringified-value)` pairs for a + /// module's `init`. Scalars become their text form; arrays and tables + /// their TOML representation. + pub config: Vec<(String, String)>, +} diff --git a/nexum/crates/nexum-runtime/src/preset.rs b/nexum/crates/nexum-runtime/src/preset.rs new file mode 100644 index 00000000..1296d3d4 --- /dev/null +++ b/nexum/crates/nexum-runtime/src/preset.rs @@ -0,0 +1,93 @@ +//! Runtime presets: one preset bundles a lattice, its component builders, +//! extensions, and add-ons, so an embedder launches with +//! `RuntimeBuilder::new(cfg).runtime::().launch()`. A preset carrying +//! pre-built backends or non-static extensions binds by value through +//! [`RuntimeBuilder::with_runtime`](crate::builder::RuntimeBuilder::with_runtime). +//! [`CoreRuntime`] is the domain-free default: a chain provider pool and a +//! local redb store, no extension payload, with the Prometheus add-on. + +use std::sync::Arc; + +use crate::addons::{AddOns, PrometheusAddOn}; +use crate::engine_config::EngineConfig; +use crate::host::component::{ + ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, + ProviderPoolBuilder, RuntimeTypes, +}; +use crate::host::extension::Extension; +use crate::host::local_store_redb::LocalStore; +use crate::host::logs::LogPipeline; +use crate::host::provider_pool::ProviderPool; + +/// A bundled runtime assembly: the [`RuntimeTypes`] lattice plus the component +/// builders, extensions, and add-ons the launcher needs. +/// +/// Sealed: a preset opts in by also implementing the sealing marker. +pub trait Runtime: crate::sealed::SealedRuntime { + /// The lattice the preset assembles. + type Types: RuntimeTypes; + /// Builds the chain backend ([`RuntimeTypes::Chain`]). + type ChainBuilder: ComponentBuilder::Chain>; + /// Builds the store backend ([`RuntimeTypes::Store`]). + type StoreBuilder: ComponentBuilder::Store>; + /// Builds the extension payload ([`RuntimeTypes::Ext`]). + type ExtBuilder: ComponentBuilder::Ext>; + /// Builds the shared [`LogPipeline`]. + type LogsBuilder: ComponentBuilder; + + /// Component builders that open the backends at launch; consumes the + /// preset, so a value-bound preset hands over owned, pre-built backends. + fn components( + self, + ) -> ComponentsBuilder< + Self::ChainBuilder, + Self::StoreBuilder, + Self::ExtBuilder, + Self::LogsBuilder, + >; + + /// The cross-cutting add-ons installed before the engine boots. + fn add_ons(&self) -> AddOns; + + /// Extensions the preset launches with, derived from config. Empty by + /// default; + /// [`PresetBuilder::with_extensions`](crate::builder::PresetBuilder::with_extensions) + /// appends on top. + fn extensions(&self, config: &EngineConfig) -> Vec>> { + let _ = config; + Vec::new() + } +} + +/// The domain-free default preset: a chain provider pool and a local redb +/// store, no extension payload, with the Prometheus add-on. Doubles as its own +/// [`RuntimeTypes`] lattice. +#[derive(Debug, Clone, Copy, Default)] +pub struct CoreRuntime; + +impl crate::sealed::SealedRuntimeTypes for CoreRuntime {} +impl crate::sealed::SealedRuntime for CoreRuntime {} + +impl RuntimeTypes for CoreRuntime { + type Chain = ProviderPool; + type Store = LocalStore; + type Ext = (); +} + +impl Runtime for CoreRuntime { + type Types = Self; + type ChainBuilder = ProviderPoolBuilder; + type StoreBuilder = LocalStoreBuilder; + type ExtBuilder = (); + type LogsBuilder = LogPipelineBuilder; + + fn components( + self, + ) -> ComponentsBuilder { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + } + + fn add_ons(&self) -> AddOns { + vec![Box::new(PrometheusAddOn)] + } +} diff --git a/crates/nexum-runtime/src/runtime/dispatch_rate.rs b/nexum/crates/nexum-runtime/src/runtime/dispatch_rate.rs similarity index 94% rename from crates/nexum-runtime/src/runtime/dispatch_rate.rs rename to nexum/crates/nexum-runtime/src/runtime/dispatch_rate.rs index 23a9853d..d7b1a862 100644 --- a/crates/nexum-runtime/src/runtime/dispatch_rate.rs +++ b/nexum/crates/nexum-runtime/src/runtime/dispatch_rate.rs @@ -55,8 +55,8 @@ impl TokenBucket { } } - /// Refill for elapsed time, then consume one token. `true` = allowed, - /// `false` = over-rate. `now` is injected to stay pure and testable. + /// Refill for elapsed time, then consume one token; `true` allowed, + /// `false` over-rate. `now` is injected. pub fn try_acquire(&mut self, now: Instant) -> bool { let capacity = f64::from(self.policy.capacity); let elapsed = now @@ -129,8 +129,7 @@ mod tests { ); } - /// The acceptance criterion at the policy layer: a flooding source is - /// throttled while a second, independent source keeps being served. + /// A flooding source is throttled while an independent source is served. #[test] fn one_flooding_bucket_does_not_starve_another() { let now = Instant::now(); diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/nexum/crates/nexum-runtime/src/runtime/event_loop.rs similarity index 74% rename from crates/nexum-runtime/src/runtime/event_loop.rs rename to nexum/crates/nexum-runtime/src/runtime/event_loop.rs index 653f566c..ef884bbe 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/nexum/crates/nexum-runtime/src/runtime/event_loop.rs @@ -1,32 +1,15 @@ -//! Open live chain event sources and dispatch their events to the -//! supervisor until a shutdown signal arrives. Blocks come from -//! `eth_subscribe(newHeads)` (WS); chain-logs come from alloy's -//! canonical `eth_getLogs` block-range poller (HTTP or WS), which -//! recovers events across a reconnect by re-querying the gap instead -//! of dropping them. +//! Open live chain event sources and dispatch their events to the supervisor +//! until shutdown. Blocks come from `eth_subscribe(newHeads)` (WS); chain-logs +//! from an `eth_getLogs` block-range poller (HTTP or WS) that recovers events +//! across a reconnect by re-querying the gap rather than dropping them. //! -//! ## Per-stream reconnect with exponential backoff -//! -//! `open_block_streams` / `open_chain_log_streams` no longer return a -//! `Vec` that ends on the first drop. They each spawn one -//! reconnect-aware task per `(chain_id)` or `(module, chain_id, -//! filter)` tuple. The task: -//! -//! 1. Opens the block subscription / log poller via the provider pool. -//! 2. Pumps items to an mpsc channel until the underlying stream ends -//! (a WebSocket drop for blocks, or a terminal poller error for -//! logs - a hard RPC failure or a reorg past retained history). -//! 3. Logs the end + waits `restart_policy::backoff_for(attempt)` -//! (1s -> 2s -> ... cap 5min). -//! 4. Reopens. On the first event after a reopen, attempt resets -//! if the stream has been healthy for `HEALTHY_WINDOW`. -//! -//! The event loop reads the receiver as a regular `Stream`. The -//! reconnect tasks live for the lifetime of the engine; they exit -//! cleanly with [`TaskExit::ReceiverGone`] when their channel receiver -//! is dropped (which happens when `run` returns). They are spawned via -//! a [`TaskExecutor`] and their handles collected into a [`TaskSet`] -//! the loop drains on shutdown. +//! `open_block_streams` and `open_chain_log_streams` each spawn one +//! reconnect-aware task per subscription: it opens the stream, pumps items to +//! an mpsc channel, and on drop waits `restart_policy::backoff_for` before +//! reopening, resetting the backoff once the stream has been healthy for +//! `HEALTHY_WINDOW`. The tasks exit with [`TaskExit::ReceiverGone`] when `run` +//! drops the receivers; their handles collect into a [`TaskSet`] the loop +//! drains on shutdown. use std::sync::Arc; use std::time::{Duration, Instant}; @@ -40,57 +23,37 @@ use tracing::{info, warn}; use crate::bindings::nexum; use crate::host::component::{ChainProvider, RuntimeTypes}; +use crate::host::extension::{ExtensionEvent, ExtensionEventStream}; use crate::host::provider_pool::ProviderError; use crate::runtime::restart_policy::backoff_for; use crate::supervisor::{ChainLogSub, Supervisor}; use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; -/// Errors carried by the tagged block / chain-log streams that the -/// supervisor consumes. Library-side code keeps `anyhow::Error` out -/// of long-lived stream item types per the rust idiomatic rubric. +/// Errors carried by the tagged block and chain-log streams. #[derive(Debug, Error)] +#[non_exhaustive] pub enum StreamError { - /// Underlying provider / transport failure while opening or - /// pumping the subscription. + /// Provider or transport failure opening or pumping the subscription. #[error(transparent)] Provider(#[from] ProviderError), } -/// Time the wrapper stream must observe uninterrupted events before -/// the backoff counter resets to 0. Long enough that a brief but -/// real connection blip does not silently undo the doubling, short -/// enough that a healthy node reverts to fast retries on the next -/// drop. +/// Uninterrupted-event duration before the backoff counter resets to 0. const HEALTHY_WINDOW: Duration = Duration::from_secs(60); -/// Time without any block event that we treat as a gap worth a -/// positive recovery log line. Sepolia and Ethereum -/// mainnet both produce blocks reliably every ~12 s, so a silence -/// longer than this is either a transport-layer reconnect that alloy -/// handled internally (no `stream ended` reached the engine, hence -/// no `subscription reopened` log fires) or an upstream RPC stall. -/// Either way, the soak operator wants a positive log line when -/// blocks resume - otherwise an `alloy_transport_ws::native` ERROR -/// followed by silence looks identical to a hung engine. +/// Silence between block events beyond which the next event logs a gap-closed +/// line, surfacing an alloy-internal transport reconnect that produced no +/// `stream ended` event. const BLOCK_GAP_LOG_THRESHOLD: Duration = Duration::from_secs(60); -/// Channel buffer for the reconnect tasks. Each chain / module -/// subscription gets its own task -> channel pair; buffer is small -/// because the event loop drains in real time. +/// Channel buffer for each reconnect task. const RECONNECT_CHANNEL_BUF: usize = 64; -/// Gap size (blocks) at or above which a re-open logs a large-backfill -/// notice. Purely informational - nothing is ever skipped. +/// Block-gap size at or above which a re-open logs a large-backfill notice. const LARGE_GAP_LOG_THRESHOLD: u64 = 1_000; -/// Per-chain block subscriptions, one reconnect-aware task per -/// chain id. Tasks are spawned via `executor` and their handles pushed -/// into `tasks` so the caller can drive graceful shutdown (the engine -/// drains the set after closing its receivers - the tasks exit cleanly -/// when the receiver drops). -/// -/// Not `async`: the openers only spawn, they never await, so the caller -/// gets the tagged streams synchronously. +/// Open one reconnect-aware block-subscription task per chain, spawned via +/// `executor` with handles pushed into `tasks` for graceful shutdown. pub fn open_block_streams( pool: &C, chains: &[Chain], @@ -113,10 +76,8 @@ where streams } -/// Per-module chain-log subscriptions. Each entry gets its own reconnect- -/// aware task tagged with the owning module name + chain id. Tasks -/// are spawned via `executor` and pushed into `tasks` (see -/// [`open_block_streams`]). +/// Open one reconnect-aware chain-log task per subscription; see +/// [`open_block_streams`]. pub fn open_chain_log_streams( pool: &C, subs: Vec, @@ -146,9 +107,7 @@ where streams } -/// Wrap an `mpsc::Receiver` as a `Stream` using -/// `futures::stream::unfold`. Avoids pulling in `tokio-stream` just -/// for `ReceiverStream`. +/// Wrap an `mpsc::Receiver` as a `Stream`. fn receiver_stream( rx: mpsc::Receiver, ) -> impl futures::Stream + Send { @@ -157,10 +116,8 @@ fn receiver_stream( }) } -/// Reconnect-aware loop for a single chain's block subscription. -/// Holds `(pool, chain_id)` and re-opens the underlying alloy -/// `eth_subscribe` stream with exponential backoff after every drop -/// or transport error. +/// Reconnect-aware loop for one chain's block subscription: re-opens the +/// `eth_subscribe` stream with exponential backoff after every drop or error. async fn reconnecting_block_task( pool: C, chain: Chain, @@ -245,28 +202,19 @@ where /// Per-subscription resume and backfill knobs for a chain-log task. struct ChainLogResume { - /// Durable cursor key, `Some` for a `resume` subscription; the block - /// under it seeds `initial_cursor`. + /// Durable cursor key; `Some` for a `resume` subscription. cursor_key: Option>, /// Persisted resume block read at boot; the first open starts here. initial_cursor: Option, - /// Opt-in cap (in blocks) on how far back the poller backfills; `None` - /// backfills the whole gap. + /// Opt-in cap in blocks on backfill depth; `None` backfills the whole gap. max_lookback: Option, } -/// Poller-backed loop for a single (module, chain) chain-log -/// subscription. Instead of `eth_subscribe(logs)` - which silently -/// drops events emitted during a WebSocket reconnect - it drives -/// alloy's canonical `eth_getLogs` block-range poller. The poller -/// reconciles reorgs and re-queries any gap internally, so no manual -/// backfill or dedup is needed here. A hard RPC error (after the -/// transport's own retries), or a reorg deeper than the poller's -/// retained history, ends the poller stream; this loop then re-opens -/// from the block after the last one it delivered and backfills the -/// entire missed range (nothing skipped) with exponential backoff - -/// unless the subscription set `max_lookback`, which bounds how far back -/// the backfill reaches. +/// Poller-backed loop for one (module, chain) chain-log subscription. Drives +/// the `eth_getLogs` block-range poller, which reconciles reorgs and re-queries +/// gaps internally. On a terminal poller error it re-opens from the block after +/// the last delivered one and backfills the whole missed range, bounded only by +/// `max_lookback` if set. async fn reconnecting_chain_log_task( pool: C, module: String, @@ -449,32 +397,27 @@ pub type TaggedBlockStream = std::pin::Pin< + Send, >, >; -/// One item on a tagged chain-log stream: `(module, chain, log, -/// cursor_key)` or a stream error. `cursor_key` is `Some` for a `resume` -/// subscription (constant per subscription; `Arc` for a cheap per-log -/// clone) and threads the durable cursor key through to the dispatch site. +/// One tagged chain-log item: `(module, chain, log, cursor_key)` or a stream +/// error. `cursor_key` is `Some` for a `resume` subscription and threads the +/// durable cursor key to the dispatch site. pub type TaggedChainLog = Result<(String, Chain, alloy_rpc_types_eth::Log, Option>), StreamError>; pub type TaggedChainLogStream = std::pin::Pin + Send>>; - /// Drive the supervisor with events until `shutdown` resolves. /// -/// Graceful shutdown: the dispatch path is structured so -/// that `shutdown` is only observed *between* dispatches, never -/// mid-`call_on_event`. Each select fork either yields a fresh event -/// to dispatch or signals shutdown - the in-flight wasmtime call -/// finishes naturally before the loop exits. Whatever `shutdown` -/// yields (the launcher passes the graceful-drain guard) is held -/// until the loop returns, so the drain covers the final dispatch -/// and cursor commit. +/// `shutdown` is observed only between dispatches, never mid-`call_on_event`, +/// so an in-flight wasmtime call finishes before the loop exits; the guard it +/// yields is held until return, so the drain covers the final dispatch and +/// cursor commit. Returns the `(blocks, chain_logs)` dispatch tally. pub async fn run( supervisor: &mut Supervisor, block_streams: Vec, chain_log_streams: Vec, + extension_streams: Vec, tasks: TaskSet, shutdown: impl std::future::Future + Send, -) { +) -> (u64, u64) { // `select_all` over an empty Vec yields `None` immediately, which // would trip the "stream ended -> shut down" arm below before the // first block / chain-log ever flows. Engine configs that subscribe to @@ -493,9 +436,15 @@ pub async fn run( } else { select_all(chain_log_streams).boxed() }; + let mut extension_events: BoxStream<'_, _> = if extension_streams.is_empty() { + futures::stream::pending().boxed() + } else { + select_all(extension_streams).boxed() + }; let mut shutdown = Box::pin(shutdown); let mut dispatched_blocks: u64 = 0; let mut dispatched_chain_logs: u64 = 0; + let mut dispatched_extension_events: u64 = 0; let started = Instant::now(); loop { // Phase 1: pick the next event OR observe shutdown. The @@ -512,6 +461,7 @@ pub async fn run( Box, Option>, ), + Extension(ExtensionEvent), // Carries the drain guard `shutdown` yielded. Shutdown(G), StreamPanic(&'static str), @@ -542,6 +492,11 @@ pub async fn run( } None => NextEvent::StreamPanic("chain-log"), }, + next = extension_events.next() => match next { + Some(event) => NextEvent::Extension(event), + // Extension source tasks loop forever; `None` means one exited. + None => NextEvent::StreamPanic("extension-event"), + }, }; match next { @@ -555,6 +510,10 @@ pub async fn run( .await; dispatched_chain_logs += 1; } + NextEvent::Extension(event) => { + supervisor.dispatch_extension_event(event).await; + dispatched_extension_events += 1; + } NextEvent::Shutdown(guard) => { // Drop the stream-end receivers so the reconnect // tasks observe a closed channel and exit. Then drain @@ -562,15 +521,17 @@ pub async fn run( // finish before returning. drop(blocks); drop(chain_logs); + drop(extension_events); tasks.shutdown().await; info!( dispatched_blocks, dispatched_chain_logs, + dispatched_extension_events, uptime_secs = started.elapsed().as_secs(), "graceful shutdown complete", ); drop(guard); - return; + return (dispatched_blocks, dispatched_chain_logs); } NextEvent::StreamPanic(kind) => { // Reconnect tasks should loop forever. @@ -578,24 +539,20 @@ pub async fn run( // exited (panic or channel closed). Bail loudly. drop(blocks); drop(chain_logs); + drop(extension_events); tasks.shutdown().await; warn!( kind, "reconnect task ended unexpectedly - shutting down for engine restart" ); - return; + return (dispatched_blocks, dispatched_chain_logs); } } } } -/// The block a re-opened log poller should start from. `None` (the -/// first open) starts at the head, so no history is replayed on boot. -/// Otherwise resume just after the last delivered block and backfill -/// the whole gap - there is no lookback cap, so nothing is ever -/// skipped. This is reorg-safe: the old blocks are final, and the -/// poller fetches one `eth_getLogs` per block (immune to a provider's -/// block-range limit). +/// Start block for a re-opened log poller: `None` (first open) starts at head; +/// otherwise just after the last delivered block, backfilling the whole gap. fn poller_resume_block(last_seen_block: Option, head: u64) -> u64 { match last_seen_block { None => head, @@ -603,11 +560,8 @@ fn poller_resume_block(last_seen_block: Option, head: u64) -> u64 { } } -/// Returns `Some(gap)` when the time between the last observed event -/// and `now` meets or exceeds `threshold` - the caller should emit a -/// positive-recovery log line at this point. `None` covers -/// both the first-event case (no `last_event` yet) and the normal -/// "events are arriving at expected cadence" case. +/// `Some(gap)` when `now` is at least `threshold` past the last event; `None` +/// on the first event or when events arrive within `threshold`. fn block_stream_gap_to_log( now: Instant, last_event: Option, @@ -641,8 +595,96 @@ pub async fn wait_for_shutdown_signal() -> anyhow::Result<&'static str> { mod tests { use super::*; - /// The helper that decides whether to emit a - /// "stream gap closed" line on the next block event. + // ── Structural tests: per-stream task allocation (#56) ────────────────── + + /// `open_block_streams` spawns one independent reconnect task per chain. + #[tokio::test] + async fn open_block_streams_opens_one_task_per_chain() { + use crate::test_utils::MockChainProvider; + use nexum_tasks::TaskManager; + + let pool = MockChainProvider::new(); + let manager = TaskManager::new(); + let executor = manager.executor(); + let mut tasks = TaskSet::new(); + let chains = vec![ + alloy_chains::Chain::mainnet(), + alloy_chains::Chain::from_id(100), + ]; + let streams = open_block_streams(&pool, &chains, &executor, &mut tasks); + assert_eq!(streams.len(), 2, "one stream per chain"); + tasks.shutdown().await; + } + + /// `open_chain_log_streams` spawns one reconnect task per subscription. + #[tokio::test] + async fn open_chain_log_streams_opens_one_task_per_subscription() { + use crate::test_utils::MockChainProvider; + use nexum_tasks::TaskManager; + + let pool = MockChainProvider::new(); + let manager = TaskManager::new(); + let executor = manager.executor(); + let mut tasks = TaskSet::new(); + let subs = vec![ + ChainLogSub { + module: "mod-a".to_string(), + chain: alloy_chains::Chain::mainnet(), + filter: alloy_rpc_types_eth::Filter::default(), + cursor_key: None, + initial_cursor: None, + max_lookback: None, + }, + ChainLogSub { + module: "mod-b".to_string(), + chain: alloy_chains::Chain::mainnet(), + filter: alloy_rpc_types_eth::Filter::default(), + cursor_key: None, + initial_cursor: None, + max_lookback: None, + }, + ]; + let streams = open_chain_log_streams(&pool, subs, &executor, &mut tasks); + assert_eq!(streams.len(), 2, "one stream per subscription"); + tasks.shutdown().await; + } + + /// A reconnect task whose receiver drops exits on its own with + /// [`TaskExit::ReceiverGone`], not via abort. + #[tokio::test] + async fn reconnect_task_exits_receiver_gone_when_receiver_drops() { + use crate::test_utils::MockChainProvider; + use nexum_tasks::TaskManager; + + let pool = MockChainProvider::new(); + // Buffer one header so the task has an item to forward - the + // failing `tx.send` against the dropped receiver is the exit path + // under test. + pool.push_block(alloy_rpc_types_eth::Header::default()); + + let manager = TaskManager::new(); + let executor = manager.executor(); + let (tx, rx) = mpsc::channel(1); + let handle = executor.spawn(reconnecting_block_task( + pool.clone(), + alloy_chains::Chain::mainnet(), + tx, + )); + drop(rx); + + let exit = tokio::time::timeout(Duration::from_secs(5), handle.join()) + .await + .expect("task must exit promptly once the receiver is gone"); + assert_eq!( + exit, + Some(TaskExit::ReceiverGone), + "the task must exit naturally, not via abort (abort yields None)", + ); + } + + // ── block_stream_gap_to_log unit tests ────────────────────────────────── + + /// No prior event yields `None`. #[test] fn block_stream_gap_to_log_returns_none_when_no_prior_event() { let now = Instant::now(); diff --git a/nexum/crates/nexum-runtime/src/runtime/limits.rs b/nexum/crates/nexum-runtime/src/runtime/limits.rs new file mode 100644 index 00000000..ab046cdd --- /dev/null +++ b/nexum/crates/nexum-runtime/src/runtime/limits.rs @@ -0,0 +1,5 @@ +//! Re-exports the per-module fuel and memory limits; canonical source +//! [`crate::engine_config::ModuleLimits`]. +//! +//! Fuel meters only guest instructions; host-call time is unmetered, so the +//! per-dispatch wall-clock deadline in [`crate::supervisor`] is the backstop. diff --git a/crates/nexum-runtime/src/runtime/mod.rs b/nexum/crates/nexum-runtime/src/runtime/mod.rs similarity index 100% rename from crates/nexum-runtime/src/runtime/mod.rs rename to nexum/crates/nexum-runtime/src/runtime/mod.rs diff --git a/nexum/crates/nexum-runtime/src/runtime/poison_policy.rs b/nexum/crates/nexum-runtime/src/runtime/poison_policy.rs new file mode 100644 index 00000000..b080e19a --- /dev/null +++ b/nexum/crates/nexum-runtime/src/runtime/poison_policy.rs @@ -0,0 +1,63 @@ +//! Supervisor poison-pill policy. +//! +//! A module reaching `max_failures` traps within a sliding `window` is +//! poisoned: the supervisor stops dispatching to it (no further restarts), +//! sets the `shepherd_module_poisoned{module}` gauge to 1, and logs the +//! quarantine. Recovery needs an operator-driven full engine restart. + +use std::time::Duration; + +/// Production defaults: 5 traps within 10 minutes quarantines a module. +pub const POISON_MAX_FAILURES: u32 = 5; +pub const POISON_WINDOW: Duration = Duration::from_secs(600); + +/// Configurable poison-pill thresholds from `[limits.poison]`, else +/// [`PoisonPolicy::default`]. +#[derive(Debug, Clone, Copy)] +pub struct PoisonPolicy { + /// Maximum traps within `window` before the module is poisoned. + pub max_failures: u32, + /// Sliding window the failures are counted across. + pub window: Duration, +} + +impl PoisonPolicy { + pub const fn new(max_failures: u32, window: Duration) -> Self { + Self { + max_failures, + window, + } + } +} + +impl Default for PoisonPolicy { + fn default() -> Self { + Self::new(POISON_MAX_FAILURES, POISON_WINDOW) + } +} + +/// `true` when the recent-failure count crosses the configured threshold. +pub fn should_poison(policy: PoisonPolicy, recent_failures: u32) -> bool { + recent_failures >= policy.max_failures +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_production_constants() { + let p = PoisonPolicy::default(); + assert_eq!(p.max_failures, POISON_MAX_FAILURES); + assert_eq!(p.window, POISON_WINDOW); + } + + #[test] + fn poisons_at_threshold() { + let p = PoisonPolicy::new(3, Duration::from_secs(60)); + assert!(!should_poison(p, 0)); + assert!(!should_poison(p, 2)); + assert!(should_poison(p, 3)); + assert!(should_poison(p, 100)); + } +} diff --git a/crates/nexum-runtime/src/runtime/restart_policy.rs b/nexum/crates/nexum-runtime/src/runtime/restart_policy.rs similarity index 56% rename from crates/nexum-runtime/src/runtime/restart_policy.rs rename to nexum/crates/nexum-runtime/src/runtime/restart_policy.rs index 7b80dd62..502cb6c5 100644 --- a/crates/nexum-runtime/src/runtime/restart_policy.rs +++ b/nexum/crates/nexum-runtime/src/runtime/restart_policy.rs @@ -1,12 +1,8 @@ //! Supervisor module restart policy. //! -//! When a module traps in `on_event`, the supervisor flips `alive = -//! false` and schedules a restart attempt with exponential backoff. -//! The next dispatch eligible for that module retries the call; on -//! success the failure counter resets so a module that recovers -//! lands back in the steady-state schedule with no further delay. -//! -//! Policy: +//! On a trap in `on_event` the supervisor marks the module dead and schedules +//! a restart with exponential backoff; the next eligible dispatch retries, and +//! a successful call resets the failure counter. //! //! | failure_count | next_attempt delay | //! |---|---| @@ -16,26 +12,16 @@ //! | ... | doubles | //! | 9+ | capped at 5 minutes | //! -//! State is in-memory per supervisor process. Persistence across -//! engine restarts is out of scope (a separate 0.3 / M5 follow-up -//! that lands alongside `submitted:{uid}` cross-restart dedup). +//! State is in-memory per process; it does not persist across restarts. use std::time::Duration; -/// Hard cap on the restart backoff. After ~8 doublings we plateau -/// here. Tuneable in 0.3 via `engine.toml::[engine.restart]`. +/// Hard cap on the restart backoff. pub const RESTART_MAX_BACKOFF: Duration = Duration::from_secs(300); -/// Compute the wait window the supervisor honours before the next -/// restart attempt of a module that has trapped `failure_count` times -/// in a row. -/// -/// `failure_count = 0` is the steady-state value (no failures yet); -/// it returns `Duration::ZERO` so the supervisor can call this -/// unconditionally without a branch at the call site. -/// -/// `failure_count >= 1` is "the module just trapped"; the first -/// retry is 1 s, doubling on each subsequent trap, capped at 5 min. +/// Backoff before the next restart of a module that trapped `failure_count` +/// times in a row. `0` returns `Duration::ZERO` (steady state, callable +/// unconditionally); `>= 1` is 1 s doubling per trap, capped at 5 min. pub fn backoff_for(failure_count: u32) -> Duration { if failure_count == 0 { return Duration::ZERO; diff --git a/crates/nexum-runtime/src/supervisor.rs b/nexum/crates/nexum-runtime/src/supervisor.rs similarity index 51% rename from crates/nexum-runtime/src/supervisor.rs rename to nexum/crates/nexum-runtime/src/supervisor.rs index 22400dfb..c7978d57 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/nexum/crates/nexum-runtime/src/supervisor.rs @@ -1,31 +1,19 @@ //! Multi-module supervisor. //! -//! Loads every `[[modules]]` entry from `engine.toml`, instantiates -//! each as an `EventModule` binding against a dedicated wasmtime -//! `Store`, and routes the event types declared in each manifest's -//! `[[subscription]]` table. +//! Loads every `[[modules]]` and `[[adapters]]` entry from `engine.toml`, +//! instantiates each against a dedicated wasmtime `Store`, and routes +//! subscribed events. //! -//! Trap handling: a wasmtime trap in `on_event` -//! marks the module `alive = false`, increments `failure_count`, and -//! schedules a `next_attempt` instant via `runtime::restart_policy:: -//! backoff_for`. The next dispatch eligible after that instant -//! re-instantiates the component (fresh `Store` + bindings; the -//! wasm instance left by a trap is poisoned with "cannot enter -//! component instance") and re-calls `init`. On a successful -//! `on_event` the failure counter resets to 0. -//! -//! Modules whose `init` returned `Err(fault)` are dead with -//! `next_attempt = None` and never get scheduled - the init failure -//! is treated as a manifest / config bug, not a transient. -//! -//! Multi-chain isolation: `dispatch_block(block)` walks -//! every module but only enters those whose subscriptions match -//! `block.chain_id`. Per-module restart / poison / fuel limits are -//! independent across chains, so a poisoned module on chain A -//! cannot starve modules on chain B. The upstream WS reconnect -//! tasks own one per-chain backoff timer each, so a -//! chain-A connection drop does not block chain-B events. +//! On a trap in `on_event` a module is marked dead, its failure count +//! bumps, and a backoff `next_attempt` is scheduled; the next eligible +//! dispatch re-instantiates it on a fresh `Store` (the trapped instance is +//! poisoned) and re-runs `init`. A successful dispatch resets the count. A +//! module whose `init` returned `Err` is permanently dead +//! (`next_attempt = None`). Providers ride the same sweeps via a shared +//! [`Liveness`]. Per-module restart, poison, and fuel state are +//! independent across chains. +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -39,9 +27,16 @@ use wasmtime::{Engine, Store}; use wasmtime_wasi::{HostMonotonicClock, HostWallClock, WasiCtxBuilder}; use crate::bindings::{Config, EventModule, nexum}; -use crate::engine_config::{EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits}; +use crate::engine_config::{ + AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits, +}; +use crate::host::actor::Liveness; use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; -use crate::host::extension::Extension; +use crate::host::extension::ExtensionEvent; +use crate::host::extension::{ + Extension, HostService, HostServices, Installed, ProviderInstance, ProviderKind, + ProviderManifest, +}; use crate::host::http::HttpGate; #[cfg(test)] use crate::host::local_store_redb::LocalStore; @@ -49,39 +44,45 @@ use crate::host::logs::{LogRecord, LogSource, RunId, StdioStream}; #[cfg(test)] use crate::host::provider_pool::ProviderPool; use crate::host::state::HostState; -use crate::manifest::{self, CapabilityRegistry, LoadedManifest, ResourceSection, Subscription}; +use crate::manifest::{ + self, CapabilityRegistry, ComponentKind, LoadedManifest, ResourceSection, Subscription, +}; -/// Owns every loaded module and exposes the dispatch surface the -/// event loop needs. Generic over the [`RuntimeTypes`] lattice binding -/// the component seam backends. +/// Owns every loaded module and provider and exposes the dispatch surface. +/// Generic over the [`RuntimeTypes`] backend lattice. pub struct Supervisor { modules: Vec>, - /// Cached for module restart: re-instantiating a trapped module - /// requires a fresh wasmtime `Store` + `Linker`, which in turn need - /// the shared backends. The `Components` bundle is cheaply cloned - /// (Arc-backed members) so the supervisor takes an owned copy at boot. + /// Providers loaded at boot; swept for restart and poison alongside + /// the modules. + providers: Vec, + /// Registered provider kinds paired with their services, for the + /// restart sweep to reinstall through. + kinds: ProviderKinds, + /// Cached for restart: rebuilding a trapped module needs a fresh + /// `Store` + `Linker`, hence the shared backends held here. engine: Engine, components: Components, - /// Extensions wired at boot. Cached so the module-restart path can - /// rebuild an identical linker (core interfaces plus every extension - /// hook) without re-consulting the composition root. - extensions: Vec>, - /// Poison-pill thresholds resolved from `[limits.poison]` at boot - /// (production defaults: 5 failures / 10 min). + /// Extensions wired at boot, cached to rebuild an identical linker on + /// restart. + extensions: Vec>>, + /// Extension-owned host services, built once at boot and carried by + /// every store. + services: HostServices, + /// Poison-pill thresholds resolved from `[limits.poison]` at boot. poison_policy: crate::runtime::poison_policy::PoisonPolicy, - /// Optional WASI clock override applied to every module store, - /// including the ones rebuilt on restart. `None` leaves the ambient - /// host clocks. + /// Optional WASI clock override applied to every module store. `None` + /// leaves the ambient host clocks. clocks: Option, } -/// Core-only lattice for the runtime's own tests: the reference core -/// backends with an empty extension slot (`Ext = ()`). Domain-extension -/// boot coverage lives in the extension crate that owns the backend. +/// Core-only lattice for the runtime's own tests (`Ext = ()`). #[cfg(test)] #[derive(Clone, Copy, Default)] pub(crate) struct TestTypes; +#[cfg(test)] +impl crate::sealed::SealedRuntimeTypes for TestTypes {} + #[cfg(test)] impl RuntimeTypes for TestTypes { type Chain = ProviderPool; @@ -89,22 +90,17 @@ impl RuntimeTypes for TestTypes { type Ext = (); } -/// The supervisor the runtime's own tests drive. The launch path infers -/// its lattice from the composition root instead. +/// The supervisor the runtime's own tests drive. #[cfg(test)] pub(crate) type DefaultSupervisor = Supervisor; -/// A wasmtime `Store` holding the lattice `HostState`. Named so the -/// module and helper signatures stay legible. +/// A wasmtime `Store` holding the lattice `HostState`. type HostStore = Store>; -/// Per-store WASI clock override applied to every module store. -/// -/// Threaded from the assembly through the boot paths onto each store's -/// `WasiCtxBuilder`. The shared wall and monotonic sources let a test handle -/// drive guest-visible time; leaving it `None` keeps the ambient host clocks, -/// so the default is behaviour-neutral. `RunId.started_at` is host wall-clock -/// and is unaffected. +/// Per-store WASI clock override applied to every module store; shared +/// wall and monotonic sources let a test drive guest-visible time. `None` +/// keeps the ambient host clocks. `RunId.started_at` is host wall-clock +/// and unaffected. #[derive(Clone)] pub struct WasiClockOverride { wall: Arc, @@ -121,8 +117,7 @@ impl WasiClockOverride { } } -/// Adapts a shared wall clock into the by-value `HostWallClock` the -/// `WasiCtxBuilder` takes ownership of per store. +/// Adapts a shared wall clock into the by-value `HostWallClock` a store owns. struct SharedWallClock(Arc); impl HostWallClock for SharedWallClock { @@ -135,8 +130,8 @@ impl HostWallClock for SharedWallClock { } } -/// Adapts a shared monotonic clock into the by-value `HostMonotonicClock` the -/// `WasiCtxBuilder` takes ownership of per store. +/// Adapts a shared monotonic clock into the by-value `HostMonotonicClock` a +/// store owns. struct SharedMonotonicClock(Arc); impl HostMonotonicClock for SharedMonotonicClock { @@ -149,16 +144,16 @@ impl HostMonotonicClock for SharedMonotonicClock { } } -/// A module's resource budget after layering its `[module.resources]` -/// overrides onto the engine `[limits]` defaults. +/// A module's resource budget: `[module.resources]` layered over engine +/// `[limits]`. struct ResolvedLimits { fuel: u64, memory: usize, state_bytes: u64, } -/// Layer a manifest's `[module.resources]` over the engine `[limits]` -/// defaults: each unset override field keeps the engine default. +/// Layer `[module.resources]` over engine `[limits]`; unset fields keep the +/// default. fn resolve_module_limits(res: &ResourceSection, cfg: &ModuleLimits) -> ResolvedLimits { ResolvedLimits { fuel: res.max_fuel_per_event.unwrap_or(cfg.fuel()), @@ -171,82 +166,242 @@ struct LoadedModule { name: String, bindings: EventModule, store: HostStore, - /// The run this store instantiates. Restarts mint a fresh `RunId` - /// with an incremented sequence; the supervisor's death path stamps - /// the synthesized panic record with it. + /// The run this store instantiates; restarts mint a fresh `RunId` with + /// an incremented sequence. run: RunId, - /// Subscriptions copied from `module.toml`. The supervisor reads - /// these on every event to decide whether to dispatch. + /// Subscriptions copied from `module.toml`, read on every event to + /// decide dispatch. subscriptions: Vec, /// Fuel budget refilled before each `on_event` invocation. fuel_per_event: u64, - /// Wall-clock deadline for a whole dispatch, guest plus every host - /// call it awaits. Fuel bounds only guest instructions, so this is - /// the backstop against a dispatch parked in a slow or blocked host - /// call (see [`crate::runtime::limits`]). + /// Wall-clock deadline for a whole dispatch (guest plus every host + /// call). Fuel bounds only guest instructions; this is the backstop + /// for a dispatch parked in a host call (see [`crate::runtime::limits`]). event_deadline: Duration, - /// Memory cap applied to the wasmtime store on reinstantiation. + /// Memory cap applied to the store on reinstantiation. memory_limit: usize, - /// Local-store byte quota applied to the module store on reinstantiation. + /// Local-store byte quota applied on reinstantiation. local_store_bytes: u64, - /// Cached for restart: re-instantiating from the original - /// wasm bytes avoids re-reading the file on every restart. The - /// `Component` itself is internally `Arc`-backed by wasmtime. + /// Cached for restart; `Component` is internally `Arc`-backed. component: Component, - /// Cached for restart: the manifest's `[config]` we pass - /// to `Guest::init`. Cloning a `Vec<(String, String)>` is cheap. + /// Cached for restart: the manifest `[config]` passed to `init`. init_config: Config, - /// Cached for restart: HTTP allowlist baked into the - /// `HostState` we rebuild on each re-instantiation. + /// Cached for restart: HTTP allowlist baked into the rebuilt `HostState`. http_allowlist: Vec, - /// Cached for restart: outbound HTTP limits baked into the - /// `HostState` we rebuild on each re-instantiation. + /// Cached for restart: outbound HTTP limits. http_limits: OutboundHttpLimits, - /// Set to `false` when `on_event` traps. Dead modules are - /// excluded from dispatch until `next_attempt` is in the past. - /// Modules whose `init` failed have `alive = false` - /// + `next_attempt = None`, so they never come back. + /// Cached for restart: chain response size cap. + chain_response_max_bytes: usize, + /// Set `false` when `on_event` traps; excluded from dispatch until + /// `next_attempt` passes. An init-failed module has `alive = false` + + /// `next_attempt = None`, so it never returns. alive: bool, - /// Number of consecutive trap-style failures since the last - /// successful dispatch. Resets to 0 on success. Drives the - /// exponential backoff via `restart_policy::backoff_for`. + /// Consecutive trap failures since the last success; resets to 0 on + /// success. Drives the backoff via `restart_policy::backoff_for`. failure_count: u32, - /// Earliest instant at which the supervisor may retry this - /// module after a trap. `None` for healthy modules + for modules - /// whose `init` failed (the latter never get scheduled because - /// the dispatch fast-path checks `next_attempt` *and* requires - /// `alive = false` before flipping back). + /// Earliest instant the supervisor may retry after a trap. `None` for + /// healthy modules and for init-failed modules (never rescheduled). next_attempt: Option, - /// Sliding-window record of recent trap timestamps for the - /// poison-pill check. Entries older than the - /// `PoisonPolicy.window` are dropped on each push. + /// Sliding-window trap timestamps for the poison-pill check; entries + /// older than `PoisonPolicy.window` drop on push. failure_timestamps: std::collections::VecDeque, - /// Once `true` the module is permanently quarantined: no restart - /// attempts, no dispatches, no metric churn. Recovery requires - /// an operator-driven full engine restart with the module - /// removed from `engine.toml::[[modules]]`. + /// Once `true` the module is permanently quarantined: no restarts, no + /// dispatches. Recovery requires removing it from `[[modules]]` and + /// restarting the engine. poisoned: bool, - /// Per-module dispatch rate limiter. Checked in `dispatch_to` - /// before the guest runs, so an event flood on this module's - /// source is throttled at the dispatch boundary without touching - /// any other module's bucket. Over-rate events are dropped and - /// counted. + /// Per-module dispatch rate limiter, checked in `dispatch_to` before + /// the guest runs; over-rate events are dropped and counted. dispatch_bucket: crate::runtime::dispatch_rate::TokenBucket, } +/// One loaded provider; mirrors [`LoadedModule`]'s restart and poison +/// bookkeeping. Liveness is shared with the installed actor. +struct LoadedProvider { + /// The provider's namespace: its manifest name. + name: String, + /// Registered kind the restart sweep reinstalls through. + kind: &'static str, + /// Extension-owned manifest sections. + sections: manifest::ExtensionSections, + /// Cached for restart, like a module's. + component: Component, + /// Cached for restart: the manifest `[config]` handed to `init`. + init_config: Config, + /// Cached for restart: the operator's transport grants. + http_allow: Vec, + messaging_topics: Vec, + /// Cached for restart. + http_limits: OutboundHttpLimits, + fuel_per_call: u64, + memory_limit: usize, + chain_response_max_bytes: usize, + local_store_bytes: u64, + /// Trap flag shared with the installed actor. + liveness: Liveness, + /// Sequence of the run currently installed; restarts increment it. + run_seq: u64, + /// The sweep's view of `liveness`: `true` against a dead liveness is + /// an unrecorded trap. Init failure leaves it `false` with + /// `next_attempt = None`, permanent. + alive: bool, + failure_count: u32, + next_attempt: Option, + failure_timestamps: std::collections::VecDeque, + poisoned: bool, +} + +/// One registered provider kind paired with the service its installs bind to. +type ProviderRow = (Box>, Arc); + +/// Registered provider kinds, keyed by their manifest spelling. +type ProviderKinds = BTreeMap<&'static str, ProviderRow>; + +/// Collect each extension's provider kind paired with that extension's +/// service. Refuses a duplicate spelling and a provider whose extension +/// owns no service to install into. +fn provider_kinds( + extensions: &[Arc>], + services: &HostServices, +) -> Result> { + let mut kinds = ProviderKinds::new(); + for ext in extensions { + let Some(provider) = ext.provider() else { + continue; + }; + let service = services.raw(ext.namespace()).cloned().ok_or_else(|| { + anyhow!( + "extension {} registers provider kind {} without a host service", + ext.namespace(), + provider.kind(), + ) + })?; + register_kind(&mut kinds, provider, service)?; + } + Ok(kinds) +} + +/// Union of subscription kinds the wired extensions declare. +fn extension_subscription_vocabulary( + extensions: &[Arc>], +) -> BTreeSet<&'static str> { + extensions + .iter() + .flat_map(|ext| ext.subscriptions().iter().copied()) + .collect() +} + +/// Refuse a manifest section no wired extension claims. +fn enforce_extension_sections( + owner: &str, + sections: &manifest::ExtensionSections, + extensions: &[Arc>], +) -> Result<()> { + for key in sections.keys() { + let claimed = extensions + .iter() + .any(|ext| ext.manifest_sections().contains(&key.as_str())); + if !claimed { + return Err(anyhow!( + "{owner} declares manifest section [{key}]; no wired extension claims it" + )); + } + } + Ok(()) +} + +/// Refuse a name two wired extensions both claim (service namespace, +/// subscription kind, or manifest section), fail-fast at boot. +fn enforce_extension_uniqueness( + extensions: &[Arc>], +) -> Result<()> { + let mut namespaces = BTreeSet::new(); + let mut kinds = BTreeSet::new(); + let mut sections = BTreeSet::new(); + for ext in extensions { + let namespace = ext.namespace(); + if !namespaces.insert(namespace) { + return Err(anyhow!("extension namespace {namespace} is claimed twice")); + } + for kind in ext.subscriptions() { + if !kinds.insert(*kind) { + return Err(anyhow!("subscription kind {kind} is claimed twice")); + } + } + for section in ext.manifest_sections() { + if !sections.insert(*section) { + return Err(anyhow!("manifest section [{section}] is claimed twice")); + } + } + } + Ok(()) +} + +/// Insert one kind row, refusing a duplicate manifest spelling. +fn register_kind( + kinds: &mut ProviderKinds, + provider: Box>, + service: Arc, +) -> Result<()> { + let kind = provider.kind(); + if kinds.insert(kind, (provider, service)).is_some() { + return Err(anyhow!("provider kind {kind} is registered twice")); + } + Ok(()) +} + +/// Comma-joined registered provider kind spellings, for boot errors. +fn registered_kinds(kinds: &ProviderKinds) -> String { + kinds.keys().copied().collect::>().join(", ") +} + impl Supervisor { - /// Compile + instantiate every module declared in - /// `engine_cfg.modules`. The wasmtime `Engine` + `Linker` are - /// passed in so `main.rs` can build them once. + /// Compile and instantiate every module and provider in `engine_cfg`. + /// The `Engine` and `Linker` are passed in. pub async fn boot( engine: &Engine, linker: &Linker>, engine_cfg: &EngineConfig, components: &Components, - extensions: &[Extension], + extensions: &[Arc>], clocks: Option, ) -> Result { + enforce_extension_uniqueness(extensions)?; let registry = capability_registry(extensions); + let services = HostServices::from_extensions(extensions)?; + // Provider kinds the boot loop resolves manifest kinds against. + let kinds = provider_kinds(extensions, &services)?; + // Providers boot first into their extension-owned services, so + // every module store built below already routes to the installed + // instances. Providers link only their kind's scoped imports. + let provider_registry = CapabilityRegistry::provider(); + let mut providers = Vec::with_capacity(engine_cfg.adapters.len()); + for entry in &engine_cfg.adapters { + let loaded = Self::load_provider( + engine, + entry, + components, + &engine_cfg.limits, + &provider_registry, + clocks.as_ref(), + &kinds, + extensions, + ) + .await + .with_context(|| format!("load provider {}", entry.path.display()))?; + providers.push(loaded); + } + // The loaded providers' manifests, as the worker install + // predicates see them. + let provider_manifests: Vec = providers + .iter() + .map(|p| ProviderManifest { + name: p.name.clone(), + kind: p.kind, + sections: p.sections.clone(), + }) + .collect(); + + let extension_kinds = extension_subscription_vocabulary(extensions); let mut modules = Vec::with_capacity(engine_cfg.modules.len()); for entry in &engine_cfg.modules { let loaded = Self::load_one( @@ -257,26 +412,39 @@ impl Supervisor { &engine_cfg.limits, ®istry, clocks.as_ref(), + services.clone(), + &extension_kinds, + extensions, + &provider_manifests, ) .await .with_context(|| format!("load module {}", entry.path.display()))?; modules.push(loaded); } - info!(loaded = modules.len(), "supervisor up"); + let alive = modules.iter().filter(|m| m.alive).count(); + let adapters_alive = providers.iter().filter(|p| p.alive).count(); + info!( + loaded = modules.len(), + alive, + adapters = providers.len(), + adapters_alive, + "supervisor up" + ); Ok(Self { modules, + providers, + kinds, engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), + services, poison_policy: engine_cfg.limits.poison(), clocks, }) } - /// One-shot construction from a single ad-hoc `(component, manifest)` - /// pair. Used by the CLI-positional invocation so `just run` - /// against the example module keeps working without an - /// `engine.toml`. + /// Construct from a single `(component, manifest)` pair, for `just run` + /// without an `engine.toml`. // One flat argument per shared backend and resource knob, plus the // optional clock override; bundling would obscure the call site. #[allow(clippy::too_many_arguments)] @@ -287,14 +455,19 @@ impl Supervisor { manifest: Option<&Path>, components: &Components, limits: &ModuleLimits, - extensions: &[Extension], + extensions: &[Arc>], clocks: Option, ) -> Result { + enforce_extension_uniqueness(extensions)?; let registry = capability_registry(extensions); + let services = HostServices::from_extensions(extensions)?; let entry = ModuleEntry { path: wasm.to_path_buf(), manifest: manifest.map(Path::to_path_buf), }; + // The single-module override path serves `just run`; providers + // are configured through `engine.toml`, so none boot here. + let extension_kinds = extension_subscription_vocabulary(extensions); let loaded = Self::load_one( engine, linker, @@ -303,22 +476,28 @@ impl Supervisor { limits, ®istry, clocks.as_ref(), + services.clone(), + &extension_kinds, + extensions, + &[], ) .await?; Ok(Self { modules: vec![loaded], + providers: Vec::new(), + kinds: ProviderKinds::new(), engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), + services, poison_policy: limits.poison(), clocks, }) } /// Build a fresh wasmtime `Store` wired to the shared backends, with - /// the per-run namespace, allowlist, memory cap, and fuel applied. - /// Shared by `load_one` and `reinstantiate_one`; each call takes a - /// freshly minted [`RunId`] so a restart's store is a distinct run. + /// the per-run namespace, allowlist, memory cap, and fuel applied. Each + /// call takes a freshly minted [`RunId`]. // One flat argument per resource knob threaded onto the store, plus the // optional clock override. #[allow(clippy::too_many_arguments)] @@ -328,10 +507,13 @@ impl Supervisor { run: RunId, http_allowlist: Vec, http_limits: OutboundHttpLimits, + messaging_topics: Vec, memory_limit: usize, fuel: u64, + chain_response_max_bytes: usize, state_quota: u64, clocks: Option<&WasiClockOverride>, + services: HostServices, ) -> Result> { let namespace: &str = &run.module; // Capture guest stdout/stderr per store instead of inheriting the @@ -382,11 +564,14 @@ impl Supervisor { limits, http_ctx: wasmtime_wasi_http::WasiHttpCtx::new(), http_gate: HttpGate::new(namespace, http_allowlist, http_limits), + messaging_topics, run, log_router: router, ext: components.ext.clone(), chain: components.chain.clone(), + chain_response_max_bytes, store: module_store, + services, }, ); store.limiter(|state| &mut state.limits); @@ -394,6 +579,8 @@ impl Supervisor { Ok(store) } + // One flat argument per shared input threaded onto the store. + #[allow(clippy::too_many_arguments)] async fn load_one( engine: &Engine, linker: &Linker>, @@ -402,27 +589,12 @@ impl Supervisor { limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, + services: HostServices, + extension_kinds: &BTreeSet<&'static str>, + extensions: &[Arc>], + provider_manifests: &[ProviderManifest], ) -> Result> { - // Canonical name is module.toml (ADR-0001). nexum.toml is accepted - // with a deprecation warning during the 0.1→0.2 transition. - let manifest_path = entry.manifest.clone().or_else(|| { - let dir = entry.path.parent()?.to_owned(); - let canonical = dir.join("module.toml"); - if canonical.exists() { - return Some(canonical); - } - let legacy = dir.join("nexum.toml"); - if legacy.exists() { - warn!( - target: "manifest", - path = %legacy.display(), - "nexum.toml is deprecated; rename to module.toml \ - (ADR-0001). Support will be removed in 0.3." - ); - return Some(legacy); - } - None - }); + let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { Some(p) if p.exists() => { info!(manifest = %p.display(), "loading module manifest"); @@ -436,6 +608,21 @@ impl Supervisor { manifest::fallback_manifest() } }; + let module_namespace = if loaded_manifest.manifest.module.name.is_empty() { + "module".to_owned() + } else { + loaded_manifest.manifest.module.name.clone() + }; + + // Run the extension install predicates before any compile cost: + // every section must be claimed, and every claiming extension + // must admit the worker against the loaded providers' manifests. + let sections = &loaded_manifest.manifest.extensions; + enforce_extension_sections(&module_namespace, sections, extensions)?; + for ext in extensions { + ext.admit_worker(&module_namespace, sections, provider_manifests) + .with_context(|| format!("install refused for {}", entry.path.display()))?; + } // Compile + instantiate. info!(component = %entry.path.display(), "compiling component"); @@ -450,11 +637,6 @@ impl Supervisor { registry, ) .with_context(|| format!("capability violation in {}", entry.path.display()))?; - let module_namespace = if loaded_manifest.manifest.module.name.is_empty() { - "module".to_owned() - } else { - loaded_manifest.manifest.module.name.clone() - }; // Layer the manifest's `[module.resources]` over the engine `[limits]` // defaults: an unset override field keeps the engine default. let ResolvedLimits { @@ -477,10 +659,15 @@ impl Supervisor { run.clone(), loaded_manifest.http_allowlist.clone(), limits_cfg.http(), + // Event modules are unscoped for messaging; only providers + // carry a topic grant. + Vec::new(), memory, fuel, + limits_cfg.chain_response_max_bytes(), state_bytes, clocks, + services, )?; let bindings = EventModule::instantiate_async(&mut store, &component, linker) .await @@ -494,7 +681,7 @@ impl Supervisor { loaded_manifest.config.clone() }; // Whether `init` returned `Ok(())`. When `init` returns - // `Err(fault)` the module's strategy state (e.g. an + // `Err(fault)` the module's state (e.g. an // `OnceLock`) is left uninitialised. Existing M3 // example modules short-circuit on the missing state via // `SETTINGS.get().is_none() -> return Ok(())`, but future @@ -523,7 +710,7 @@ impl Supervisor { warn!( module = %module_namespace, kind = crate::host::error::fault_label(&e), - message = crate::host::error::fault_message(&e), + message = %crate::host::error::fault_message(&e), "init failed - module loaded but marked dead; dispatcher will skip it", ); false @@ -534,13 +721,23 @@ impl Supervisor { // Surface any `[[subscription]]` entries the host cannot // service yet, so an operator running 0.2 against a 0.3 - // manifest does not silently drop events. + // manifest does not silently drop events, and refuse an + // extension kind no wired extension declares. for sub in &loaded_manifest.manifest.subscriptions { - if matches!(sub, Subscription::Cron { .. }) { - warn!( + match sub { + Subscription::Cron { .. } => warn!( module = %module_namespace, "cron subscriptions are declared but inert in 0.2 (lands in 0.3)", - ); + ), + Subscription::Extension { kind, .. } + if !extension_kinds.contains(kind.as_str()) => + { + return Err(anyhow!( + "module {module_namespace} subscribes to unknown event kind {kind}; \ + no wired extension declares it" + )); + } + _ => {} } } @@ -561,6 +758,7 @@ impl Supervisor { init_config: config, http_allowlist: loaded_manifest.http_allowlist.clone(), http_limits: limits_cfg.http(), + chain_response_max_bytes: limits_cfg.chain_response_max_bytes(), failure_timestamps: std::collections::VecDeque::new(), poisoned: false, dispatch_bucket: crate::runtime::dispatch_rate::TokenBucket::new( @@ -570,17 +768,198 @@ impl Supervisor { }) } + /// Load one `[[adapters]]` entry: resolve its manifest and kind, + /// enforce the scoped-transport capabilities, build a supervised store + /// with the operator's grants, and hand the instance to its kind to + /// install. A failed `init` loads the provider dead and unroutable, + /// permanently. + // One flat argument per shared input threaded onto the store, matching + // the module load path. + #[allow(clippy::too_many_arguments)] + async fn load_provider( + engine: &Engine, + entry: &AdapterEntry, + components: &Components, + limits_cfg: &ModuleLimits, + registry: &CapabilityRegistry, + clocks: Option<&WasiClockOverride>, + kinds: &ProviderKinds, + extensions: &[Arc>], + ) -> Result { + let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); + let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { + Some(p) if p.exists() => { + info!(manifest = %p.display(), "loading provider manifest"); + manifest::load(p, registry)? + } + _ => { + warn!( + component = %entry.path.display(), + "no module.toml - falling back to anonymous provider" + ); + manifest::fallback_manifest() + } + }; + let namespace = if loaded_manifest.manifest.module.name.is_empty() { + "provider".to_owned() + } else { + loaded_manifest.manifest.module.name.clone() + }; + + // Run the extension install predicates before any compile cost: + // every section must be claimed, and every claiming extension + // must admit the provider's own sections. + let sections = loaded_manifest.manifest.extensions.clone(); + enforce_extension_sections(&namespace, §ions, extensions)?; + for ext in extensions { + ext.admit_provider(&namespace, §ions) + .with_context(|| format!("install refused for {}", entry.path.display()))?; + } + + // The manifest kind is the discriminator: an [[adapters]] entry + // must name a registered provider kind, caught here before + // instantiation. A fallback manifest has the default worker kind, + // so a provider must ship a module.toml that declares its kind + // explicitly. + let (kind, service) = match &loaded_manifest.manifest.module.kind { + ComponentKind::Worker => { + return Err(anyhow!( + "{} declares the worker kind; an [[adapters]] entry requires a \ + module.toml declaring a registered provider kind ({})", + entry.path.display(), + registered_kinds(kinds), + )); + } + ComponentKind::Provider(spelling) => kinds.get(spelling.as_str()).ok_or_else(|| { + anyhow!( + "{} declares unregistered provider kind {spelling}; registered \ + kinds: {}", + entry.path.display(), + registered_kinds(kinds), + ) + })?, + }; + + info!( + component = %entry.path.display(), + kind = kind.kind(), + "compiling provider component", + ); + let component = Component::from_file(engine, &entry.path) + .map_err(Error::from) + .with_context(|| format!("compile {}", entry.path.display()))?; + + // Enforce the scoped-transport capability set: `registry` is the + // provider registry, so a declaration of any core-only interface + // fails at manifest load, and an undeclared transport import fails + // here. The linker withholds the same core-only interfaces, so a + // provider reaching for one also fails to instantiate. + manifest::enforce_capabilities( + &loaded_manifest, + component.component_type().imports(engine).map(|(n, _)| n), + registry, + ) + .with_context(|| format!("capability violation in {}", entry.path.display()))?; + + info!( + provider = %namespace, + kind = kind.kind(), + fuel = limits_cfg.fuel(), + memory_bytes = limits_cfg.memory(), + http_allow = entry.http_allow.len(), + messaging_topics = entry.messaging_topics.len(), + "applied provider resource limits and transport scope", + ); + + let linker = build_provider_linker::(engine, kind.as_ref())?; + let run = RunId::new(namespace.clone(), 0); + // A provider links no service-consuming import, so its store carries + // an empty service map; the shared map holds the registry that owns + // the provider's store, and carrying it here would cycle. + let store = Self::build_store( + engine, + components, + run, + entry.http_allow.clone(), + limits_cfg.http(), + entry.messaging_topics.clone(), + limits_cfg.memory(), + limits_cfg.fuel(), + limits_cfg.chain_response_max_bytes(), + limits_cfg.state_bytes(), + clocks, + HostServices::default(), + )?; + + let config: Config = if loaded_manifest.config.is_empty() { + vec![("name".into(), namespace.clone())] + } else { + loaded_manifest.config.clone() + }; + let liveness = Liveness::default(); + let installed = kind + .install( + ProviderInstance { + component: &component, + linker: &linker, + store, + config: config.clone(), + sections: §ions, + fuel_per_call: limits_cfg.fuel(), + liveness: liveness.clone(), + }, + service, + ) + .await + .with_context(|| format!("install {}", entry.path.display()))?; + if installed == Installed::Dead { + liveness.mark_dead(); + } + Ok(LoadedProvider { + name: namespace, + kind: kind.kind(), + sections, + component, + init_config: config, + http_allow: entry.http_allow.clone(), + messaging_topics: entry.messaging_topics.clone(), + http_limits: limits_cfg.http(), + fuel_per_call: limits_cfg.fuel(), + memory_limit: limits_cfg.memory(), + chain_response_max_bytes: limits_cfg.chain_response_max_bytes(), + local_store_bytes: limits_cfg.state_bytes(), + liveness, + run_seq: 0, + alive: installed == Installed::Live, + failure_count: 0, + next_attempt: None, + failure_timestamps: std::collections::VecDeque::new(), + poisoned: false, + }) + } + /// Number of modules currently loaded. pub fn module_count(&self) -> usize { self.modules.len() } - /// Chains any **alive** module asked for block events on. Dead modules - /// (init-failed or currently in trap-backoff) are excluded so the - /// caller does not open live RPC subscriptions for chains with no - /// reachable module. The caller opens one shared block subscription - /// per chain and routes through `dispatch_block`. Sorted by numeric id - /// and deduped (`Chain` is not `Ord`, so this is not a `BTreeSet`). + /// Number of providers loaded at boot, alive or not. + pub fn adapter_count(&self) -> usize { + self.providers.len() + } + + /// Number of adapters currently alive and routable. + #[cfg_attr(not(test), allow(dead_code))] + pub fn adapter_alive_count(&self) -> usize { + self.providers + .iter() + .filter(|p| p.liveness.is_alive()) + .count() + } + + /// Chains any alive module subscribes to block events on. Dead modules + /// are excluded so no live subscription opens for an unreachable chain. + /// Sorted by numeric id and deduped. pub fn block_chains(&self) -> Vec { let mut out: Vec = Vec::new(); for module in self.modules.iter().filter(|m| m.alive) { @@ -595,14 +974,9 @@ impl Supervisor { out } - /// Per-module chain-log subscriptions for **alive** modules only. - /// Called once at launch, when a dead module can only mean init - /// failure (trap-backoff cannot exist yet); excluding them keeps the - /// caller from opening live log subscriptions no reachable module - /// consumes. Each entry names the module, chain, and filter the event - /// loop opens against the matching alloy provider; the resulting - /// stream tags every log with `module_name` so `dispatch_chain_log` - /// routes correctly. + /// Per-module chain-log subscriptions for alive modules only. Each + /// entry names the module, chain, and filter the event loop opens; the + /// stream tags every log with `module_name` for routing. pub fn chain_log_subscriptions(&self) -> Vec { let mut out = Vec::new(); for module in self.modules.iter().filter(|m| m.alive) { @@ -654,8 +1028,8 @@ impl Supervisor { out } - /// Read the persisted resume cursor for a chain-log subscription, or - /// `None` when absent / unreadable - both treated as "start at head". + /// Read the persisted resume cursor, or `None` when absent or + /// unreadable (both start at head). fn read_chain_log_cursor(&self, module: &str, key: &str) -> Option { let handle = self.components.store.module(module).ok()?; let bytes = handle.get(key).ok()??; @@ -663,29 +1037,22 @@ impl Supervisor { Some(u64::from_le_bytes(arr)) } - /// Dispatch a block event to every module subscribed to - /// `block.chain_id`. Returns the number of modules invoked. - /// Modules that trap are marked dead and excluded from future dispatch. - /// Rebuild a module from its cached `Component` + `init_config` - /// after a wasmtime trap. A trap leaves the original - /// `Store` + component instance in a poisoned state ("cannot - /// enter component instance" on the next call); the only way to - /// recover is to create a fresh `Store` + re-instantiate. The - /// `LoadedModule.subscriptions` and `LoadedModule.name` are - /// preserved so the dispatch routing keeps working. - /// - /// On success the module's `alive` flag is left for the caller - /// to flip; on failure (e.g. `init` returns Err again) the - /// module stays dead and the failure_count keeps climbing. + /// Rebuild a trapped module from its cached `Component` and + /// `init_config` on a fresh `Store` (the trapped instance is poisoned) + /// and re-run `init`, preserving name and subscriptions. On success the + /// caller flips `alive`; on failure the module stays dead and its + /// failure count keeps climbing. async fn reinstantiate_one(&mut self, idx: usize) -> Result<()> { // Re-build the linker: core interfaces plus every extension hook, // identical to the boot-time linker. Cheap `add_to_linker` calls // against the cached `Engine`. let linker = build_linker::(&self.engine, &self.extensions)?; - // Borrowed before the `&mut self.modules[idx]` reborrow so the - // restart path applies the same clock override as the initial boot. + // Borrowed before the `&mut self.modules[idx]` reborrow so the restart + // path applies the same clock override and the same shared services + // as the initial boot. let clocks = self.clocks.clone(); + let services = self.services.clone(); let module = &mut self.modules[idx]; // A restart is a new run: bump the sequence so its logs key // apart from the dead run's, which stays readable until evicted. @@ -696,10 +1063,13 @@ impl Supervisor { run.clone(), module.http_allowlist.clone(), module.http_limits, + Vec::new(), module.memory_limit, module.fuel_per_event, + module.chain_response_max_bytes, module.local_store_bytes, clocks.as_ref(), + services, )?; let bindings = EventModule::instantiate_async(&mut store, &module.component, &linker) .await @@ -758,6 +1128,7 @@ impl Supervisor { for idx in restart_candidates { self.try_restart(idx).await; } + self.sweep_providers().await; let mut dispatched = 0; let candidate_indices: Vec = (0..self.modules.len()) @@ -773,7 +1144,7 @@ impl Supervisor { .collect(); for idx in candidate_indices { if matches!( - self.dispatch_to(idx, chain, "block", block_number, &event) + self.dispatch_to(idx, chain_id, "block", block_number, &event) .await, DispatchOutcome::Ok, ) { @@ -809,10 +1180,9 @@ impl Supervisor { dispatched } - /// Dispatch a chain-log event to the specific module that opened the - /// subscription. Returns `true` when the module accepted the dispatch; - /// `false` when the module is dead, not found, or its callback failed. - /// A trapping module is marked dead and excluded from future dispatch. + /// Dispatch a chain-log event to the module that opened the + /// subscription. Returns `true` when accepted; `false` when the module + /// is dead, missing, or its callback failed. A trap marks it dead. pub async fn dispatch_chain_log( &mut self, module_name: &str, @@ -821,6 +1191,7 @@ impl Supervisor { cursor_key: Option<&str>, ) -> bool { let now = std::time::Instant::now(); + self.sweep_providers().await; let Some(idx) = self.modules.iter().position(|m| m.name == module_name) else { warn!(module = %module_name, "no such module - dropping chain-log"); return false; @@ -857,7 +1228,7 @@ impl Supervisor { let ok = matches!( self.dispatch_to( idx, - chain, + chain.id(), "chain-log", block_number.unwrap_or_default(), &event @@ -891,20 +1262,83 @@ impl Supervisor { ok } - /// Shared per-module dispatch path: refuel, call `on_event`, and - /// process the three outcomes (ok / fault / trap) with the - /// same telemetry + lifecycle bookkeeping. Returns whether the - /// guest call succeeded; the caller layers any path-specific - /// follow-up (e.g. the progress marker on `dispatch_block`). + /// Dispatch one extension event to every module whose subscription kind + /// and filters match. Returns the number invoked. Like `dispatch_block`: + /// dead modules past backoff restart first, poisoned modules skip. + pub async fn dispatch_extension_event(&mut self, event: ExtensionEvent) -> usize { + let now = std::time::Instant::now(); + let restart_candidates: Vec = (0..self.modules.len()) + .filter(|&i| { + let m = &self.modules[i]; + !m.poisoned && !m.alive && m.next_attempt.is_some_and(|t| t <= now) + }) + .collect(); + for idx in restart_candidates { + self.try_restart(idx).await; + } + self.sweep_providers().await; + + let candidate_indices: Vec = (0..self.modules.len()) + .filter(|&i| { + let m = &self.modules[i]; + if m.poisoned || !m.alive { + return false; + } + m.subscriptions.iter().any(|s| { + matches!( + s, + Subscription::Extension { kind, filters } + if kind == event.kind && filters.iter().all(|(fk, fv)| { + event.attrs.iter().any(|(ak, av)| ak == fk && av == fv) + }) + ) + }) + }) + .collect(); + let mut dispatched = 0; + for idx in candidate_indices { + // Extension events are not chain-scoped: the telemetry chain + // id and block number carry the 0 sentinel. + if matches!( + self.dispatch_to(idx, 0, event.kind, 0, &event.event).await, + DispatchOutcome::Ok, + ) { + dispatched += 1; + } + } + dispatched + } + + /// Extension subscription kinds at least one loaded module declares. An + /// extension opens an event source only when its kind appears here. + pub fn extension_subscription_kinds(&self) -> BTreeSet { + self.modules + .iter() + .flat_map(|m| m.subscriptions.iter()) + .filter_map(|s| match s { + Subscription::Extension { kind, .. } => Some(kind.clone()), + _ => None, + }) + .collect() + } + + /// The extension-owned services, shared by every module store. + pub fn services(&self) -> &HostServices { + &self.services + } + + /// Shared per-module dispatch: refuel, call `on_event`, handle the + /// three outcomes (ok / fault / trap) with the same telemetry and + /// lifecycle bookkeeping. Returns whether the guest call succeeded. + /// `chain_id` is telemetry only; chain-less kinds pass 0. async fn dispatch_to( &mut self, idx: usize, - chain: Chain, + chain_id: u64, event_kind: &'static str, block_number: u64, event: &nexum::host::types::Event, ) -> DispatchOutcome { - let chain_id = chain.id(); let poison_policy = self.poison_policy; // Hoisted before the per-module borrow so the trap arm can // synthesize a panic record without re-borrowing `self`. @@ -994,7 +1428,7 @@ impl Supervisor { block_number, latency_ms, kind, - message = crate::host::error::fault_message(&fault), + message = %crate::host::error::fault_message(&fault), "on-event returned fault", ); metrics::counter!( @@ -1047,10 +1481,8 @@ impl Supervisor { } } - /// Attempt to re-instantiate a dead module in place. On success - /// the module is marked `alive`; on failure the failure counter - /// is bumped and `next_attempt` slides further out per the - /// restart-policy backoff. Used by both dispatch paths. + /// Re-instantiate a dead module in place. On success mark it `alive`; + /// on failure bump the counter and slide `next_attempt` per the backoff. async fn try_restart(&mut self, idx: usize) { let name = self.modules[idx].name.clone(); let failure_count = self.modules[idx].failure_count; @@ -1083,25 +1515,148 @@ impl Supervisor { } } - /// Count of modules currently alive. A module is not alive when its - /// `init` returned `Err` (permanent, never retried) or when `on_event` - /// trapped and its restart backoff has not yet elapsed. + /// Fold providers into recovery: record any trap the shared liveness + /// reports (backoff plus poison), then reinstall dead, unpoisoned + /// providers past their backoff. Runs at the head of every dispatch. + async fn sweep_providers(&mut self) { + let now = std::time::Instant::now(); + let policy = self.poison_policy; + for idx in 0..self.providers.len() { + let provider = &mut self.providers[idx]; + if provider.alive + && let Some(died_at) = provider.liveness.dead_since() + { + provider.alive = false; + provider.failure_count = provider.failure_count.saturating_add(1); + let backoff = crate::runtime::restart_policy::backoff_for(provider.failure_count); + // Backoff counts from the death, not from this sweep, so a + // trap whose backoff already elapsed restarts right below. + provider.next_attempt = Some(died_at.checked_add(backoff).unwrap_or(now)); + warn!( + adapter = %provider.name, + failure_count = provider.failure_count, + backoff_ms = backoff.as_millis() as u64, + "adapter trapped - marked dead; will restart after backoff", + ); + metrics::counter!( + "shepherd_adapter_errors_total", + "adapter" => provider.name.clone(), + "error_kind" => "trap", + ) + .increment(1); + if let Some(recent) = poison_crossed(&mut provider.failure_timestamps, policy) + && !provider.poisoned + { + provider.poisoned = true; + warn!( + adapter = %provider.name, + recent_failures = recent, + window_secs = policy.window.as_secs(), + "adapter poisoned - quarantined; remove from engine.toml + restart to clear", + ); + metrics::gauge!( + "shepherd_adapter_poisoned", + "adapter" => provider.name.clone(), + ) + .set(1.0); + } + } + let provider = &self.providers[idx]; + if !provider.poisoned + && !provider.alive + && provider.next_attempt.is_some_and(|t| t <= now) + { + self.try_restart_provider(idx).await; + } + } + } + + /// Reinstall a dead provider in place (fresh store, instance, `init`, + /// re-install). On success revive the shared liveness; on failure slide + /// the backoff. + async fn try_restart_provider(&mut self, idx: usize) { + let name = self.providers[idx].name.clone(); + let failure_count = self.providers[idx].failure_count; + info!(adapter = %name, failure_count, "adapter restart attempt"); + metrics::counter!( + "shepherd_adapter_restarts_total", + "adapter" => name.clone(), + ) + .increment(1); + let outcome = self.reinstall_provider(idx).await; + let provider = &mut self.providers[idx]; + match outcome { + Ok(Installed::Live) => { + provider.run_seq += 1; + provider.liveness.mark_alive(); + provider.alive = true; + provider.failure_count = 0; + provider.next_attempt = None; + info!(adapter = %name, "adapter restart succeeded"); + } + Ok(Installed::Dead) => { + defer_provider_restart(provider, "init returned fault on restart"); + } + Err(e) => defer_provider_restart(provider, &format!("{e:#}")), + } + } + + /// Rebuild a provider from its cached component and grants and reinstall + /// it over the dead slot. + async fn reinstall_provider(&mut self, idx: usize) -> Result { + let provider = &self.providers[idx]; + let (kind, service) = self + .kinds + .get(provider.kind) + .ok_or_else(|| anyhow!("provider kind {} is not registered", provider.kind))?; + let linker = build_provider_linker::(&self.engine, kind.as_ref())?; + // A restart is a new run, like a module's. + let run = RunId::new(provider.name.clone(), provider.run_seq + 1); + let store = Self::build_store( + &self.engine, + &self.components, + run, + provider.http_allow.clone(), + provider.http_limits, + provider.messaging_topics.clone(), + provider.memory_limit, + provider.fuel_per_call, + provider.chain_response_max_bytes, + provider.local_store_bytes, + self.clocks.as_ref(), + HostServices::default(), + )?; + kind.install( + ProviderInstance { + component: &provider.component, + linker: &linker, + store, + config: provider.init_config.clone(), + sections: &provider.sections, + fuel_per_call: provider.fuel_per_call, + liveness: provider.liveness.clone(), + }, + service, + ) + .await + } + + /// Modules currently alive. Not alive when `init` returned `Err` + /// (permanent) or a trap's backoff has not elapsed. pub fn alive_count(&self) -> usize { self.modules.iter().filter(|m| m.alive).count() } - /// True when at least one init-failed module declared subscriptions. - /// Lets the launch path distinguish "no manifest declares any - /// `[[subscription]]`" (benign: exit cleanly) from "every declared - /// subscription belongs to a dead module" (operator error: abort). + /// True when an init-failed module declared subscriptions. Lets the + /// launch path tell "no subscriptions declared" (benign) from "every + /// declared subscription belongs to a dead module" (operator error). pub fn dead_modules_hold_subscriptions(&self) -> bool { self.modules .iter() .any(|m| !m.alive && !m.subscriptions.is_empty()) } - /// Also expose a per-module poisoned state for - /// metrics + integration tests. + /// Modules currently poisoned. #[cfg_attr(not(test), allow(dead_code))] pub fn poisoned_count(&self) -> usize { self.modules.iter().filter(|m| m.poisoned).count() @@ -1109,16 +1664,13 @@ impl Supervisor { } /// Build a `Linker` binding the core `event-module` interfaces plus every -/// extension's own interfaces for `HostState`. Shared by the supervisor -/// restart path and the bootstrap launch path. -/// -/// Extension hooks run after the core interfaces. A module that imports an -/// extension interface instantiates only if that extension's hook is -/// present here, so the same `extensions` slice must drive both this linker -/// and capability enforcement via the crate-internal `capability_registry`. +/// extension's interfaces. Shared by the restart and launch paths. A module +/// importing an extension interface instantiates only if that extension's +/// hook is present, so the same `extensions` slice must drive this and +/// capability enforcement. pub fn build_linker( engine: &Engine, - extensions: &[Extension], + extensions: &[Arc>], ) -> anyhow::Result>> { let mut linker = Linker::>::new(engine); EventModule::add_to_linker::, HasSelf>>(&mut linker, |state| state)?; @@ -1127,29 +1679,69 @@ pub fn build_linker( // wasi:io/wasi:clocks interfaces. wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; for ext in extensions { - (ext.link)(&mut linker)?; + ext.link(&mut linker)?; } Ok(linker) } +/// Build a `Linker` for one provider kind: the kind's scoped imports plus +/// the WASI base and allowlisted `wasi:http`. Core `nexum:host` interfaces +/// (local-store, remote-store, identity, logging) are withheld, so a +/// provider importing one fails to instantiate. Extensions are not linked +/// into providers. +pub fn build_provider_linker( + engine: &Engine, + kind: &dyn ProviderKind, +) -> anyhow::Result>> { + let mut linker = Linker::>::new(engine); + kind.link(&mut linker)?; + wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; + wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; + Ok(linker) +} + +/// Resolve a component's manifest: explicit override, else sibling +/// `module.toml`, else the deprecated `nexum.toml` with a rename warning. +/// `None` when neither exists. +fn resolve_manifest_path(component: &Path, explicit: Option<&Path>) -> Option { + if let Some(path) = explicit { + return Some(path.to_path_buf()); + } + // Canonical name is module.toml (ADR-0001). nexum.toml is accepted + // with a deprecation warning during the 0.1->0.2 transition. + let dir = component.parent()?.to_owned(); + let canonical = dir.join("module.toml"); + if canonical.exists() { + return Some(canonical); + } + let legacy = dir.join("nexum.toml"); + if legacy.exists() { + warn!( + target: "manifest", + path = %legacy.display(), + "nexum.toml is deprecated; rename to module.toml \ + (ADR-0001). Support will be removed in 0.3." + ); + return Some(legacy); + } + None +} + /// Assemble the capability registry from the core namespace plus every -/// extension's namespace. The result must agree with the linker built from -/// the same `extensions`: enforcement recognises an extension import as a -/// declared capability only when its namespace is registered here. +/// extension's. Must agree with the linker built from the same `extensions`. pub(crate) fn capability_registry( - extensions: &[Extension], + extensions: &[Arc>], ) -> CapabilityRegistry { let mut registry = CapabilityRegistry::core(); for ext in extensions { - registry.register(ext.capabilities); + registry.register(ext.capabilities()); } registry } -/// A guest dispatch, guest execution plus every host call it awaited, -/// outlived its wall-clock deadline and was cancelled. Distinct from a -/// fuel trap: fuel bounds guest instructions, this bounds time spent in -/// host calls (chain RPC, redb, HTTP), which fuel does not meter. +/// A dispatch (guest plus every host call it awaited) outlived its +/// wall-clock deadline and was cancelled. Distinct from a fuel trap, which +/// bounds guest instructions. #[derive(Debug, thiserror::Error)] #[error( "dispatch exceeded its {0:?} wall-clock deadline \ @@ -1157,17 +1749,11 @@ pub(crate) fn capability_registry( )] struct DeadlineExceeded(Duration); -/// Run a guest dispatch future under a wall-clock `deadline`. -/// -/// Fuel and epoch metering bound only *guest* instructions; time spent -/// inside a host call is unmetered (see [`crate::runtime::limits`]), so -/// without this a module could park the dispatch indefinitely behind a -/// cheap-in-fuel host call. Returns `Err(DeadlineExceeded)` once the -/// future, guest plus every host call it awaited, outlives `deadline`; -/// dropping the future on timeout cancels the in-flight host call at its -/// next await point. Pure guest CPU spinning stays fuel's job: a future -/// that never yields cannot be interrupted here, which is exactly why -/// fuel and this deadline are complementary rather than redundant. +/// Run a guest dispatch future under a wall-clock `deadline`. Fuel bounds +/// only guest instructions, so this bounds time in host calls (see +/// [`crate::runtime::limits`]). Returns `Err(DeadlineExceeded)` once the +/// future outlives `deadline`; dropping it cancels the in-flight host call +/// at its next await point. Pure guest spinning stays fuel's job. async fn with_dispatch_deadline( deadline: Duration, fut: F, @@ -1177,53 +1763,55 @@ async fn with_dispatch_deadline( .map_err(|_elapsed| DeadlineExceeded(deadline)) } -/// Outcome of [`Supervisor::dispatch_to`] for a single module. -/// -/// Returned to the caller so path-specific follow-ups (e.g. the -/// progress marker on the block path) can branch on whether -/// the guest actually ran cleanly. Kept private; only the two -/// `dispatch_*` entry points consume it. +/// Outcome of [`Supervisor::dispatch_to`] for one module. Private; only +/// the `dispatch_*` entry points consume it. #[derive(Debug, Eq, PartialEq)] enum DispatchOutcome { /// Guest returned `Ok(())`. Ok, /// Guest returned a typed `fault` via WIT. Fault, - /// Guest trapped (panic / OOM / fuel exhaustion / etc.). Module - /// has been marked dead and may be quarantined per the - /// poison-policy. + /// Guest trapped (panic / OOM / fuel / etc). Marked dead, maybe + /// quarantined per the poison policy. Trapped, - /// `set_fuel` failed before the call. Module is left alive but - /// this event is skipped. + /// `set_fuel` failed before the call; the module stays alive, this + /// event is skipped. Skipped, - /// The per-module dispatch rate limit was exceeded. The event is - /// dropped before the guest runs; the module stays alive and its - /// failure / poison state is untouched. + /// Per-module dispatch rate limit exceeded; the event is dropped before + /// the guest runs, liveness untouched. RateLimited, } -/// Push the current trap timestamp into the module's -/// failure-window ring, drop entries older than the policy window, -/// and flip `poisoned = true` once the window holds more than -/// `policy.max_failures` traps. The first transition emits the -/// `shepherd_module_poisoned` gauge + a structured WARN. -fn record_failure_and_maybe_poison( - module: &mut LoadedModule, +/// Push the current trap timestamp into a component's failure-window ring, +/// drop entries older than the window, and report the recent count once it +/// crosses `policy.max_failures`. +fn poison_crossed( + failure_timestamps: &mut std::collections::VecDeque, policy: crate::runtime::poison_policy::PoisonPolicy, - last_error: &str, -) { +) -> Option { let now = std::time::Instant::now(); - // Prune entries outside the window. - while let Some(&front) = module.failure_timestamps.front() { + while let Some(&front) = failure_timestamps.front() { if now.duration_since(front) > policy.window { - module.failure_timestamps.pop_front(); + failure_timestamps.pop_front(); } else { break; } } - module.failure_timestamps.push_back(now); - let recent = module.failure_timestamps.len() as u32; - if crate::runtime::poison_policy::should_poison(policy, recent) && !module.poisoned { + failure_timestamps.push_back(now); + let recent = failure_timestamps.len() as u32; + crate::runtime::poison_policy::should_poison(policy, recent).then_some(recent) +} + +/// Flip `poisoned` once the module's failure window crosses the threshold; +/// the first transition emits the gauge and a WARN. +fn record_failure_and_maybe_poison( + module: &mut LoadedModule, + policy: crate::runtime::poison_policy::PoisonPolicy, + last_error: &str, +) { + if let Some(recent) = poison_crossed(&mut module.failure_timestamps, policy) + && !module.poisoned + { module.poisoned = true; warn!( module = %module.name, @@ -1240,15 +1828,28 @@ fn record_failure_and_maybe_poison( } } +/// Slide a failed provider restart's next attempt further out. +fn defer_provider_restart(provider: &mut LoadedProvider, error: &str) { + provider.failure_count = provider.failure_count.saturating_add(1); + let backoff = crate::runtime::restart_policy::backoff_for(provider.failure_count); + provider.next_attempt = Some(std::time::Instant::now() + backoff); + error!( + adapter = %provider.name, + failure_count = provider.failure_count, + backoff_ms = backoff.as_millis() as u64, + error, + "adapter restart failed - will retry after backoff", + ); +} + /// Persisted per-chain progress key; must stay numeric for data compat. fn progress_key(chain: Chain) -> String { format!("last_dispatched_block:{}", chain.id()) } -/// A resolved chain-log subscription for the event loop: the owning -/// module, the chain + alloy `Filter`, and - when the subscription opted -/// into `resume` - the durable cursor key plus the block to resume from -/// (read from the store at boot). +/// A resolved chain-log subscription for the event loop: owning module, +/// chain, alloy `Filter`, and, when `resume` is set, the durable cursor key +/// and resume block. pub struct ChainLogSub { /// Module that declared the subscription; also its store namespace. pub module: String, @@ -1256,23 +1857,21 @@ pub struct ChainLogSub { pub chain: Chain, /// Alloy filter the poller opens with. pub filter: alloy_rpc_types_eth::Filter, - /// `Some` iff `resume = true`: the store key the resume cursor is read - /// and written under. + /// `Some` iff `resume = true`: the store key the resume cursor lives + /// under. pub cursor_key: Option, /// The persisted resume block, read at boot for a `resume` - /// subscription; `None` on first run or when `resume` is off. + /// subscription; `None` otherwise. pub initial_cursor: Option, - /// Opt-in cap on how far back the poller backfills, in blocks. `None` - /// backfills the whole gap; `Some(cap)` bounds the start to - /// `head - cap`, dropping the oldest missed blocks. + /// Opt-in cap on backfill depth, in blocks. `None` backfills the whole + /// gap; `Some(cap)` bounds the start to `head - cap`. pub max_lookback: Option, } /// Durable resume-cursor key for a chain-log subscription. Derived from -/// the normalized manifest inputs - NOT the alloy `Filter`, whose hash -/// uses a process-randomized `HashSet` and is not reproducible across -/// restarts. Stable and independent of `[[subscription]]` ordering. The -/// module name is the store namespace, so it is not part of the digest. +/// normalized manifest inputs, not the alloy `Filter` (whose hash is +/// process-randomized), so it is stable across restarts and subscription +/// ordering. fn chainlog_cursor_key( chain: Chain, address: Option<&str>, @@ -1291,10 +1890,8 @@ fn chainlog_cursor_key( } impl From<&alloy_rpc_types_eth::Log> for nexum::host::types::ChainLog { - /// Project an alloy `Log` onto the WIT `chain-log` record, preserving every - /// RPC field so the guest reconstructs the alloy log without loss. The chain - /// id is not on the alloy log; the subscription context supplies it at the - /// `chain-logs` batch level. + /// Project an alloy `Log` onto the WIT `chain-log` record without loss. + /// The chain id is not on the alloy log; the batch level supplies it. fn from(log: &alloy_rpc_types_eth::Log) -> Self { Self { address: log.address().as_slice().to_vec(), @@ -1312,15 +1909,6 @@ impl From<&alloy_rpc_types_eth::Log> for nexum::host::types::ChainLog { } /// Errors surfaced by [`build_alloy_filter`]. -/// -/// Variants thread the underlying alloy parse error via `#[source]` -/// instead of `to_string()`-ing it - keeps the typed chain intact for -/// the supervisor's `tracing::warn!(error = %err, ...)` log line at -/// the call site (where the `Display` chain prints the parse detail). -/// -/// `IntoStaticStr` exposes the snake_case variant name as a -/// `&'static str` so the warn log can carry -/// `error_kind = address | topic` without a match-ladder. #[derive(Debug, thiserror::Error, strum::IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/nexum/crates/nexum-runtime/src/supervisor/tests.rs similarity index 76% rename from crates/nexum-runtime/src/supervisor/tests.rs rename to nexum/crates/nexum-runtime/src/supervisor/tests.rs index 6ce381b5..a16ae712 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/nexum/crates/nexum-runtime/src/supervisor/tests.rs @@ -28,6 +28,103 @@ fn manifest_resource_overrides_take_effect_and_are_field_local() { assert_eq!(resolved.state_bytes, 2048); } +/// A manifest section a wired extension claims passes; an unclaimed one +/// (a typo, or a section for an unwired extension) is refused. +#[test] +fn extension_sections_must_be_claimed() { + struct Claiming; + impl Extension for Claiming { + fn namespace(&self) -> &'static str { + "acme" + } + fn capabilities(&self) -> crate::manifest::NamespaceCaps { + crate::manifest::NamespaceCaps { + prefix: "acme:ext/", + ifaces: &[], + } + } + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + Ok(()) + } + fn manifest_sections(&self) -> &'static [&'static str] { + &["venue"] + } + } + let extensions: Vec>> = vec![Arc::new(Claiming)]; + + let mut sections = manifest::ExtensionSections::new(); + sections.insert("venue".into(), toml::Value::Boolean(true)); + enforce_extension_sections("keeper", §ions, &extensions).expect("claimed section"); + + sections.insert("venu".into(), toml::Value::Boolean(true)); + let err = enforce_extension_sections("keeper", §ions, &extensions) + .expect_err("unclaimed section"); + assert!(err.to_string().contains("[venu]"), "{err}"); + assert!(err.to_string().contains("keeper"), "{err}"); +} + +/// Two extensions colliding on a subscription kind or a manifest section +/// are refused at boot; a non-colliding set passes the uniqueness pass. +#[test] +fn extension_claims_must_be_unique() { + struct Claiming { + namespace: &'static str, + subscriptions: &'static [&'static str], + sections: &'static [&'static str], + } + impl Extension for Claiming { + fn namespace(&self) -> &'static str { + self.namespace + } + fn capabilities(&self) -> crate::manifest::NamespaceCaps { + crate::manifest::NamespaceCaps { + prefix: "acme:ext/", + ifaces: &[], + } + } + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + Ok(()) + } + fn subscriptions(&self) -> &'static [&'static str] { + self.subscriptions + } + fn manifest_sections(&self) -> &'static [&'static str] { + self.sections + } + } + fn ext( + namespace: &'static str, + subscriptions: &'static [&'static str], + sections: &'static [&'static str], + ) -> Arc> { + Arc::new(Claiming { + namespace, + subscriptions, + sections, + }) + } + + enforce_extension_uniqueness(&[ + ext("a", &["orders"], &["venue"]), + ext("b", &["fills"], &["pool"]), + ]) + .expect("non-colliding set boots"); + + let err = enforce_extension_uniqueness(&[ + ext("a", &["orders"], &["venue"]), + ext("b", &["orders"], &["pool"]), + ]) + .expect_err("duplicate subscription kind"); + assert!(err.to_string().contains("orders"), "{err}"); + + let err = enforce_extension_uniqueness(&[ + ext("a", &["orders"], &["venue"]), + ext("b", &["fills"], &["venue"]), + ]) + .expect_err("duplicate manifest section"); + assert!(err.to_string().contains("[venue]"), "{err}"); +} + #[tokio::test] async fn empty_supervisor_returns_no_subscriptions() { let engine = make_wasmtime_engine(); @@ -38,27 +135,16 @@ async fn empty_supervisor_returns_no_subscriptions() { } /// Data-compat guard: the persisted progress marker keys on the numeric -/// chain id, never the `Chain` `Display` name. A named chain must still -/// yield `last_dispatched_block:11155111`, not `...:sepolia`, so existing -/// redb entries keep resolving after this refactor. +/// chain id, so a named chain still yields `last_dispatched_block:11155111`. #[test] fn progress_marker_key_uses_numeric_chain_id() { let chain = Chain::from_id(11_155_111); assert_eq!(progress_key(chain), "last_dispatched_block:11155111"); } -/// Regression guard: engines whose modules only declare -/// `[[subscription]] kind = "block"` (or only `kind = "chain-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. +/// An engine whose modules declare only `kind = "block"` (or only +/// `kind = "chain-log"`) must not bail at boot when the other stream set +/// is empty. #[tokio::test] async fn run_does_not_bail_when_both_stream_kinds_are_empty() { use std::time::{Duration, Instant}; @@ -72,6 +158,7 @@ async fn run_does_not_bail_when_both_stream_kinds_are_empty() { &mut supervisor, Vec::new(), Vec::new(), + Vec::new(), nexum_tasks::TaskSet::new(), shutdown, ) @@ -88,27 +175,139 @@ async fn run_does_not_bail_when_both_stream_kinds_are_empty() { ); } +// ── event_loop integration tests (#56 + #58) ───────────────────────── +// +// Verify the stream-open + run() + shutdown lifecycle end to end at the +// supervisor boundary, without loading a real wasm module. + +/// The `biased` select drains both block and chain-log streams within one +/// `run()` session without starving either; the returned tally shows both +/// were consumed. +#[tokio::test] +async fn run_delivers_block_and_chain_log_events_without_starvation() { + use std::time::Duration; + + use alloy_chains::Chain; + use alloy_rpc_types_eth::Filter; + + use crate::runtime::event_loop::{open_block_streams, open_chain_log_streams, run}; + use crate::test_utils::MockChainProvider; + use nexum_tasks::{TaskManager, TaskSet}; + + let engine = make_wasmtime_engine(); + let mut supervisor = boot_mock_supervisor(&engine).await; + let pool = MockChainProvider::new(); + let manager = TaskManager::new(); + let executor = manager.executor(); + let mut tasks = TaskSet::new(); + + // Pre-push one event of each kind before the loop starts so both mpsc + // channels have an item for `run()` to drain on its first pass. + pool.push_block(alloy_rpc_types_eth::Header::default()); + pool.push_chain_log(alloy_rpc_types_eth::Log::default()); + + let block_streams = open_block_streams(&pool, &[Chain::mainnet()], &executor, &mut tasks); + let log_subs = vec![crate::supervisor::ChainLogSub { + module: "test-module".to_string(), + chain: Chain::mainnet(), + filter: Filter::default(), + cursor_key: None, + initial_cursor: None, + max_lookback: None, + }]; + let chain_log_streams = open_chain_log_streams(&pool, log_subs, &executor, &mut tasks); + + // The shutdown window only bounds wall time; the assertion is on the + // tally, not on timing. 500 ms is orders of magnitude more than the + // two channel hops need, so a miss means a broken select arm, not a + // slow scheduler. + let shutdown = tokio::time::sleep(Duration::from_millis(500)); + let (blocks, chain_logs) = tokio::time::timeout( + Duration::from_secs(10), + run( + &mut supervisor, + block_streams, + chain_log_streams, + Vec::new(), + tasks, + shutdown, + ), + ) + .await + .expect("run() must return once shutdown fires"); + assert_eq!(blocks, 1, "the queued block must be drained and dispatched"); + assert_eq!( + chain_logs, 1, + "the queued chain-log must be drained and dispatched", + ); +} + +/// On the shutdown path `run()` aborts and joins every reconnect task, so +/// none detaches and outlives the engine. +#[tokio::test] +async fn run_drains_reconnect_tasks_cleanly_on_shutdown() { + use std::time::Duration; + + use alloy_chains::Chain; + + use crate::runtime::event_loop::{open_block_streams, run}; + use crate::test_utils::MockChainProvider; + use nexum_tasks::{TaskManager, TaskSet}; + + let engine = make_wasmtime_engine(); + let mut supervisor = boot_mock_supervisor(&engine).await; + let pool = MockChainProvider::new(); + let manager = TaskManager::new(); + let executor = manager.executor(); + let mut tasks = TaskSet::new(); + + // Two subscription tasks — both must drain before `run()` returns. + let block_streams = open_block_streams( + &pool, + &[Chain::mainnet(), Chain::from_id(100)], + &executor, + &mut tasks, + ); + + let shutdown = tokio::time::sleep(Duration::from_millis(10)); + // If the drain were absent, the spawned reconnect tasks would detach + // and outlive the supervisor; if the drain hung, the timeout fails + // fast instead of stalling the suite until the CI job limit. + tokio::time::timeout( + Duration::from_secs(10), + run( + &mut supervisor, + block_streams, + vec![], + Vec::new(), + tasks, + shutdown, + ), + ) + .await + .expect("run() + task drain must complete promptly after shutdown"); +} + // ── E2E helpers ─────────────────────────────────────────────────────── -/// Path to the pre-built example WASM component. Tests that need it -/// call `example_wasm_or_skip()` which skips gracefully if absent. +/// Workspace root: the topmost ancestor with a `Cargo.toml`. +fn workspace_root() -> PathBuf { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + manifest + .ancestors() + .filter(|d| d.join("Cargo.toml").is_file()) + .last() + .unwrap_or(manifest) + .to_path_buf() +} + +/// Path to the pre-built example WASM component. fn example_wasm() -> PathBuf { - // CARGO_MANIFEST_DIR → crates/nexum-runtime - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("target/wasm32-wasip2/release/example.wasm") + workspace_root().join("target/wasm32-wasip2/release/example.wasm") } fn example_module_toml() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("modules/example/module.toml") + workspace_root().join("nexum/modules/example/module.toml") } /// Returns `None` and prints a skip message if the fixture isn't built. @@ -132,9 +331,8 @@ fn make_wasmtime_engine() -> wasmtime::Engine { wasmtime::Engine::new(&config).expect("wasmtime engine") } -/// The core-only extension set: no domain extensions. Domain-extension -/// boot coverage lives in the extension crate that owns the backend. -fn core_extensions() -> Vec> { +/// The core-only extension set: no domain extensions. +fn core_extensions() -> Vec>> { Vec::new() } @@ -153,10 +351,8 @@ fn test_components(store: crate::host::local_store_redb::LocalStore) -> Componen } } -/// Return `(dir, store)` so the test holds the `TempDir` for the -/// duration of the test scope and cleans it up on drop. Forgetting -/// the dir (the old `ManuallyDrop` approach) leaks it for the -/// entire process lifetime. +/// Return `(dir, store)` so the test holds the `TempDir` and cleans it up +/// on drop. fn temp_local_store() -> (tempfile::TempDir, crate::host::local_store_redb::LocalStore) { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("ls.redb"); @@ -165,8 +361,7 @@ fn temp_local_store() -> (tempfile::TempDir, crate::host::local_store_redb::Loca } /// Boot a zero-module supervisor over the in-process mock backends via the -/// real `boot` path. The default config declares no modules, so `boot` -/// returns with an empty module set, touching neither disk nor network. +/// real `boot` path. async fn boot_mock_supervisor( engine: &wasmtime::Engine, ) -> Supervisor { @@ -210,6 +405,43 @@ async fn e2e_supervisor_boots_example_module() { assert_eq!(supervisor.alive_count(), 1); } +/// The example component's capability-bearing imports are exactly what its +/// manifest declares (`logging`). +#[test] +fn e2e_example_component_imports_equal_declared_capabilities() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + let engine = make_wasmtime_engine(); + let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); + let imports: Vec = component + .component_type() + .imports(&engine) + .map(|(name, _)| name.to_owned()) + .collect(); + + // Capability-bearing imports resolve to exactly the declared set. + let registry = CapabilityRegistry::core(); + let caps: std::collections::BTreeSet<&str> = imports + .iter() + .filter_map(|name| registry.wit_import_to_cap(name)) + .collect(); + assert_eq!( + caps, + std::collections::BTreeSet::from(["logging"]), + "imports were: {imports:?}" + ); + + // No extension interface leaks in either: the per-module world holds + // exactly what the manifest declared. + assert!( + imports + .iter() + .all(|name| name.starts_with("nexum:host/") || name.starts_with("wasi:")), + "imports were: {imports:?}" + ); +} + /// Boot with a manifest that subscribes to block events; dispatch one /// block event and verify the module was invoked and stayed alive. #[tokio::test] @@ -266,9 +498,8 @@ chain_id = 1 } /// A `ManualClock` override threads through `boot_single` onto the module -/// store and is behaviour-neutral: the module boots, dispatches a block, and -/// stays alive exactly as it does on the ambient clock. Locks the plumbing so -/// the seam keeps reaching the store on the boot path. +/// store and is behaviour-neutral: the module boots, dispatches a block, +/// and stays alive as on the ambient clock. #[cfg(feature = "test-utils")] #[tokio::test] async fn e2e_manual_clock_override_boots_and_dispatches() { @@ -342,24 +573,17 @@ chain_id = 1 // // One test per module that goes through the real wit-bindgen + // WitBindgenHost adapter + supervisor dispatch path, not just the -// strategy-level MockHost coverage. Mirrors the example-module e2e +// module-level MockHost coverage. Mirrors the example-module e2e // shape above; each test is guarded by `module_wasm_or_skip()` so // local runs without a fresh `--target wasm32-wasip2 --release` // build are skipped rather than failing. const SEPOLIA: u64 = 11_155_111; -/// Path to a production module's .wasm artefact under the workspace -/// target dir. `Cargo` writes the artefact as `.wasm` with -/// hyphens replaced by underscores, so the helper mirrors that. +/// A production module's built `.wasm`; hyphens in the name become underscores. fn module_wasm(module_name: &str) -> PathBuf { let artifact = module_name.replace('-', "_"); - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join(format!("target/wasm32-wasip2/release/{artifact}.wasm")) + workspace_root().join(format!("target/wasm32-wasip2/release/{artifact}.wasm")) } fn module_wasm_or_skip(module_name: &str) -> Option { @@ -383,17 +607,9 @@ fn module_wasm_or_skip(module_name: &str) -> Option { } } -/// Resolve a real `module.toml` for one of the production modules. -/// Looking up the real manifest (rather than synthesising one) keeps -/// the integration test honest about the capability set + subscription -/// shape each module actually ships. +/// Resolve the real `module.toml` for one of the production modules. fn production_module_toml(relative_path: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join(relative_path) + workspace_root().join(relative_path) } fn synthetic_sepolia_block() -> nexum::host::types::Block { @@ -406,7 +622,7 @@ fn synthetic_sepolia_block() -> nexum::host::types::Block { } /// Boot a single module from `(wasm, manifest)` and return the live -/// supervisor. Shared body across the 5 integration tests. +/// supervisor. async fn boot_production_module( engine: &wasmtime::Engine, linker: &Linker>, @@ -430,59 +646,12 @@ async fn boot_production_module( .expect("boot_single") } -/// The boot-order invariant, exercised (not merely asserted in prose): -/// a module that imports `shepherd:cow/cow-api` (twap-monitor) must NOT -/// boot when the cow extension is absent from the linker AND the -/// capability registry. The paired linker-hook + capability-namespace -/// registration is what makes the same module boot once the cow extension -/// is wired at the composition root; drop the pairing and boot fails. The -/// positive direction (boots WITH the cow extension) is covered by the -/// extension crate that owns the backend. -#[tokio::test] -async fn twap_monitor_without_cow_extension_fails_to_boot() { - let Some(wasm) = module_wasm_or_skip("twap-monitor") else { - return; - }; - let manifest = production_module_toml("modules/twap-monitor/module.toml"); - let engine = make_wasmtime_engine(); - // Core-only: no cow linker hook, no cow capability namespace. - let linker = crate::supervisor::build_linker::(&engine, &[]).expect("build_linker"); - let (_dir, store) = temp_local_store(); - let components = test_components(store); - let limits = ModuleLimits::default(); - - let result = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &[], - None, - ) - .await; - - let err = result - .err() - .expect("cow-importing module must not boot without the cow extension registered"); - // Pin the failure to its specific cause: twap-monitor declares the - // cow-api capability, which a core-only registry does not recognise - // (registering it is exactly what the cow extension does). Rules out - // an unrelated failure masquerading as the invariant. - let chain = format!("{err:#}"); - assert!( - chain.contains(r#"unknown capability "cow-api""#), - "expected the cow-api unknown-capability failure, got: {chain}", - ); -} - #[tokio::test] async fn e2e_price_alert_block_dispatch() { let Some(wasm) = module_wasm_or_skip("price-alert") else { return; }; - let manifest = production_module_toml("modules/examples/price-alert/module.toml"); + let manifest = production_module_toml("nexum/modules/examples/price-alert/module.toml"); let engine = make_wasmtime_engine(); let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); @@ -498,7 +667,7 @@ async fn e2e_balance_tracker_block_dispatch() { let Some(wasm) = module_wasm_or_skip("balance-tracker") else { return; }; - let manifest = production_module_toml("modules/examples/balance-tracker/module.toml"); + let manifest = production_module_toml("nexum/modules/examples/balance-tracker/module.toml"); let engine = make_wasmtime_engine(); let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); @@ -509,13 +678,10 @@ async fn e2e_balance_tracker_block_dispatch() { assert_eq!(supervisor.alive_count(), 1); } -/// End-to-end wasi:http path: http-probe fetches a loopback server -/// admitted by its allowlist, then fetches an off-list host and -/// requires the HTTP-request-denied outcome inside the guest. The -/// module returns `Ok` from `on_event` only when both legs hold, so -/// `dispatched == 1` asserts the success AND denied paths together. -/// The off-list host is never resolved or dialled (the gate denies -/// before any connection), so the test needs no external network. +/// End-to-end wasi:http path: http-probe fetches a loopback server on its +/// allowlist, then an off-list host that the gate denies before any +/// connection. The guest returns `Ok` only when both legs hold, so +/// `dispatched == 1` asserts the allow and deny paths together. #[tokio::test] async fn e2e_http_probe_allowlisted_fetch_and_denied_path() { let Some(wasm) = module_wasm_or_skip("http-probe") else { @@ -577,15 +743,9 @@ denied_url = "http://denied.invalid/" // ── Init-failed modules must be marked dead ──────────────── -/// Drive `Supervisor::boot_single` with a module whose `[config]` -/// carries a malformed `threshold` value (`"not-a-number"`). The -/// module's `init` returns `Err(fault.invalid-input)`. -/// Previously the supervisor still marked the module -/// `alive = true`, so it received block dispatches forever. The fix -/// flips `alive = false` when `init` fails. -/// -/// Surfaced live on Sepolia in -/// `docs/operations/m3-edge-case-validation.md` scenario 1.4. +/// A module whose `[config]` carries a malformed `threshold` fails `init` +/// with `fault.invalid-input`; the supervisor marks it `alive = false` so +/// it receives no dispatches. #[tokio::test] async fn init_failure_marks_module_dead_and_excludes_from_dispatch() { let Some(wasm) = module_wasm_or_skip("price-alert") else { @@ -593,7 +753,7 @@ async fn init_failure_marks_module_dead_and_excludes_from_dispatch() { }; // Synthesise a manifest with the same shape as the real - // price-alert module but with a `threshold` that the strategy + // price-alert module but with a `threshold` that the module // rejects in `parse_config`. let dir = tempfile::tempdir().unwrap(); let manifest = dir.path().join("module.toml"); @@ -644,11 +804,8 @@ every_n_blocks = "1" ); } -/// Dead modules (here: init-failed, `alive = false`) must not contribute -/// their chain to `block_chains()` or `chain_log_subscriptions()`. Without -/// the alive filter the builder opens live RPC subscriptions against chains -/// that will never dispatch to any module, wasting connections and emitting -/// zero-dispatch events until shutdown. +/// An init-failed (dead) module must not contribute its chain to +/// `block_chains()` or `chain_log_subscriptions()`. #[tokio::test] async fn dead_modules_excluded_from_subscription_lists() { let Some(wasm) = module_wasm_or_skip("price-alert") else { @@ -710,10 +867,7 @@ every_n_blocks = "1" } /// Positive control for the alive filter: with one dead and one alive -/// module, the alive module's subscriptions must survive the filter. -/// Guards against a regression where the filter (or a manifest-schema -/// change) empties the lists for everyone, which the all-dead test -/// above cannot distinguish from correct filtering. +/// module, the alive module's subscriptions survive the filter. #[tokio::test] async fn alive_module_subscriptions_survive_alongside_dead_module() { let Some(price_alert_wasm) = module_wasm_or_skip("price-alert") else { @@ -791,6 +945,7 @@ chain_id = 1 manifest: Some(alive_manifest), }, ], + adapters: Vec::new(), }; let supervisor = Supervisor::boot( @@ -822,8 +977,7 @@ chain_id = 1 // host-call time fuel cannot meter. /// `with_dispatch_deadline` cancels rather than awaits an over-long future: -/// a sleep far past the deadline is dropped, not run. The end-to-end case is -/// `dispatch_deadline_cuts_off_a_blocked_host_call_and_recovers`. +/// a sleep far past the deadline is dropped, not run. #[tokio::test] async fn dispatch_deadline_interrupts_a_sleeping_host_call() { use std::sync::Arc; @@ -889,10 +1043,10 @@ fn event_deadline_resolves_override_default_and_floor() { } /// A guest suspended inside a host call is cut off by the wall-clock -/// deadline, the poisoned store torn down and the module marked dead, then a -/// later dispatch reinstantiates it on a fresh store. The `slow-host` fixture -/// parks its first `chain::request` an hour past a 1s deadline override; the -/// park is one-shot, so the module recovers after the restart backoff. +/// deadline and the module marked dead, then a later dispatch reinstantiates +/// it on a fresh store. The `slow-host` fixture parks its first +/// `chain::request` an hour past a 1s deadline, one-shot, so it recovers +/// after the backoff. #[tokio::test] async fn dispatch_deadline_cuts_off_a_blocked_host_call_and_recovers() { use std::time::Instant; @@ -918,7 +1072,7 @@ async fn dispatch_deadline_cuts_off_a_blocked_host_call_and_recovers() { let components = crate::test_utils::mock_components_from(chain, crate::test_utils::MockStateStore::new()); - let manifest = fixture_module_toml("modules/fixtures/slow-host/module.toml"); + let manifest = fixture_module_toml("nexum/modules/fixtures/slow-host/module.toml"); // 1s is the floor the resolver saturates up to; short enough to keep // the test quick, long enough to prove the call was cut off (the park // is an hour) rather than never started. @@ -999,16 +1153,10 @@ async fn dispatch_deadline_cuts_off_a_blocked_host_call_and_recovers() { // changes to the supervisor cannot silently bypass the limits. fn fixture_module_toml(relative_path: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join(relative_path) + workspace_root().join(relative_path) } -/// Boot a single fixture (.wasm + module.toml) under the supervisor. -/// Shared body across the two resource-limit tests. +/// Boot a single fixture (`.wasm` + `module.toml`) under the supervisor. async fn boot_fixture(wasm: &Path, manifest_relative: &str) -> DefaultSupervisor { let engine = make_wasmtime_engine(); let linker = make_linker(&engine); @@ -1035,7 +1183,7 @@ async fn resource_limit_fuel_bomb_traps_and_marks_module_dead() { let Some(wasm) = module_wasm_or_skip("fuel-bomb") else { return; }; - let mut supervisor = boot_fixture(&wasm, "modules/fixtures/fuel-bomb/module.toml").await; + let mut supervisor = boot_fixture(&wasm, "nexum/modules/fixtures/fuel-bomb/module.toml").await; assert_eq!(supervisor.module_count(), 1); assert_eq!(supervisor.alive_count(), 1, "loads alive"); @@ -1123,7 +1271,7 @@ chain_id = 1 crate::engine_config::ModuleEntry { path: bomb_wasm.clone(), manifest: Some(fixture_module_toml( - "modules/fixtures/fuel-bomb/module.toml", + "nexum/modules/fixtures/fuel-bomb/module.toml", )), }, crate::engine_config::ModuleEntry { @@ -1131,6 +1279,7 @@ chain_id = 1 manifest: Some(example_manifest.clone()), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -1175,7 +1324,8 @@ async fn resource_limit_memory_bomb_traps_and_marks_module_dead() { let Some(wasm) = module_wasm_or_skip("memory-bomb") else { return; }; - let mut supervisor = boot_fixture(&wasm, "modules/fixtures/memory-bomb/module.toml").await; + let mut supervisor = + boot_fixture(&wasm, "nexum/modules/fixtures/memory-bomb/module.toml").await; assert_eq!(supervisor.module_count(), 1); assert_eq!(supervisor.alive_count(), 1); @@ -1319,7 +1469,7 @@ async fn poison_pill_quarantines_module_after_threshold() { let Some(wasm) = module_wasm_or_skip("fuel-bomb") else { return; }; - let manifest = production_module_toml("modules/fixtures/fuel-bomb/module.toml"); + let manifest = production_module_toml("nexum/modules/fixtures/fuel-bomb/module.toml"); let engine = make_wasmtime_engine(); let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); @@ -1418,12 +1568,9 @@ fn components_with_logs( (components, logs) } -/// Ported to the [`TestRuntime`] harness: it replaces the hand-built -/// `boot_single` plus manual `dispatch_block` ceremony with an inline-manifest -/// launch, an injected header, and a polled log read, while holding the same -/// coverage. The example module logs via the host logging glue at init and on -/// the block, so its run holds retrievable HostInterface records after one -/// dispatch. +/// The example module logs via the host logging glue at init and on the +/// block, so its run holds retrievable HostInterface records after one +/// dispatch. Driven through the [`TestRuntime`] harness. #[tokio::test] async fn host_interface_records_are_retrievable_after_a_run() { let Some(wasm) = example_wasm_or_skip() else { @@ -1490,7 +1637,7 @@ async fn dying_run_leaves_a_panic_record() { let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); let (components, logs) = components_with_logs(store); - let manifest = fixture_module_toml("modules/fixtures/fuel-bomb/module.toml"); + let manifest = fixture_module_toml("nexum/modules/fixtures/fuel-bomb/module.toml"); let limits = ModuleLimits::default(); let mut supervisor = Supervisor::boot_single( &engine, @@ -1545,7 +1692,7 @@ async fn facade_panic_leaves_stderr_host_interface_and_panic_records() { let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); let (components, logs) = components_with_logs(store); - let manifest = fixture_module_toml("modules/fixtures/panic-bomb/module.toml"); + let manifest = fixture_module_toml("nexum/modules/fixtures/panic-bomb/module.toml"); let limits = ModuleLimits::default(); let mut supervisor = Supervisor::boot_single( &engine, @@ -1675,6 +1822,7 @@ chain_id = 100 manifest: Some(chain_b_manifest), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -1714,12 +1862,10 @@ chain_id = 100 assert_eq!(supervisor.alive_count(), 2); } -/// Acceptance criterion for the per-handler dispatch rate limit: a -/// source flooding one module is throttled at the dispatch boundary -/// (over-rate events dropped) while a second module on another chain -/// still gets every dispatch. Two healthy example modules; a tiny -/// `[limits.dispatch]` (burst = 2, refill = 1/s) so the flood drains -/// the first module's bucket almost immediately. +/// Per-module dispatch rate limit: a source flooding one module is +/// throttled (over-rate events dropped) while a second module on another +/// chain still gets every dispatch. A tiny `[limits.dispatch]` (burst = 2, +/// refill = 1/s) drains the first bucket almost immediately. #[tokio::test] async fn dispatch_rate_limit_throttles_a_flood_without_starving_others() { let Some(wasm) = example_wasm_or_skip() else { @@ -1791,6 +1937,7 @@ chain_id = 100 manifest: Some(calm_manifest), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -1913,7 +2060,7 @@ chain_id = 100 crate::engine_config::ModuleEntry { path: bomb_wasm, manifest: Some(fixture_module_toml( - "modules/fixtures/fuel-bomb/module.toml", + "nexum/modules/fixtures/fuel-bomb/module.toml", )), }, crate::engine_config::ModuleEntry { @@ -1921,6 +2068,7 @@ chain_id = 100 manifest: Some(example_manifest), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -2138,3 +2286,246 @@ fn chainlog_cursor_key_differs_by_each_input() { "address presence changes the key", ); } + +// ── provider boot gating ────────────────────────────────────────────── + +/// A stub extension registering the `acme-adapter` provider kind behind a +/// unit service, for the boot-gate tests. +struct AcmeService; +impl crate::host::extension::HostService for AcmeService {} + +struct AcmeKind; + +#[async_trait::async_trait] +impl ProviderKind for AcmeKind { + fn kind(&self) -> &'static str { + "acme-adapter" + } + + fn link( + &self, + _linker: &mut Linker>, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn install( + &self, + _instance: ProviderInstance<'_, crate::test_utils::MockTypes>, + _service: &Arc, + ) -> anyhow::Result { + Ok(Installed::Live) + } +} + +struct AcmeExtension; + +impl Extension for AcmeExtension { + fn namespace(&self) -> &'static str { + "acme" + } + + fn capabilities(&self) -> manifest::NamespaceCaps { + manifest::NamespaceCaps { + prefix: "test:acme/", + ifaces: &[], + } + } + + fn link( + &self, + _linker: &mut Linker>, + ) -> anyhow::Result<()> { + Ok(()) + } + + fn service(&self) -> Option> { + Some(Arc::new(AcmeService)) + } + + fn provider(&self) -> Option>> { + Some(Box::new(AcmeKind)) + } +} + +/// The stub extension set registering the `acme-adapter` kind. +fn acme_extensions() -> Vec>> { + vec![Arc::new(AcmeExtension)] +} + +/// An `[[adapters]]` entry whose manifest is (or defaults to) an +/// event-module is rejected before instantiation, naming the registered +/// kinds. +#[tokio::test] +async fn boot_rejects_provider_whose_manifest_is_an_event_module() { + let engine = make_wasmtime_engine(); + let components = crate::test_utils::mock_components(); + let extensions = acme_extensions(); + let linker = + crate::supervisor::build_linker::(&engine, &extensions) + .expect("build_linker"); + + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + "[module]\nname = \"acme\"\nkind = \"event-module\"\n", + ) + .expect("write manifest"); + + let config = EngineConfig { + adapters: vec![crate::engine_config::AdapterEntry { + path: dir.path().join("acme.wasm"), + manifest: Some(manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + ..Default::default() + }; + + let err = + match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { + Ok(_) => panic!("event-module manifest in an [[adapters]] slot must be rejected"), + Err(err) => err, + }; + let msg = format!("{err:#}"); + assert!( + msg.contains("acme-adapter"), + "the kind gate names the registered kinds: {msg}", + ); +} + +/// A kind spelling no extension registered is refused at boot with a +/// message naming the registered kinds. +#[tokio::test] +async fn boot_rejects_an_unregistered_provider_kind() { + let engine = make_wasmtime_engine(); + let components = crate::test_utils::mock_components(); + let extensions = acme_extensions(); + let linker = + crate::supervisor::build_linker::(&engine, &extensions) + .expect("build_linker"); + + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write(&manifest, "[module]\nname = \"bad\"\nkind = \"gadget\"\n") + .expect("write manifest"); + + let config = EngineConfig { + adapters: vec![crate::engine_config::AdapterEntry { + path: dir.path().join("gadget.wasm"), + manifest: Some(manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + ..Default::default() + }; + + let err = + match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { + Ok(_) => panic!("an unregistered provider kind must be refused"), + Err(err) => err, + }; + let msg = format!("{err:#}"); + assert!( + msg.contains("unregistered provider kind gadget") && msg.contains("acme-adapter"), + "the refusal names the unknown spelling and the registered kinds: {msg}", + ); +} + +/// A registered kind clears the discriminator; boot then reaches the +/// compile step and fails only because the referenced wasm is absent. +#[tokio::test] +async fn boot_admits_a_registered_provider_kind_past_the_kind_gate() { + let engine = make_wasmtime_engine(); + let components = crate::test_utils::mock_components(); + let extensions = acme_extensions(); + let linker = + crate::supervisor::build_linker::(&engine, &extensions) + .expect("build_linker"); + + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + "[module]\nname = \"acme\"\nkind = \"acme-adapter\"\n\n\ + [capabilities]\nrequired = [\"chain\"]\n", + ) + .expect("write manifest"); + + let config = EngineConfig { + adapters: vec![crate::engine_config::AdapterEntry { + path: dir.path().join("missing-acme.wasm"), + manifest: Some(manifest), + http_allow: vec!["api.acme.example".into()], + messaging_topics: vec!["/nexum/1/acme-orders/proto".into()], + }], + ..Default::default() + }; + + let err = + match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { + Ok(_) => panic!("absent provider wasm must fail the compile step"), + Err(err) => err, + }; + let msg = format!("{err:#}"); + assert!( + msg.contains("compile") || msg.contains("missing-acme"), + "boot reached the compile step past the kind gate: {msg}", + ); + assert!( + !msg.contains("requires a module.toml"), + "the kind gate passed rather than rejecting: {msg}", + ); +} + +/// A module subscribing to an extension kind no wired extension declares +/// is refused at boot, preserving the unknown-kind fail-fast. +#[tokio::test] +async fn boot_refuses_an_undeclared_extension_subscription_kind() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "example" + +[capabilities] +required = ["logging"] + +[[subscription]] +kind = "acme-status" +"#, + ) + .expect("write manifest"); + + let engine = make_wasmtime_engine(); + let linker = make_linker(&engine); + let (_dir, local_store) = temp_local_store(); + let components = test_components(local_store); + let limits = ModuleLimits::default(); + + let result = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &core_extensions(), + None, + ) + .await; + let err = result + .err() + .expect("an undeclared extension subscription kind must refuse boot"); + let msg = format!("{err:#}"); + assert!( + msg.contains("unknown event kind acme-status"), + "the refusal names the kind: {msg}", + ); +} diff --git a/crates/nexum-runtime/src/test_utils/builders.rs b/nexum/crates/nexum-runtime/src/test_utils/builders.rs similarity index 57% rename from crates/nexum-runtime/src/test_utils/builders.rs rename to nexum/crates/nexum-runtime/src/test_utils/builders.rs index 529f229a..d173ce4c 100644 --- a/crates/nexum-runtime/src/test_utils/builders.rs +++ b/nexum/crates/nexum-runtime/src/test_utils/builders.rs @@ -1,13 +1,9 @@ -//! Pass-through [`ComponentBuilder`] wrapping a pre-built backend, so a test -//! hands a programmed mock straight into the public builder path. +//! Pass-through [`ComponentBuilder`] wrapping a pre-built backend. use crate::host::component::{BuilderContext, ComponentBuilder}; /// A [`ComponentBuilder`] that yields a pre-built backend, ignoring the build -/// context. Wrap any mock instance (chain, store, or extension payload) to -/// compose it through [`RuntimeBuilder::with_components`]. -/// -/// [`RuntimeBuilder::with_components`]: crate::builder::TypedBuilder::with_components +/// context. Wrap any mock instance to compose it through the public builder. pub struct Prebuilt(pub T); impl ComponentBuilder for Prebuilt { diff --git a/crates/nexum-runtime/src/test_utils/chain.rs b/nexum/crates/nexum-runtime/src/test_utils/chain.rs similarity index 79% rename from crates/nexum-runtime/src/test_utils/chain.rs rename to nexum/crates/nexum-runtime/src/test_utils/chain.rs index a1deeeae..78a94445 100644 --- a/crates/nexum-runtime/src/test_utils/chain.rs +++ b/nexum/crates/nexum-runtime/src/test_utils/chain.rs @@ -28,18 +28,10 @@ pub struct RecordedRequest { type BlockItem = Result; type LogItem = Result; -/// One subscription kind's channel pair. The receiver is taken by the first -/// subscribe call. -/// -/// A concurrent second subscribe (with no close in between) finds the -/// receiver already taken and parks on a pending stream, so a reconnect loop -/// does not busy-spin against a live subscriber. -/// -/// A subscribe after [`close`](Self::close) is the reconnect path and is -/// distinct: close ends the open stream and re-arms the slot with a fresh -/// channel, so the next subscribe (the event loop's reconnect after backoff) -/// gets a real stream and resumes delivery of subsequently sent items, -/// mirroring a provider that reconnects after a dropped connection. +/// One subscription kind's channel pair; the receiver is taken by the first +/// subscribe. A second subscribe before any [`close`](Self::close) parks on +/// a pending stream. [`close`](Self::close) ends the open stream and re-arms +/// the slot, so the next subscribe (the reconnect path) resumes delivery. struct StreamSlot { tx: UnboundedSender, rx: Option>, @@ -93,21 +85,14 @@ struct Inner { /// Mock chain backend. Program `request` responses with [`on_method`] / /// [`on_request`], drive subscriptions with [`push_block`] / -/// [`push_chain_log`], script transport failures with [`push_block_err`] / -/// [`push_chain_log_err`], end a stream with [`close_block_stream`] / -/// [`close_chain_log_stream`], and read back dispatched calls with -/// [`recorded_requests`]. Cheap `Arc` clone shares one backing state, so a -/// test keeps a clone to program and assert while another clone lives inside -/// the runtime assembly. +/// [`push_chain_log`] (and the `_err` / `close_*` variants), and read +/// dispatched calls with [`recorded_requests`]. Cheap `Arc` clone shares one +/// backing state, so a test keeps a clone to program and assert. /// /// [`on_method`]: MockChainProvider::on_method /// [`on_request`]: MockChainProvider::on_request /// [`push_block`]: MockChainProvider::push_block /// [`push_chain_log`]: MockChainProvider::push_chain_log -/// [`push_block_err`]: MockChainProvider::push_block_err -/// [`push_chain_log_err`]: MockChainProvider::push_chain_log_err -/// [`close_block_stream`]: MockChainProvider::close_block_stream -/// [`close_chain_log_stream`]: MockChainProvider::close_chain_log_stream /// [`recorded_requests`]: MockChainProvider::recorded_requests #[derive(Clone)] pub struct MockChainProvider { @@ -158,8 +143,8 @@ impl MockChainProvider { self } - /// Deliver a block header to the open block subscription. Items sent - /// while no subscription is open buffer and drain into the next one. + /// Deliver a block header to the open block subscription; items sent with + /// no open subscription buffer and drain into the next. pub fn push_block(&self, header: Header) { self.lock().blocks.send(Ok(header)); } @@ -169,9 +154,7 @@ impl MockChainProvider { self.lock().logs.send(Ok(log)); } - /// Deliver an error item to the open block subscription, so a - /// reconnect-and-backoff loop on the [`BlockStream`] contract can be - /// exercised against the fake. + /// Deliver an error item to the open block subscription. pub fn push_block_err(&self, err: ProviderError) { self.lock().blocks.send(Err(err)); } @@ -181,11 +164,9 @@ impl MockChainProvider { self.lock().logs.send(Err(err)); } - /// End the block subscription, modelling a dropped upstream connection: - /// buffered items drain, then the stream terminates (yields `None`). The - /// slot re-arms, so a later `subscribe_blocks` (the event loop's - /// reconnect after backoff) resumes delivery of subsequently pushed - /// items, as a real provider does once its connection is back. + /// End the block subscription (modelling a dropped connection): buffered + /// items drain, the stream terminates, and the slot re-arms so a later + /// `subscribe_blocks` resumes delivery of subsequently pushed items. pub fn close_block_stream(&self) { self.lock().blocks.close(); } @@ -196,11 +177,9 @@ impl MockChainProvider { self.lock().logs.close(); } - /// Park the next [`ChainProvider::request`] for `delay` before it - /// resolves, modelling a hung node or a server that never answers. - /// One-shot: the delay is consumed when that request begins, so a - /// caller that drops the request future mid-park (e.g. a dispatch that - /// hits its wall-clock deadline) leaves the following request prompt. + /// Park the next [`ChainProvider::request`] for `delay`. One-shot, + /// consumed when the request begins, so a caller that drops the request + /// future mid-park leaves the following request prompt. pub fn delay_next_request(&self, delay: Duration) -> &Self { self.lock().next_request_delay = Some(delay); self diff --git a/crates/nexum-runtime/src/test_utils/clock.rs b/nexum/crates/nexum-runtime/src/test_utils/clock.rs similarity index 74% rename from crates/nexum-runtime/src/test_utils/clock.rs rename to nexum/crates/nexum-runtime/src/test_utils/clock.rs index fcda4919..440efb1a 100644 --- a/crates/nexum-runtime/src/test_utils/clock.rs +++ b/nexum/crates/nexum-runtime/src/test_utils/clock.rs @@ -7,13 +7,11 @@ use wasmtime_wasi::{HostMonotonicClock, HostWallClock}; use crate::supervisor::WasiClockOverride; -/// A shared, manually-advanced clock source. -/// -/// Cloning yields another handle onto the same instant: install one clone as -/// the store's [`WasiClockOverride`] and drive the other from the test. -/// [`set`](Self::set) pins wall time; [`advance`](Self::advance) moves both the -/// wall and monotonic sources forward. Guest-visible only; it does not touch -/// the host wall-clock a `RunId` stamps its start with. +/// A shared, manually-advanced clock source. Cloning yields another handle +/// onto the same instant: install one clone as a store's +/// [`WasiClockOverride`], drive the other from the test. [`set`](Self::set) +/// pins wall time; [`advance`](Self::advance) moves wall and monotonic +/// together. Guest-visible only. #[derive(Clone)] pub struct ManualClock { inner: Arc>, @@ -44,10 +42,8 @@ impl ManualClock { } } - /// Pin wall time to `time`, leaving the monotonic reading untouched. - /// Times before the Unix epoch clamp to it. Because it does not move - /// monotonic, a `set` after an `advance` can put wall time behind the - /// monotonic source; the two only stay in step under `advance`. + /// Pin wall time to `time` (clamped to the Unix epoch), leaving monotonic + /// untouched; a `set` after an `advance` can put wall behind monotonic. pub fn set(&self, time: SystemTime) { let wall = time.duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO); self.locked().wall = wall; @@ -61,12 +57,9 @@ impl ManualClock { state.monotonic = state.monotonic.saturating_add(nanos); } - /// Build a [`WasiClockOverride`] backed by this clock for both the wall and - /// monotonic sources. The two `Arc`s wrap separate `clone`s of the same - /// `ManualClock`, which share one inner `Arc>`, so both handles - /// read and drive the same time. Swapping a `clone` for a fresh - /// `ManualClock::new()` would split that state and silently break the - /// override. + /// A [`WasiClockOverride`] over this clock for both sources. Both `Arc`s + /// share this clock's inner state; a fresh `ManualClock::new()` would + /// split it and break the override. pub fn as_override(&self) -> WasiClockOverride { WasiClockOverride::new(Arc::new(self.clone()), Arc::new(self.clone())) } diff --git a/crates/nexum-runtime/src/test_utils/harness.rs b/nexum/crates/nexum-runtime/src/test_utils/harness.rs similarity index 60% rename from crates/nexum-runtime/src/test_utils/harness.rs rename to nexum/crates/nexum-runtime/src/test_utils/harness.rs index e06e163a..3dc1d021 100644 --- a/crates/nexum-runtime/src/test_utils/harness.rs +++ b/nexum/crates/nexum-runtime/src/test_utils/harness.rs @@ -1,27 +1,17 @@ //! In-process test harness: launch one module over the mock assembly and -//! drive it from a test, with no supervisor ceremony. +//! drive it from a test. //! -//! [`TestRuntime`] wraps the public builder path over [`MockTypes`]: it opens -//! a module from a wasm path plus a manifest (a path, or inline TOML written -//! to a temp file), installs a manually-driven [`ManualClock`], and returns a -//! handle bundling the running [`RuntimeHandle`] with the retained mock -//! handles. A test programs chain responses and injects block headers or chain -//! logs through [`chain`](TestRuntime::chain), advances guest time through -//! [`clock`](TestRuntime::clock), reads what a module wrote through -//! [`store`](TestRuntime::store), and reads runs and log pages through -//! [`logs`](TestRuntime::logs), then [`shutdown`](TestRuntime::shutdown) and -//! [`wait`](TestRuntime::wait). -//! -//! Events dispatch on the spawned event-loop task, so a test injects an event -//! and then awaits an observable effect; -//! [`wait_for_log`](TestRuntime::wait_for_log) polls the log pipeline for one. -//! -//! The extension slot is the lattice type parameter: pass an `Ext` payload -//! and any [`Extension`]s through -//! [`builder_with_ext`](TestRuntime::builder_with_ext) to drive an extension -//! crate's backend through the same harness. +//! [`TestRuntime`] wraps the public builder path over [`MockTypes`] with a +//! manually-driven [`ManualClock`]. Program the mocks and read effects +//! through [`chain`](TestRuntime::chain), [`clock`](TestRuntime::clock), +//! [`store`](TestRuntime::store) and [`logs`](TestRuntime::logs). Events +//! dispatch on the spawned event-loop task, so +//! [`wait_for_log`](TestRuntime::wait_for_log) polls for an observable +//! effect. Bind an extension payload through +//! [`builder_with_ext`](TestRuntime::builder_with_ext). use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use alloy_rpc_types_eth::{Header, Log}; @@ -44,9 +34,7 @@ enum ManifestSource { Inline(String), } -/// Builder for a [`TestRuntime`]. Program the mock backends through -/// [`chain`](Self::chain) / [`store`](Self::store) / [`clock`](Self::clock) -/// before [`launch`](Self::launch); the launched handle shares the same +/// Builder for a [`TestRuntime`]; the launched handle shares the same mock /// backends. pub struct TestRuntimeBuilder where @@ -54,7 +42,7 @@ where { wasm: PathBuf, manifest: ManifestSource, - extensions: Vec>>, + extensions: Vec>>>, ext: E, limits: ModuleLimits, chain: MockChainProvider, @@ -70,9 +58,9 @@ impl TestRuntime<()> { } impl TestRuntime { - /// Start a harness whose lattice binds `ext` as the extension payload. - /// Pair with [`extension`](TestRuntimeBuilder::extension) to register the - /// extension's linker hook and capability namespace. + /// Start a harness binding `ext` as the extension payload; pair with + /// [`extension`](TestRuntimeBuilder::extension) to register its linker + /// hook and capability namespace. pub fn builder_with_ext(wasm: impl Into, ext: E) -> TestRuntimeBuilder { TestRuntimeBuilder { wasm: wasm.into(), @@ -100,8 +88,8 @@ impl TestRuntimeBuilder { self } - /// Register an extension's linker hook and capability namespace. - pub fn extension(mut self, extension: Extension>) -> Self { + /// Register an extension. + pub fn extension(mut self, extension: Arc>>) -> Self { self.extensions.push(extension); self } @@ -109,27 +97,25 @@ impl TestRuntimeBuilder { /// Register several extensions at once. pub fn extensions( mut self, - extensions: impl IntoIterator>>, + extensions: impl IntoIterator>>>, ) -> Self { self.extensions.extend(extensions); self } - /// Replace the `[limits]` the launch resolves: fuel, memory, outbound - /// HTTP, log retention, and the poison-pill thresholds. Defaults to the + /// Replace the `[limits]` the launch resolves; defaults to the /// production defaults. pub fn limits(mut self, limits: ModuleLimits) -> Self { self.limits = limits; self } - /// The mock chain backend, for programming request responses before - /// launch. The launched handle shares this instance. + /// The mock chain backend; the launched handle shares this instance. pub fn chain(&self) -> &MockChainProvider { &self.chain } - /// The mock state store. The launched handle shares this instance. + /// The mock state store; the launched handle shares this instance. pub fn store(&self) -> &MockStateStore { &self.store } @@ -139,8 +125,7 @@ impl TestRuntimeBuilder { &self.clock } - /// Open the module and start the runtime. Composes entirely through the - /// public builder path over [`MockTypes`]. + /// Open the module and start the runtime through the public builder path. pub async fn launch(self) -> anyhow::Result> { // A temp directory roots any inline manifest and stands in as the // (unused, in-memory backends) state directory. @@ -185,9 +170,8 @@ impl TestRuntimeBuilder { } } -/// A launched in-process runtime over the mock assembly. Holds the running -/// [`RuntimeHandle`] and the retained mock handles; dropping it fires the -/// shutdown trigger. +/// A launched in-process runtime over the mock assembly; dropping it fires +/// the shutdown trigger. pub struct TestRuntime { handle: RuntimeHandle, chain: MockChainProvider, @@ -200,9 +184,7 @@ pub struct TestRuntime { } impl TestRuntime { - /// The mock chain backend: program request responses, inject block - /// headers with [`push_block`](Self::push_block), and inject logs with - /// [`push_chain_log`](Self::push_chain_log). + /// The mock chain backend. pub fn chain(&self) -> &MockChainProvider { &self.chain } @@ -222,7 +204,7 @@ impl TestRuntime { &self.ext } - /// The shared log pipeline: read runs and log pages here. + /// The shared log pipeline. pub fn logs(&self) -> &LogPipeline { self.handle.logs() } @@ -237,12 +219,9 @@ impl TestRuntime { self.chain.push_chain_log(log); } - /// Await a `module` log record whose message contains `needle`, returning - /// it. Driven by log-append notifications rather than a timer, so it - /// resolves as soon as the dispatched event's record lands; the 5s bound - /// is a failure backstop, not the cadence. Use it to await an injected - /// event's effect: the record only lands once the event-loop task has - /// dispatched the event. + /// Await a `module` log record whose message contains `needle`. + /// Notification-driven, so it resolves as soon as the dispatched event's + /// record lands; the 5s bound is a failure backstop. pub async fn wait_for_log(&self, module: &str, needle: &str) -> anyhow::Result { let logs = self.logs(); let appended = logs.appended(); @@ -290,15 +269,16 @@ mod tests { use crate::host::extension::Extension; use crate::manifest::NamespaceCaps; - /// The pre-built module wasm named `file`, or `None` (with a skip note) - /// when the fixture is not built. + /// The pre-built module wasm named `file`, or `None` with a skip note. fn module_wasm_or_skip(file: &str) -> Option { - let wasm = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("repo root") - .join("target/wasm32-wasip2/release") - .join(file); + // Workspace root: the topmost ancestor with a `Cargo.toml`. + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + let root = manifest + .ancestors() + .filter(|d| d.join("Cargo.toml").is_file()) + .last() + .unwrap_or(manifest); + let wasm = root.join("target/wasm32-wasip2/release").join(file); if wasm.exists() { Some(wasm) } else { @@ -310,8 +290,7 @@ mod tests { } } - /// The pre-built example module, or `None` (with a skip note) when the - /// wasm fixture is not built. + /// The pre-built example module, or `None` with a skip note. fn example_wasm_or_skip() -> Option { module_wasm_or_skip("example.wasm") } @@ -351,18 +330,15 @@ chain_id = {chain_id} ) } - /// A header carrying just the block number the event loop projects onto - /// the dispatched block. + /// A header carrying just the block number. fn header_numbered(number: u64) -> Header { let mut header: Header = Header::default(); header.inner.number = number; header } - /// End-to-end through the harness: launch the example module from an - /// inline manifest, inject a block header, and read the module's log line - /// back off the pipeline. Locks the harness ergonomics against the boot - /// plus dispatch plus log-read path. + /// End-to-end: launch the example module from an inline manifest, inject + /// a block header, and read the module's log line back. #[tokio::test] async fn harness_launches_dispatches_and_reads_logs() { let Some(wasm) = example_wasm_or_skip() else { @@ -390,10 +366,8 @@ chain_id = {chain_id} rt.wait().await.expect("clean shutdown"); } - /// End-to-end through the harness on the chain-log leg: launch the - /// example module with a `chain-log` subscription, inject a log, and read - /// the module's log line back. Locks the log-stream path the way - /// [`harness_launches_dispatches_and_reads_logs`] locks the block path. + /// End-to-end on the chain-log leg: launch with a `chain-log` + /// subscription, inject a log, and read the module's log line back. #[tokio::test] async fn harness_dispatches_chain_logs() { let Some(wasm) = example_wasm_or_skip() else { @@ -415,28 +389,40 @@ chain_id = {chain_id} rt.wait().await.expect("clean shutdown"); } - /// The extension slot threads through the same harness: a trivial - /// extension (no-op linker hook, empty capability namespace) and an ext - /// payload compose through the mock lattice, the module boots and - /// dispatches, and the harness hands the payload back. + /// The extension slot threads through the harness: a trivial extension + /// and an ext payload compose, the module dispatches, and the harness + /// hands the payload back. #[tokio::test] async fn harness_threads_an_extension_and_ext_payload() { let Some(wasm) = example_wasm_or_skip() else { return; }; - let calls = Arc::new(AtomicUsize::new(0)); - let hooked = calls.clone(); - let extension = Extension::>> { - link: Arc::new(move |_linker| { - hooked.fetch_add(1, Ordering::SeqCst); + struct CountingExtension(Arc); + + impl Extension>> for CountingExtension { + fn namespace(&self) -> &'static str { + "test" + } + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: "test:ext/", + ifaces: &[], + } + } + fn link( + &self, + _linker: &mut wasmtime::component::Linker< + crate::host::state::HostState>>, + >, + ) -> anyhow::Result<()> { + self.0.fetch_add(1, Ordering::SeqCst); Ok(()) - }), - capabilities: NamespaceCaps { - prefix: "test:ext/", - ifaces: &[], - }, - }; + } + } + + let calls = Arc::new(AtomicUsize::new(0)); + let extension = Arc::new(CountingExtension(calls.clone())); let mut rt = TestRuntime::builder_with_ext(wasm, calls.clone()) .extension(extension) @@ -462,9 +448,8 @@ chain_id = {chain_id} rt.wait().await.expect("clean shutdown"); } - /// [`TestRuntimeBuilder::limits`] reaches the launch: with a one-byte - /// log ring the run keeps only its newest record, so the init line is - /// evicted once the block line lands. + /// [`TestRuntimeBuilder::limits`] reaches the launch: a one-byte log ring + /// keeps only the newest record, evicting the init line. #[tokio::test] async fn harness_threads_module_limits() { use crate::engine_config::LogLimitsSection; @@ -505,11 +490,9 @@ chain_id = {chain_id} rt.wait().await.expect("clean shutdown"); } - /// The module-author flow end to end on the chain-request leg: program - /// the mock chain's `eth_call` response, launch the price-alert module, - /// inject a block, and read the module's alert line back. The programmed - /// oracle answer sits above the configured threshold, so the module logs - /// its TRIGGERED line. + /// End to end on the chain-request leg: program the mock `eth_call`, + /// launch price-alert, inject a block, and read its alert line back; the + /// programmed answer is above threshold, so the module logs TRIGGERED. #[tokio::test] async fn harness_serves_chain_requests_to_the_module() { use crate::host::component::ChainMethod; @@ -579,10 +562,223 @@ direction = "above" rt.wait().await.expect("clean shutdown"); } - /// A dropped block stream is not the end of dispatch: the event loop's - /// reconnect task reopens the subscription after backoff and the - /// re-armed mock resumes delivery, matching a real provider that comes - /// back after a connection drop. + /// Both block and chain-log events dispatch in one session: the `biased` + /// select in `run()` delivers both kinds without starvation. + #[tokio::test] + async fn harness_delivers_block_and_chain_log_events_without_starvation() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + + let mut rt = TestRuntime::builder(wasm) + .manifest_inline( + r#" +[module] +name = "example" + +[capabilities] +required = ["logging"] + +[[subscription]] +kind = "block" +chain_id = 1 + +[[subscription]] +kind = "chain-log" +chain_id = 1 +"#, + ) + .launch() + .await + .expect("launch example subscribed to both blocks and chain-logs"); + + // Both events are queued before either is awaited, so the biased + // select genuinely arbitrates between two ready streams — a + // sequential push→wait→push→wait would never create contention. + rt.push_block(header_numbered(42)); + rt.push_chain_log(Log::default()); + + rt.wait_for_log("example", "block 42 on chain") + .await + .expect("block event dispatched"); + rt.wait_for_log("example", "received 1 chain-log entries") + .await + .expect("chain-log event dispatched — neither event kind starved the other"); + + rt.shutdown(); + rt.wait().await.expect("clean shutdown"); + } + + /// Blocks pushed in order arrive in the same order; the stream, select, + /// and dispatch path preserve delivery order, asserted on the module's + /// own log records. + #[tokio::test] + async fn harness_delivers_blocks_in_push_order() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + + let mut rt = TestRuntime::builder(wasm) + .manifest_inline(block_manifest("example", 1)) + .launch() + .await + .expect("launch example over the harness"); + + rt.push_block(header_numbered(7)); + rt.push_block(header_numbered(8)); + rt.push_block(header_numbered(9)); + + // The last block's log line proves all three dispatches completed. + rt.wait_for_log("example", "block 9 on chain") + .await + .expect("final block dispatched"); + + // Recover the per-block log lines in record order and assert the + // sequence matches the push order exactly. + let logs = rt.logs(); + let numbers: Vec = logs + .list_runs("example") + .into_iter() + .flat_map(|meta| logs.read(&meta.run, 0).records) + .filter_map(|record| { + let rest = record.message.strip_prefix("block ")?; + rest.split(' ').next()?.parse().ok() + }) + .collect(); + assert_eq!( + numbers, + vec![7, 8, 9], + "blocks must be dispatched in push order", + ); + + rt.shutdown(); + rt.wait().await.expect("clean shutdown"); + } + + /// Shutdown never destroys completed work: a picked-up block finishes its + /// wasmtime call and its log record survives `wait()`. Proven by + /// re-reading the record after full teardown. + #[tokio::test] + async fn harness_shutdown_preserves_completed_dispatch() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + + let mut rt = TestRuntime::builder(wasm) + .manifest_inline(block_manifest("example", 1)) + .launch() + .await + .expect("launch example over the harness"); + + rt.push_block(header_numbered(1)); + rt.wait_for_log("example", "block 1 on chain") + .await + .expect("dispatch completed before shutdown"); + + let logs = rt.logs().clone(); + rt.shutdown(); + rt.wait().await.expect("no panic or corruption on shutdown"); + + let survived = logs.list_runs("example").into_iter().any(|meta| { + logs.read(&meta.run, 0) + .records + .iter() + .any(|r| r.message.contains("block 1 on chain")) + }); + assert!( + survived, + "the completed dispatch's log record must survive engine teardown", + ); + } + + /// `[limits.chain].response_body_max_bytes` is enforced on the real + /// `chain::request` path: an over-cap response is rejected before the + /// guest copy, and the module observes the typed `invalid-input` fault. + #[tokio::test] + async fn harness_enforces_chain_response_cap_on_the_request_path() { + use crate::engine_config::ChainLimitsSection; + use crate::host::component::ChainMethod; + + let Some(wasm) = module_wasm_or_skip("price_alert.wasm") else { + return; + }; + + // A syntactically valid oracle answer, ~330 bytes - far over the + // 16-byte cap below, so the module must never see it. + fn word(v: u128) -> String { + format!("{v:064x}") + } + let result = format!( + "\"0x{}{}{}{}{}\"", + word(1), + word(300_000_000_000), + word(0), + word(0), + word(1), + ); + + let builder = TestRuntime::builder(wasm) + .manifest_inline( + r#" +[module] +name = "price-alert" + +[capabilities] +required = ["logging", "chain"] + +[[subscription]] +kind = "block" +chain_id = 1 + +[config] +oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" +decimals = "8" +threshold = "2500.00" +direction = "above" +"#, + ) + .limits(ModuleLimits { + chain: ChainLimitsSection { + response_body_max_bytes: Some(16), + }, + ..Default::default() + }); + builder.chain().on_method(ChainMethod::EthCall, result); + + let mut rt = builder + .launch() + .await + .expect("launch price-alert with a 16-byte chain response cap"); + + rt.push_block(header_numbered(19_000_000)); + let record = rt + .wait_for_log("price-alert", "exceeds the configured cap") + .await + .expect("the module logs the guest-visible cap fault"); + assert!( + record.message.contains("eth_call failed"), + "the cap surfaces as a failed eth_call, got: {}", + record.message, + ); + + // The module never saw the oracle answer, so it must not trigger. + let runs = rt.logs().list_runs("price-alert"); + let triggered = runs.into_iter().any(|meta| { + rt.logs() + .read(&meta.run, 0) + .records + .iter() + .any(|r| r.message.contains("TRIGGERED")) + }); + assert!(!triggered, "an over-cap response must never reach classify"); + + rt.shutdown(); + rt.wait().await.expect("clean shutdown"); + } + + /// A dropped block stream is not the end of dispatch: the reconnect task + /// reopens the subscription after backoff and the re-armed mock resumes + /// delivery. #[tokio::test] async fn harness_resumes_dispatch_after_a_dropped_block_stream() { let Some(wasm) = example_wasm_or_skip() else { @@ -610,12 +806,9 @@ direction = "above" rt.wait().await.expect("clean shutdown"); } - /// The guest observes the `WasiClockOverride` end to end: pin the harness - /// clock to a known instant, boot the clock-reader fixture under it, and - /// dispatch a block. The fixture reads `wasi:clocks/wall-clock` through - /// `std` and logs the wall time as whole seconds, so the logged value - /// equalling the pinned instant (and not the ambient host clock) proves - /// the override reaches the guest, not just the host boot path. + /// The guest observes the `WasiClockOverride`: pin the harness clock, + /// dispatch a block, and check the clock-reader fixture logs the pinned + /// wall time, not the ambient host clock. #[tokio::test] async fn harness_guest_observes_the_clock_override() { use std::time::{Duration, UNIX_EPOCH}; diff --git a/crates/nexum-runtime/src/test_utils/mod.rs b/nexum/crates/nexum-runtime/src/test_utils/mod.rs similarity index 84% rename from crates/nexum-runtime/src/test_utils/mod.rs rename to nexum/crates/nexum-runtime/src/test_utils/mod.rs index dc2ffeea..073a6145 100644 --- a/crates/nexum-runtime/src/test_utils/mod.rs +++ b/nexum/crates/nexum-runtime/src/test_utils/mod.rs @@ -1,11 +1,10 @@ -//! Engine-side mock backends for launching an in-process runtime entirely on -//! fakes. +//! Engine-side mock backends for an in-process runtime on fakes. //! //! [`MockChainProvider`] and [`MockStateStore`] implement the component seam -//! traits with no network and no disk; [`Prebuilt`] wraps a pre-built instance -//! as a [`ComponentBuilder`](crate::host::component::ComponentBuilder); and -//! [`MockTypes`] is the domain-free lattice that ties them together. The -//! assembly composes through the public builder path: +//! traits with no network or disk; [`Prebuilt`] wraps a pre-built instance +//! as a [`ComponentBuilder`](crate::host::component::ComponentBuilder); +//! [`MockTypes`] is the lattice tying them together. Compose through the +//! public builder path: //! //! ```no_run //! # use nexum_runtime::builder::RuntimeBuilder; @@ -28,9 +27,6 @@ //! # Ok(()) //! # } //! ``` -//! -//! The caller keeps its own clones of `chain` / `store` to program responses -//! and assert on what a module wrote. mod builders; mod chain; @@ -49,8 +45,7 @@ use crate::engine_config::ModuleLimits; use crate::host::component::Components; use crate::host::logs::LogPipeline; -/// A fresh in-memory [`LogPipeline`] at the default retention limits, the -/// one fake test bundles share so the construction lives in a single place. +/// A fresh in-memory [`LogPipeline`] at default retention limits. pub(crate) fn in_memory_logs() -> LogPipeline { LogPipeline::in_memory(ModuleLimits::default().logs()) } @@ -62,8 +57,7 @@ pub fn mock_components() -> Components { } /// A [`Components`] bundle over the given mock backends, with an empty -/// extension slot and an in-memory log pipeline. Pass clones the caller -/// retains for programming and assertion. +/// extension slot and an in-memory log pipeline. pub fn mock_components_from( chain: MockChainProvider, store: MockStateStore, @@ -89,11 +83,9 @@ mod tests { }; use crate::host::provider_pool::ProviderError; - /// M0 acceptance: a custom component set launched entirely through the - /// public builder, on fakes, with no CLI, no disk, and no network. The - /// launch reaches supervisor boot and bails only because the default - /// config declares no modules, which proves the mock backends composed - /// and the build path ran end to end. + /// A custom component set launches through the public builder on fakes; + /// it bails at boot only because the default config declares no modules, + /// proving the mock backends composed and the build path ran. #[tokio::test] async fn m0_custom_component_set_launches_through_the_public_builder() { let chain = MockChainProvider::new(); @@ -181,8 +173,7 @@ mod tests { assert!(item.is_ok(), "pushed header arrives as Ok"); } - /// A scripted error surfaces as an `Err` item on the block stream, and - /// closing the stream terminates it so a reconnect loop sees the end. + /// A scripted error surfaces as `Err`, and closing terminates the stream. #[tokio::test] async fn block_stream_scripts_errors_and_end() { let chain = MockChainProvider::new(); @@ -206,9 +197,8 @@ mod tests { ); } - /// After a close, a fresh `subscribe_blocks` (the event loop's reconnect - /// path) yields the items pushed since, so the fake keeps the real - /// provider's drop-then-reconnect delivery contract. + /// After a close, a fresh `subscribe_blocks` (the reconnect path) yields + /// the items pushed since, keeping the drop-then-reconnect contract. #[tokio::test] async fn closed_block_stream_rearms_for_the_reconnect_subscribe() { let chain = MockChainProvider::new(); @@ -226,9 +216,8 @@ mod tests { assert!(item.is_ok(), "pushed header arrives on the reopened stream"); } - /// The chain-log poller stream carries scripted errors and terminates on - /// close, mirroring the block leg. Each pushed log arrives as a one-log - /// canonical batch. + /// The chain-log poller carries scripted errors and terminates on close; + /// each pushed log arrives as a one-log canonical batch. #[tokio::test] async fn chain_log_stream_scripts_errors_and_end() { let chain = MockChainProvider::new(); @@ -266,8 +255,8 @@ mod tests { ); } - /// The store round-trips values, isolates namespaces, lists by prefix, and - /// rejects the empty namespace. + /// The store round-trips values, isolates namespaces, lists by prefix, + /// and rejects the empty namespace. #[test] fn store_roundtrips_and_isolates_namespaces() { let store = MockStateStore::new(); diff --git a/crates/nexum-runtime/src/test_utils/store.rs b/nexum/crates/nexum-runtime/src/test_utils/store.rs similarity index 56% rename from crates/nexum-runtime/src/test_utils/store.rs rename to nexum/crates/nexum-runtime/src/test_utils/store.rs index 7d8be449..56c7837b 100644 --- a/crates/nexum-runtime/src/test_utils/store.rs +++ b/nexum/crates/nexum-runtime/src/test_utils/store.rs @@ -7,13 +7,12 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use crate::host::component::{StateHandle, StateStore}; -use crate::host::local_store_redb::StorageError; +use crate::host::local_store_redb::{MAX_APPLY_OPS, MAX_APPLY_VALUE_BYTES, StorageError, WriteOp}; type Namespaces = HashMap>>; -/// Process-lifetime in-memory store keyed by namespace then key. Cheap `Arc` -/// clone shares one backing map, so a test keeps a clone to assert on what a -/// module wrote. +/// In-memory store keyed by namespace then key; cheap `Arc` clone shares one +/// backing map, so a test keeps a clone to assert on what a module wrote. #[derive(Clone, Default)] pub struct MockStateStore { namespaces: Arc>, @@ -115,4 +114,67 @@ impl StateHandle for MockStateHandle { keys.sort(); Ok(keys) } + + fn apply(&self, ops: &[WriteOp]) -> Result<(), StorageError> { + if ops.len() > MAX_APPLY_OPS { + return Err(StorageError::ApplyOpsExceeded { + ops: ops.len(), + cap: MAX_APPLY_OPS, + }); + } + let value_bytes: u64 = ops + .iter() + .map(|op| match op { + WriteOp::Set { value, .. } => value.len() as u64, + WriteOp::Delete { .. } => 0, + }) + .sum(); + if value_bytes > MAX_APPLY_VALUE_BYTES { + return Err(StorageError::ApplyBytesExceeded { + bytes: value_bytes, + cap: MAX_APPLY_VALUE_BYTES, + }); + } + let mut map = self.lock(); + let ns = map.entry(self.namespace.clone()).or_default(); + // Net whole-batch projection, checked once before any mutation so + // an over-quota batch lands nothing (the map mirrors one txn). + if let Some(quota) = self.quota_bytes { + let mut finals: HashMap<&str, Option> = HashMap::new(); + for op in ops { + match op { + WriteOp::Set { key, value } => finals.insert(key, Some(value.len())), + WriteOp::Delete { key } => finals.insert(key, None), + }; + } + let used: u64 = ns.iter().map(|(k, v)| (k.len() + v.len()) as u64).sum(); + let mut released = 0u64; + let mut charged = 0u64; + for (key, value_len) in &finals { + released += ns + .get(*key) + .map(|v| (key.len() + v.len()) as u64) + .unwrap_or(0); + charged += value_len.map(|len| (key.len() + len) as u64).unwrap_or(0); + } + let projected = used.saturating_sub(released) + charged; + if projected > quota { + return Err(StorageError::QuotaExceeded { + needed: projected, + quota, + }); + } + } + for op in ops { + match op { + WriteOp::Set { key, value } => { + ns.insert(key.clone(), value.clone()); + } + WriteOp::Delete { key } => { + ns.remove(key); + } + } + } + Ok(()) + } } diff --git a/crates/nexum-runtime/src/test_utils/types.rs b/nexum/crates/nexum-runtime/src/test_utils/types.rs similarity index 55% rename from crates/nexum-runtime/src/test_utils/types.rs rename to nexum/crates/nexum-runtime/src/test_utils/types.rs index 089d197a..16b20b55 100644 --- a/crates/nexum-runtime/src/test_utils/types.rs +++ b/nexum/crates/nexum-runtime/src/test_utils/types.rs @@ -6,15 +6,12 @@ use crate::host::component::RuntimeTypes; use crate::test_utils::{MockChainProvider, MockStateStore}; /// Lattice binding the mock backends. The extension slot is the type -/// parameter `E`, defaulting to the empty payload (`()`) so an assembly with -/// no extensions composes exactly as a domain-free lattice. An extension -/// crate binds its own `Ext` payload through the same mocks by naming -/// `MockTypes`. -/// -/// This is a type-level marker: it is only ever named, never constructed, so -/// it derives no traits and is zero-sized at runtime. +/// parameter `E` (default `()`); an extension crate binds its own payload as +/// `MockTypes`. A type-level marker, only ever named. pub struct MockTypes(PhantomData E>); +impl crate::sealed::SealedRuntimeTypes for MockTypes {} + impl RuntimeTypes for MockTypes { type Chain = MockChainProvider; type Store = MockStateStore; diff --git a/crates/nexum-sdk-test/Cargo.toml b/nexum/crates/nexum-sdk-test/Cargo.toml similarity index 100% rename from crates/nexum-sdk-test/Cargo.toml rename to nexum/crates/nexum-sdk-test/Cargo.toml diff --git a/nexum/crates/nexum-sdk-test/src/lib.rs b/nexum/crates/nexum-sdk-test/src/lib.rs new file mode 100644 index 00000000..7c637c70 --- /dev/null +++ b/nexum/crates/nexum-sdk-test/src/lib.rs @@ -0,0 +1,1633 @@ +//! In-memory [`nexum_sdk::host`] trait implementations plus assertion +//! helpers, so a module can test its logic without wit-bindgen, +//! wasmtime, or a network round-trip. +//! +//! [`MockHost`] composes the six per-seam mocks ([`MockChain`], +//! [`MockIdentity`], [`MockLocalStore`], [`MockRemoteStore`], +//! [`MockMessaging`], [`MockLogging`]); [`capture_tracing`] records +//! emitted `tracing` events. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] + +use std::cell::{Cell, RefCell}; +use std::collections::{BTreeMap, HashMap}; +use std::fmt::{self, Write as _}; +use std::rc::Rc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use nexum_sdk::Level; +use nexum_sdk::host::{ + ChainError, ChainHost, Fault, IdentityHost, LocalStoreHost, LoggingHost, Message, + MessagingHost, RemoteStoreHost, +}; +use nexum_sdk::prelude::{Address, B256, Signature, keccak256}; +use tracing::field::{Field, Visit}; +use tracing::level_filters::LevelFilter; +use tracing::span::{Attributes, Id, Record}; +use tracing::{Event, Metadata, Subscriber}; + +/// Composed in-memory host; each field is the per-seam mock. +#[derive(Default)] +pub struct MockHost { + /// `nexum:host/chain` mock. + pub chain: MockChain, + /// `nexum:host/identity` mock. + pub identity: MockIdentity, + /// `nexum:host/local-store` mock. + pub store: MockLocalStore, + /// `nexum:host/remote-store` mock. + pub remote_store: MockRemoteStore, + /// `nexum:host/messaging` mock. + pub messaging: MockMessaging, + /// `nexum:host/logging` mock. + pub logging: MockLogging, +} + +impl MockHost { + /// Fresh empty host. + pub fn new() -> Self { + Self::default() + } +} + +impl ChainHost for MockHost { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { + self.chain.request(chain_id, method, params) + } +} + +impl LocalStoreHost for MockHost { + fn get(&self, key: &str) -> Result>, Fault> { + self.store.get(key) + } + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { + self.store.set(key, value) + } + fn delete(&self, key: &str) -> Result<(), Fault> { + self.store.delete(key) + } + fn list_keys(&self, prefix: &str) -> Result, Fault> { + self.store.list_keys(prefix) + } + fn contains(&self, key: &str) -> Result { + self.store.contains(key) + } + fn len(&self, key: &str) -> Result, Fault> { + // Qualified: the mock's inherent `len` counts rows. + LocalStoreHost::len(&self.store, key) + } + fn count(&self, prefix: &str) -> Result { + self.store.count(prefix) + } +} + +impl IdentityHost for MockHost { + fn accounts(&self) -> Result, Fault> { + self.identity.accounts() + } + fn sign(&self, account: Address, message: &[u8]) -> Result { + self.identity.sign(account, message) + } + fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result { + self.identity.sign_typed_data(account, typed_data) + } +} + +impl RemoteStoreHost for MockHost { + fn upload(&self, data: &[u8]) -> Result { + self.remote_store.upload(data) + } + fn download(&self, reference: B256) -> Result, Fault> { + self.remote_store.download(reference) + } + fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault> { + self.remote_store.read_feed(owner, topic) + } + fn write_feed(&self, topic: B256, data: &[u8]) -> Result { + self.remote_store.write_feed(topic, data) + } +} + +impl MessagingHost for MockHost { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + self.messaging.publish(content_topic, payload) + } + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + self.messaging + .query(content_topic, start_time, end_time, limit) + } +} + +impl LoggingHost for MockHost { + fn log(&self, level: Level, message: &str) { + self.logging.log(level, message); + } +} + +/// In-memory [`ChainHost`] over a `(method, params)` response map; +/// records every call. +#[derive(Default)] +pub struct MockChain { + responses: RefCell>>, + calls: RefCell>, +} + +/// One recorded [`MockChain::request`] invocation. +#[derive(Clone, Debug)] +pub struct ChainCall { + /// EVM chain id the guest passed. + pub chain_id: u64, + /// JSON-RPC method name. + pub method: String, + /// JSON-encoded params array (verbatim). + pub params: String, +} + +impl MockChain { + /// Program the response for `(method, params)`; overwrites any prior entry. + pub fn respond_to( + &self, + method: impl Into, + params: impl Into, + result: Result, + ) { + self.responses + .borrow_mut() + .insert((method.into(), params.into()), result); + } + + /// All calls received, in arrival order. + pub fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + + /// Last call received, if any. + pub fn last_call(&self) -> Option { + self.calls.borrow().last().cloned() + } + + /// Total call count. + pub fn call_count(&self) -> usize { + self.calls.borrow().len() + } +} + +impl ChainHost for MockChain { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { + self.calls.borrow_mut().push(ChainCall { + chain_id, + method: method.to_string(), + params: params.to_string(), + }); + self.responses + .borrow() + .get(&(method.to_string(), params.to_string())) + .cloned() + .unwrap_or_else(|| { + Err(ChainError::Fault(Fault::Unsupported(format!( + "MockChain: no response configured for {method} {params}" + )))) + }) + } +} + +/// One recorded [`MockIdentity`] signing invocation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignCall { + /// Account the guest asked to sign with. + pub account: Address, + /// What was signed. + pub payload: SignPayload, +} + +/// The payload of a [`SignCall`], per signing entry point. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SignPayload { + /// A `sign` call: raw message bytes (`personal_sign` semantics). + Message(Vec), + /// A `sign_typed_data` call: the JSON-encoded EIP-712 payload. + TypedData(String), +} + +/// In-memory [`IdentityHost`] with a programmable roster and one signing +/// outcome; records every call. Off-roster accounts fail +/// [`Fault::Denied`]; with no outcome programmed signing fails +/// [`Fault::Unsupported`]. +#[derive(Default)] +pub struct MockIdentity { + accounts: RefCell>, + response: RefCell>>, + calls: RefCell>, +} + +impl MockIdentity { + /// Add an account to the roster. + pub fn add_account(&self, account: Address) { + self.accounts.borrow_mut().push(account); + } + + /// Program the outcome every subsequent signing call returns. + pub fn respond(&self, result: Result) { + *self.response.borrow_mut() = Some(result); + } + + /// All signing calls received, in arrival order. + pub fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + + /// Last signing call received, if any. + pub fn last_call(&self) -> Option { + self.calls.borrow().last().cloned() + } + + /// Total signing call count. + pub fn call_count(&self) -> usize { + self.calls.borrow().len() + } + + fn dispatch(&self, account: Address, payload: SignPayload) -> Result { + self.calls.borrow_mut().push(SignCall { account, payload }); + if !self.accounts.borrow().contains(&account) { + return Err(Fault::Denied(format!( + "MockIdentity: account {account} is not held" + ))); + } + self.response.borrow().clone().unwrap_or_else(|| { + Err(Fault::Unsupported( + "MockIdentity: no signing outcome programmed".to_string(), + )) + }) + } +} + +impl IdentityHost for MockIdentity { + fn accounts(&self) -> Result, Fault> { + Ok(self.accounts.borrow().clone()) + } + + fn sign(&self, account: Address, message: &[u8]) -> Result { + self.dispatch(account, SignPayload::Message(message.to_vec())) + } + + fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result { + self.dispatch(account, SignPayload::TypedData(typed_data.to_owned())) + } +} + +/// One recorded [`MessagingHost::publish`] invocation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PublishRecord { + /// Content topic published to. + pub content_topic: String, + /// Payload bytes, verbatim. + pub payload: Vec, +} + +/// In-memory [`MessagingHost`]: seeded messages answer queries, publishes +/// are recorded, an optional scope mirrors the `messaging_topics` grant. +/// Queries answer from seeds, never from what the guest published. +#[derive(Default)] +pub struct MockMessaging { + history: RefCell>, + published: RefCell>, + scope: RefCell>>, + faults: RefCell>, +} + +impl MockMessaging { + /// Seed one message into the queryable history. + pub fn seed(&self, message: Message) { + self.history.borrow_mut().push(message); + } + + /// Seed a payload on `content_topic` at `timestamp` (ms since the + /// Unix epoch, UTC), no sender. + pub fn seed_payload( + &self, + content_topic: impl Into, + payload: impl Into>, + timestamp: u64, + ) { + self.seed(Message { + content_topic: content_topic.into(), + payload: payload.into(), + timestamp, + sender: None, + }); + } + + /// Confine the mock to `topics`, mirroring the `messaging_topics` + /// grant: a topic is admitted if it equals an entry or descends from + /// one as a `/`-bounded prefix, else [`Fault::Denied`]. An empty + /// grant is unscoped. + pub fn scope_topics(&self, topics: impl IntoIterator>) { + *self.scope.borrow_mut() = Some(topics.into_iter().map(Into::into).collect()); + } + + /// Inject a fault for any operation on a topic starting with + /// `prefix`; first registered match fires. + pub fn fail_on(&self, prefix: impl Into, fault: Fault) { + self.faults.borrow_mut().push((prefix.into(), fault)); + } + + /// All publishes received, in arrival order. + pub fn published(&self) -> Vec { + self.published.borrow().clone() + } + + /// Last publish received, if any. + pub fn last_published(&self) -> Option { + self.published.borrow().last().cloned() + } + + /// Total publish count. + pub fn publish_count(&self) -> usize { + self.published.borrow().len() + } + + fn admit(&self, content_topic: &str) -> Result<(), Fault> { + for (prefix, fault) in self.faults.borrow().iter() { + if content_topic.starts_with(prefix.as_str()) { + return Err(fault.clone()); + } + } + if let Some(scope) = self.scope.borrow().as_ref() + && !topic_in_scope(content_topic, scope) + { + return Err(Fault::Denied(format!( + "MockMessaging: {content_topic} is outside the scoped topics" + ))); + } + Ok(()) + } +} + +/// Grant matching: empty scope admits all; else a topic must equal an +/// entry or descend from one as a `/`-bounded prefix. +fn topic_in_scope(topic: &str, scope: &[String]) -> bool { + if scope.is_empty() { + return true; + } + scope.iter().any(|allowed| { + if topic == allowed { + return true; + } + let prefix = allowed.strip_suffix('/').unwrap_or(allowed); + topic + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/')) + }) +} + +impl MessagingHost for MockMessaging { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + self.admit(content_topic)?; + self.published.borrow_mut().push(PublishRecord { + content_topic: content_topic.to_owned(), + payload: payload.to_vec(), + }); + Ok(()) + } + + /// Exact-topic seeds within the inclusive `start_time..=end_time` + /// window, in seed order; `limit` keeps the newest, the tail. + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + self.admit(content_topic)?; + let mut matches: Vec = self + .history + .borrow() + .iter() + .filter(|message| { + message.content_topic == content_topic + && start_time.is_none_or(|start| message.timestamp >= start) + && end_time.is_none_or(|end| message.timestamp <= end) + }) + .cloned() + .collect(); + if let Some(limit) = limit { + let keep = usize::try_from(limit).unwrap_or(usize::MAX); + if matches.len() > keep { + matches.drain(..matches.len() - keep); + } + } + Ok(matches) + } +} + +/// In-memory [`RemoteStoreHost`]: `keccak256`-addressed blobs plus +/// mutable `(owner, topic)` feeds. Feed writes land under the mock's own +/// owner ([`set_owner`](Self::set_owner), zero by default). +#[derive(Default)] +pub struct MockRemoteStore { + blobs: RefCell>>, + feeds: RefCell>>, + owner: Cell
, + fault: RefCell>, +} + +impl MockRemoteStore { + /// Set the owner feed writes land under. + pub fn set_owner(&self, owner: Address) { + self.owner.set(owner); + } + + /// Seed a blob directly; returns its reference. + pub fn seed_blob(&self, data: impl Into>) -> B256 { + let data = data.into(); + let reference = keccak256(&data); + self.blobs.borrow_mut().insert(reference, data); + reference + } + + /// Seed another owner's feed. + pub fn seed_feed(&self, owner: Address, topic: B256, data: impl Into>) { + self.feeds.borrow_mut().insert((owner, topic), data.into()); + } + + /// Inject a fault every subsequent operation returns. + pub fn fail_with(&self, fault: Fault) { + *self.fault.borrow_mut() = Some(fault); + } + + /// Number of stored blobs. + pub fn blob_count(&self) -> usize { + self.blobs.borrow().len() + } + + fn check_injected_fault(&self) -> Result<(), Fault> { + match self.fault.borrow().as_ref() { + Some(fault) => Err(fault.clone()), + None => Ok(()), + } + } +} + +impl RemoteStoreHost for MockRemoteStore { + fn upload(&self, data: &[u8]) -> Result { + self.check_injected_fault()?; + Ok(self.seed_blob(data)) + } + + fn download(&self, reference: B256) -> Result, Fault> { + self.check_injected_fault()?; + self.blobs + .borrow() + .get(&reference) + .cloned() + .ok_or_else(|| Fault::Unavailable(format!("MockRemoteStore: no blob at {reference}"))) + } + + fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault> { + self.check_injected_fault()?; + Ok(self.feeds.borrow().get(&(owner, topic)).cloned()) + } + + fn write_feed(&self, topic: B256, data: &[u8]) -> Result { + self.check_injected_fault()?; + let reference = self.seed_blob(data); + self.feeds + .borrow_mut() + .insert((self.owner.get(), topic), data.to_vec()); + Ok(reference) + } +} + +/// In-memory [`LocalStoreHost`]: namespaced views over one shared row +/// map, with store-wide entry and byte limits. +/// [`namespaced`](Self::namespaced) derives a sibling view over the same +/// rows; identical keys in different namespaces never collide, and limits +/// are shared across namespaces. Every `set` commits immediately, with no +/// transaction rollback on trap. +#[derive(Default)] +pub struct MockLocalStore { + shared: Rc, + namespace: String, + /// Key patterns that trigger injected faults on any operation. + error_patterns: RefCell>, +} + +/// Backing rows and limits shared by every namespaced view. +#[derive(Default)] +struct SharedRows { + /// Rows keyed by `(namespace, key)`. + rows: RefCell>>, + /// Total stored bytes (key + value) across all namespaces. + bytes: Cell, + /// When set, `set` on a new key fails once the store holds this + /// many rows. + max_entries: Cell>, + /// When set, `set` fails once stored bytes would exceed this. + max_bytes: Cell>, +} + +impl MockLocalStore { + /// A view over the same rows under `namespace`; same-namespace views + /// alias, different namespaces isolate identical keys. + /// + /// # Panics + /// + /// On an empty namespace. + pub fn namespaced(&self, namespace: impl Into) -> MockLocalStore { + let namespace = namespace.into(); + assert!( + !namespace.is_empty(), + "MockLocalStore: namespace must not be empty", + ); + MockLocalStore { + shared: Rc::clone(&self.shared), + namespace, + error_patterns: RefCell::new(Vec::new()), + } + } + + /// Number of rows in this view's namespace. + pub fn len(&self) -> usize { + self.shared + .rows + .borrow() + .keys() + .filter(|(ns, _)| *ns == self.namespace) + .count() + } + + /// Whether this view's namespace holds no rows. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Direct read of this view's namespace, for assertions. + pub fn snapshot(&self) -> HashMap> { + self.shared + .rows + .borrow() + .iter() + .filter(|((ns, _), _)| *ns == self.namespace) + .map(|((_, key), value)| (key.clone(), value.clone())) + .collect() + } + + /// Cap row count across all namespaces; `set` on a new key then + /// fails, overwrites still succeed. + pub fn set_max_entries(&self, limit: usize) { + self.shared.max_entries.set(Some(limit)); + } + + /// Cap total stored bytes (key + value, all namespaces); an over-cap + /// `set` fails, deletes and overwrites release displaced bytes. + pub fn set_max_bytes(&self, limit: usize) { + self.shared.max_bytes.set(Some(limit)); + } + + /// Inject a fault for any operation whose key starts with `prefix`; + /// first registered match fires. + pub fn fail_on(&self, prefix: impl Into, fault: Fault) { + self.error_patterns + .borrow_mut() + .push((prefix.into(), fault)); + } + + fn check_injected_error(&self, key: &str) -> Result<(), Fault> { + for (pattern, fault) in self.error_patterns.borrow().iter() { + if key.starts_with(pattern) { + return Err(fault.clone()); + } + } + Ok(()) + } +} + +impl LocalStoreHost for MockLocalStore { + fn get(&self, key: &str) -> Result>, Fault> { + self.check_injected_error(key)?; + Ok(self + .shared + .rows + .borrow() + .get(&(self.namespace.clone(), key.to_string())) + .cloned()) + } + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { + self.check_injected_error(key)?; + let mut rows = self.shared.rows.borrow_mut(); + let compound = (self.namespace.clone(), key.to_string()); + let existing = rows.get(&compound).map(Vec::len); + if existing.is_none() + && let Some(limit) = self.shared.max_entries.get() + && rows.len() >= limit + { + return Err(Fault::Internal(format!( + "MockLocalStore: max entries ({limit}) reached" + ))); + } + // Same-key overwrites release the displaced bytes before the + // new row is charged. + let displaced = existing.map_or(0, |len| key.len() + len); + let total = self.shared.bytes.get() - displaced + key.len() + value.len(); + if let Some(budget) = self.shared.max_bytes.get() + && total > budget + { + return Err(Fault::Internal(format!( + "MockLocalStore: max bytes ({budget}) reached" + ))); + } + rows.insert(compound, value.to_vec()); + self.shared.bytes.set(total); + Ok(()) + } + fn delete(&self, key: &str) -> Result<(), Fault> { + self.check_injected_error(key)?; + if let Some(value) = self + .shared + .rows + .borrow_mut() + .remove(&(self.namespace.clone(), key.to_string())) + { + self.shared + .bytes + .set(self.shared.bytes.get() - key.len() - value.len()); + } + Ok(()) + } + fn list_keys(&self, prefix: &str) -> Result, Fault> { + self.check_injected_error(prefix)?; + let mut keys: Vec = self + .shared + .rows + .borrow() + .keys() + .filter(|(ns, key)| *ns == self.namespace && key.starts_with(prefix)) + .map(|(_, key)| key.clone()) + .collect(); + keys.sort(); + Ok(keys) + } + fn contains(&self, key: &str) -> Result { + self.check_injected_error(key)?; + Ok(self + .shared + .rows + .borrow() + .contains_key(&(self.namespace.clone(), key.to_string()))) + } + fn len(&self, key: &str) -> Result, Fault> { + self.check_injected_error(key)?; + Ok(self + .shared + .rows + .borrow() + .get(&(self.namespace.clone(), key.to_string())) + .map(|v| v.len() as u64)) + } + fn count(&self, prefix: &str) -> Result { + self.check_injected_error(prefix)?; + Ok(self + .shared + .rows + .borrow() + .keys() + .filter(|(ns, key)| *ns == self.namespace && key.starts_with(prefix)) + .count() as u64) + } +} + +/// Trap-injection wrapper over a [`LocalStoreHost`]: counts `set` and +/// `delete` calls and, once armed, simulates a guest trap mid-flow. +/// [`arm_after`](Self::arm_after)`(n)` lets the next `n` writes land; +/// the write after that trips the trap, and from then on every +/// operation - reads included - faults until +/// [`disarm`](Self::disarm), because nothing past a trap executes. +/// Sweeping `n` over a flow's write count drives the store through +/// every torn prefix a trap can strand, so a recovery pass can be +/// held to convergence from each one. +/// +/// Review rule this harness enforces (#609): no in-store invariant +/// may span two `set` calls unless the intermediate state is +/// self-healing or the writes ride the atomic `apply` batch verb. +pub struct TrapStore { + inner: H, + /// Write calls the trap let through. + writes: Cell, + /// Writes still allowed before the trap trips; `None` when unarmed. + remaining: Cell>, + tripped: Cell, +} + +impl TrapStore { + /// Wrap `inner`, unarmed: every operation delegates, writes are + /// counted. + pub fn new(inner: H) -> Self { + Self { + inner, + writes: Cell::new(0), + remaining: Cell::new(None), + tripped: Cell::new(false), + } + } + + /// Arm the trap: the next `n` writes land, the one after trips it. + pub fn arm_after(&self, n: u64) { + self.remaining.set(Some(n)); + self.tripped.set(false); + } + + /// Clear the trap and the tripped state; operations resume. The + /// write count keeps accumulating. + pub fn disarm(&self) { + self.remaining.set(None); + self.tripped.set(false); + } + + /// `set`/`delete` calls the trap let through since construction. + pub fn writes(&self) -> u64 { + self.writes.get() + } + + /// Whether the trap has fired. + pub fn tripped(&self) -> bool { + self.tripped.get() + } + + /// The wrapped store. + pub fn inner(&self) -> &H { + &self.inner + } + + /// Fault unless still executing: past the trap nothing runs. + fn read_gate(&self) -> Result<(), Fault> { + if self.tripped.get() { + return Err(Fault::Internal("TrapStore: trapped".into())); + } + Ok(()) + } + + /// Spend one write from the armed budget, tripping at zero. + fn write_gate(&self) -> Result<(), Fault> { + self.read_gate()?; + if let Some(remaining) = self.remaining.get() { + if remaining == 0 { + self.tripped.set(true); + return Err(Fault::Internal("TrapStore: trapped".into())); + } + self.remaining.set(Some(remaining - 1)); + } + self.writes.set(self.writes.get() + 1); + Ok(()) + } +} + +impl LocalStoreHost for TrapStore { + fn get(&self, key: &str) -> Result>, Fault> { + self.read_gate()?; + self.inner.get(key) + } + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { + self.write_gate()?; + self.inner.set(key, value) + } + fn delete(&self, key: &str) -> Result<(), Fault> { + self.write_gate()?; + self.inner.delete(key) + } + fn list_keys(&self, prefix: &str) -> Result, Fault> { + self.read_gate()?; + self.inner.list_keys(prefix) + } + fn contains(&self, key: &str) -> Result { + self.read_gate()?; + self.inner.contains(key) + } + fn len(&self, key: &str) -> Result, Fault> { + self.read_gate()?; + self.inner.len(key) + } + fn count(&self, prefix: &str) -> Result { + self.read_gate()?; + self.inner.count(prefix) + } +} + +/// One recorded log line. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LogLine { + /// Severity the module passed. + pub level: Level, + /// Message body. + pub message: String, +} + +/// In-memory [`LoggingHost`] that buffers every emitted line. +#[derive(Default)] +pub struct MockLogging { + lines: RefCell>, +} + +impl MockLogging { + /// All buffered log lines, in emission order. + pub fn lines(&self) -> Vec { + self.lines.borrow().clone() + } + + /// `true` if any buffered line contains `needle` (substring match). + pub fn contains(&self, needle: &str) -> bool { + self.lines + .borrow() + .iter() + .any(|l| l.message.contains(needle)) + } + + /// Count of lines at `level`. + pub fn count_at(&self, level: Level) -> usize { + self.lines + .borrow() + .iter() + .filter(|l| l.level == level) + .count() + } +} + +impl LoggingHost for MockLogging { + fn log(&self, level: Level, message: &str) { + self.lines.borrow_mut().push(LogLine { + level, + message: message.to_string(), + }); + } +} + +/// One tracing event captured pre-flattening. +#[derive(Clone, Debug, PartialEq)] +pub struct CapturedEvent { + /// Event severity. + pub level: Level, + /// Callsite target (module path by default). + pub target: String, + /// The `message` field; empty when the event carried none. + pub message: String, + /// Every non-message field, keyed by name. + pub fields: BTreeMap, +} + +/// A field value as tracing's `Visit` delivered it. +#[derive(Clone, Debug, PartialEq)] +pub enum FieldValue { + /// A `record_str` value. + Str(String), + /// A `record_u64` value. + U64(u64), + /// A `record_i64` value. + I64(i64), + /// A `record_bool` value. + Bool(bool), + /// A `record_debug` fallback (`?x`, `%x`, `f64`, ...), pre-rendered + /// with `{:?}`. + Debug(String), +} + +impl fmt::Display for FieldValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FieldValue::Str(v) | FieldValue::Debug(v) => f.write_str(v), + FieldValue::U64(v) => write!(f, "{v}"), + FieldValue::I64(v) => write!(f, "{v}"), + FieldValue::Bool(v) => write!(f, "{v}"), + } + } +} + +impl CapturedEvent { + /// The value recorded for `name`, if the event carried it. + pub fn field(&self, name: &str) -> Option<&FieldValue> { + self.fields.get(name) + } + + /// Display-rendered field, for string comparisons. + pub fn field_str(&self, name: &str) -> Option { + self.fields.get(name).map(FieldValue::to_string) + } +} + +/// Events captured during [`capture_tracing`]. +pub struct CapturedEvents { + events: Arc>>, +} + +impl CapturedEvents { + /// Every captured event, in emission order. + pub fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + + /// Whether no events were captured. + pub fn is_empty(&self) -> bool { + self.events.lock().unwrap().is_empty() + } + + /// Count of events at `level`. + pub fn count_at(&self, level: Level) -> usize { + self.events + .lock() + .unwrap() + .iter() + .filter(|e| e.level == level) + .count() + } + + /// Whether any captured event satisfies `pred`. + pub fn any(&self, pred: impl Fn(&CapturedEvent) -> bool) -> bool { + self.events.lock().unwrap().iter().any(pred) + } + + /// Exactly one matching event; panics with the full capture dump + /// otherwise. + pub fn expect_one(&self, pred: impl Fn(&CapturedEvent) -> bool) -> CapturedEvent { + let events = self.events.lock().unwrap(); + let matches: Vec<&CapturedEvent> = events.iter().filter(|e| pred(e)).collect(); + match matches.as_slice() { + [only] => (*only).clone(), + other => panic!( + "expected exactly one matching event, found {}; captured: {events:#?}", + other.len(), + ), + } + } +} + +type Buffer = Arc>>; + +std::thread_local! { + /// The capture buffer active on this thread, if any. + static ACTIVE_CAPTURE: RefCell> = const { RefCell::new(None) }; +} + +/// Restores the previous thread-local capture slot when a +/// `capture_tracing` call returns or unwinds. +struct CaptureGuard(Option); + +impl Drop for CaptureGuard { + fn drop(&mut self) { + ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = self.0.take()); + } +} + +/// Events-only subscriber recording each event into the thread's active +/// buffer; spans are inert. +struct CaptureSubscriber { + next_id: AtomicU64, +} + +impl Subscriber for CaptureSubscriber { + fn enabled(&self, _metadata: &Metadata<'_>) -> bool { + true + } + + fn max_level_hint(&self) -> Option { + Some(LevelFilter::TRACE) + } + + fn new_span(&self, _span: &Attributes<'_>) -> Id { + // Spans are inert, but a valid non-zero id must be returned. + let raw = self.next_id.fetch_add(1, Ordering::Relaxed).wrapping_add(1); + Id::from_u64(raw.max(1)) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows: &Id) {} + + fn event(&self, event: &Event<'_>) { + let mut visitor = FieldVisitor::default(); + event.record(&mut visitor); + let captured = CapturedEvent { + level: *event.metadata().level(), + target: event.metadata().target().to_owned(), + message: visitor.message, + fields: visitor.fields, + }; + ACTIVE_CAPTURE.with(|slot| { + if let Some(buffer) = slot.borrow().as_ref() { + buffer.lock().unwrap().push(captured); + } + }); + } + + fn enter(&self, _span: &Id) {} + + fn exit(&self, _span: &Id) {} +} + +/// Splits an event into its `message` field and a name-keyed map of the rest. +#[derive(Default)] +struct FieldVisitor { + message: String, + fields: BTreeMap, +} + +impl Visit for FieldVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if field.name() == "message" { + // tracing delivers `message` as the `format_args!` result, whose + // `Debug` renders unquoted; keep the raw text, do not re-quote it. + let _ = write!(self.message, "{value:?}"); + } else { + self.fields.insert( + field.name().to_owned(), + FieldValue::Debug(format!("{value:?}")), + ); + } + } + + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message.push_str(value); + } else { + self.fields + .insert(field.name().to_owned(), FieldValue::Str(value.to_owned())); + } + } + + fn record_u64(&mut self, field: &Field, value: u64) { + self.fields + .insert(field.name().to_owned(), FieldValue::U64(value)); + } + + fn record_i64(&mut self, field: &Field, value: i64) { + self.fields + .insert(field.name().to_owned(), FieldValue::I64(value)); + } + + fn record_bool(&mut self, field: &Field, value: bool) { + self.fields + .insert(field.name().to_owned(), FieldValue::Bool(value)); + } +} + +static INSTALL_ROUTING: std::sync::Once = std::sync::Once::new(); + +/// Run `f`, returning its value and every `tracing` event it emitted on +/// the calling thread. Capture is thread-scoped; events emitted outside +/// any `capture_tracing` call are dropped. +pub fn capture_tracing(f: impl FnOnce() -> R) -> (R, CapturedEvents) { + INSTALL_ROUTING.call_once(|| { + let _ = tracing::subscriber::set_global_default(CaptureSubscriber { + next_id: AtomicU64::new(0), + }); + }); + + let events: Buffer = Arc::new(Mutex::new(Vec::new())); + let previous = ACTIVE_CAPTURE.with(|slot| slot.borrow_mut().replace(Arc::clone(&events))); + let _guard = CaptureGuard(previous); + let result = f(); + drop(_guard); + (result, CapturedEvents { events }) +} + +#[cfg(test)] +mod tests { + use nexum_sdk::prelude::U256; + + use super::*; + + #[test] + fn chain_records_calls_and_returns_programmed_response() { + let chain = MockChain::default(); + chain.respond_to("eth_blockNumber", "[]", Ok("\"0x1234\"".into())); + + assert_eq!( + chain.request(1, "eth_blockNumber", "[]").unwrap(), + "\"0x1234\"" + ); + assert_eq!(chain.call_count(), 1); + let last = chain.last_call().unwrap(); + assert_eq!(last.chain_id, 1); + assert_eq!(last.method, "eth_blockNumber"); + } + + #[test] + fn chain_unconfigured_method_returns_unsupported() { + let chain = MockChain::default(); + let err = chain.request(1, "eth_call", "[]").unwrap_err(); + let ChainError::Fault(Fault::Unsupported(msg)) = err else { + panic!("expected Unsupported fault, got {err:?}"); + }; + assert!(msg.contains("MockChain")); + assert_eq!(chain.call_count(), 1); + } + + #[test] + fn local_store_round_trips() { + let store = MockLocalStore::default(); + store.set("k", b"v").unwrap(); + assert_eq!(store.get("k").unwrap().as_deref(), Some(&b"v"[..])); + store.delete("k").unwrap(); + assert!(store.get("k").unwrap().is_none()); + } + + #[test] + fn local_store_list_keys_prefix_scan() { + let store = MockLocalStore::default(); + store.set("watch:a:1", b"").unwrap(); + store.set("watch:a:2", b"").unwrap(); + store.set("submitted:1", b"").unwrap(); + let keys = store.list_keys("watch:").unwrap(); + assert_eq!(keys, vec!["watch:a:1", "watch:a:2"]); + } + + #[test] + fn logging_captures_lines_and_filters_by_level() { + let log = MockLogging::default(); + log.log(Level::INFO, "hello"); + log.log(Level::WARN, "uh oh"); + log.log(Level::INFO, "still here"); + + assert_eq!(log.lines().len(), 3); + assert_eq!(log.count_at(Level::INFO), 2); + assert_eq!(log.count_at(Level::WARN), 1); + assert!(log.contains("uh oh")); + } + + #[test] + fn local_store_metadata_queries() { + let store = MockLocalStore::default(); + store.set("watch:a", b"abc").unwrap(); + store.set("watch:b", b"").unwrap(); + store.set("posted:1", b"x").unwrap(); + + assert!(store.contains("watch:a").unwrap()); + assert!(!store.contains("missing").unwrap()); + assert_eq!(LocalStoreHost::len(&store, "watch:a").unwrap(), Some(3)); + assert_eq!(LocalStoreHost::len(&store, "watch:b").unwrap(), Some(0)); + assert_eq!(LocalStoreHost::len(&store, "missing").unwrap(), None); + assert_eq!(store.count("watch:").unwrap(), 2); + assert_eq!(store.count("").unwrap(), 3); + + // Metadata queries stay namespace-scoped. + let other = store.namespaced("other"); + assert_eq!(other.count("").unwrap(), 0); + assert!(!other.contains("watch:a").unwrap()); + + // And respect fault injection. + store.fail_on("bad:", Fault::Internal("injected".into())); + assert!(store.contains("bad:k").is_err()); + assert!(LocalStoreHost::len(&store, "bad:k").is_err()); + assert!(store.count("bad:").is_err()); + } + + #[test] + fn local_store_error_injection() { + let store = MockLocalStore::default(); + store.fail_on("bad:", Fault::Internal("injected".into())); + // Non-matching keys work fine. + store.set("good:k", b"v").unwrap(); + assert_eq!(store.get("good:k").unwrap().as_deref(), Some(&b"v"[..])); + // Matching keys trigger the error. + assert!(store.set("bad:k", b"v").is_err()); + assert!(store.get("bad:k").is_err()); + assert!(store.delete("bad:k").is_err()); + assert!(store.list_keys("bad:").is_err()); + } + + #[test] + fn trap_store_counts_writes_unarmed() { + let store = TrapStore::new(MockLocalStore::default()); + store.set("a", b"1").unwrap(); + store.set("b", b"2").unwrap(); + store.delete("a").unwrap(); + assert_eq!(store.writes(), 3); + assert!(!store.tripped()); + assert_eq!(store.get("b").unwrap().as_deref(), Some(&b"2"[..])); + } + + #[test] + fn trap_store_trips_after_the_armed_budget() { + let store = TrapStore::new(MockLocalStore::default()); + store.arm_after(2); + store.set("a", b"1").unwrap(); + store.delete("a").unwrap(); + // The third write trips; the row never lands. + assert!(store.set("b", b"2").is_err()); + assert!(store.tripped()); + assert_eq!(store.writes(), 2); + assert!(store.inner().get("b").unwrap().is_none()); + } + + #[test] + fn trap_store_faults_every_operation_once_tripped() { + let store = TrapStore::new(MockLocalStore::default()); + store.set("a", b"1").unwrap(); + store.arm_after(0); + assert!(store.set("b", b"2").is_err()); + // Nothing past a trap executes, reads included. + assert!(store.get("a").is_err()); + assert!(store.list_keys("").is_err()); + assert!(store.contains("a").is_err()); + assert!(store.delete("a").is_err()); + } + + #[test] + fn trap_store_disarm_resumes_over_the_surviving_rows() { + let store = TrapStore::new(MockLocalStore::default()); + store.set("a", b"1").unwrap(); + store.arm_after(0); + assert!(store.set("b", b"2").is_err()); + + store.disarm(); + assert!(!store.tripped()); + // The torn prefix survives: `a` landed, `b` never did. + assert_eq!(store.get("a").unwrap().as_deref(), Some(&b"1"[..])); + assert!(store.get("b").unwrap().is_none()); + store.set("b", b"2").unwrap(); + assert_eq!(store.writes(), 2); + } + + #[test] + fn local_store_max_entries_enforced() { + let store = MockLocalStore::default(); + store.set_max_entries(2); + store.set("a", b"1").unwrap(); + store.set("b", b"2").unwrap(); + // Updating an existing key is OK even at the limit. + store.set("b", b"3").unwrap(); + // Adding a new key exceeds the limit. + let err = store.set("c", b"4").unwrap_err(); + assert!(matches!(err, Fault::Internal(ref m) if m.contains("max entries"))); + assert_eq!(store.len(), 2); + } + + #[test] + fn local_store_namespaces_isolate_identical_keys() { + let store = MockLocalStore::default(); + let other = store.namespaced("other-module"); + store.set("watch:a", b"mine").unwrap(); + other.set("watch:a", b"theirs").unwrap(); + + assert_eq!(store.get("watch:a").unwrap().as_deref(), Some(&b"mine"[..])); + assert_eq!( + other.get("watch:a").unwrap().as_deref(), + Some(&b"theirs"[..]), + ); + + // Scans, counts, and snapshots stay view-scoped. + assert_eq!(store.len(), 1); + assert_eq!(other.len(), 1); + assert_eq!(store.list_keys("").unwrap(), vec!["watch:a"]); + assert_eq!(store.snapshot().get("watch:a").unwrap(), b"mine"); + + // Deletes never reach across the namespace boundary. + other.delete("watch:a").unwrap(); + assert!(other.is_empty()); + assert_eq!(store.get("watch:a").unwrap().as_deref(), Some(&b"mine"[..])); + } + + #[test] + fn local_store_same_namespace_views_alias_the_same_rows() { + let store = MockLocalStore::default(); + let one = store.namespaced("mod"); + let two = store.namespaced("mod"); + one.set("k", b"v").unwrap(); + assert_eq!(two.get("k").unwrap().as_deref(), Some(&b"v"[..])); + } + + #[test] + #[should_panic(expected = "namespace must not be empty")] + fn local_store_empty_namespace_panics() { + let _ = MockLocalStore::default().namespaced(""); + } + + #[test] + fn local_store_entry_limit_spans_namespaces() { + let store = MockLocalStore::default(); + store.set_max_entries(2); + let other = store.namespaced("other-module"); + store.set("a", b"1").unwrap(); + other.set("b", b"2").unwrap(); + // The store is one shared file: a sibling namespace's rows + // consume the same headroom. + let err = store.set("c", b"3").unwrap_err(); + assert!(matches!(err, Fault::Internal(ref m) if m.contains("max entries"))); + } + + #[test] + fn local_store_byte_budget_enforced_and_released() { + let store = MockLocalStore::default(); + store.set_max_bytes(8); + store.set("abcd", b"1234").unwrap(); // 4 + 4 = 8, exactly at budget + let err = store.set("x", b"y").unwrap_err(); + assert!(matches!(err, Fault::Internal(ref m) if m.contains("max bytes"))); + + // A same-key overwrite releases the displaced value first. + store.set("abcd", b"12").unwrap(); + store.set("x", b"y").unwrap(); + + // Deleting releases the whole row's bytes. + store.delete("abcd").unwrap(); + store.set("ab", b"12").unwrap(); + assert_eq!(store.len(), 2); + } + + #[test] + fn identity_roster_and_programmed_outcome() { + let identity = MockIdentity::default(); + let account = Address::from([0xAA; 20]); + assert!(identity.accounts().unwrap().is_empty()); + identity.add_account(account); + assert_eq!(identity.accounts().unwrap(), vec![account]); + + // No outcome programmed: signing is unsupported, the stub posture. + let err = identity.sign(account, b"hello").unwrap_err(); + assert!(matches!(err, Fault::Unsupported(ref m) if m.contains("MockIdentity"))); + + let signature = Signature::new(U256::from(1), U256::from(2), false); + identity.respond(Ok(signature)); + assert_eq!(identity.sign(account, b"hello").unwrap(), signature); + assert_eq!(identity.sign_typed_data(account, "{}").unwrap(), signature); + + assert_eq!(identity.call_count(), 3); + assert_eq!( + identity.last_call().unwrap(), + SignCall { + account, + payload: SignPayload::TypedData("{}".to_owned()), + }, + ); + } + + #[test] + fn identity_denies_off_roster_accounts() { + let identity = MockIdentity::default(); + identity.respond(Ok(Signature::new(U256::from(1), U256::from(2), true))); + let err = identity.sign(Address::from([0xBB; 20]), b"x").unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + // The refused call is still recorded. + assert_eq!(identity.call_count(), 1); + } + + #[test] + fn messaging_records_publishes_and_answers_from_seeds() { + let messaging = MockMessaging::default(); + messaging.seed_payload("/acme/1/orders/proto", b"one".to_vec(), 10); + messaging.seed_payload("/acme/1/orders/proto", b"two".to_vec(), 20); + messaging.seed_payload("/acme/1/other/proto", b"noise".to_vec(), 15); + + messaging.publish("/acme/1/orders/proto", b"out").unwrap(); + assert_eq!(messaging.publish_count(), 1); + assert_eq!( + messaging.last_published().unwrap(), + PublishRecord { + content_topic: "/acme/1/orders/proto".to_owned(), + payload: b"out".to_vec(), + }, + ); + + // Publishes never leak into query results. + let all = messaging + .query("/acme/1/orders/proto", None, None, None) + .unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].payload, b"one"); + assert_eq!(all[1].payload, b"two"); + } + + #[test] + fn messaging_query_applies_bounds_and_limit() { + let messaging = MockMessaging::default(); + for (payload, ts) in [(b"a", 10u64), (b"b", 20), (b"c", 30), (b"d", 40)] { + messaging.seed_payload("/t", payload.to_vec(), ts); + } + + let window = messaging.query("/t", Some(20), Some(30), None).unwrap(); + assert_eq!(window.len(), 2); + assert_eq!(window[0].payload, b"b"); + + // A limit keeps the newest matches: the tail of the window. + let limited = messaging.query("/t", None, None, Some(2)).unwrap(); + assert_eq!(limited.len(), 2); + assert_eq!(limited[0].payload, b"c"); + assert_eq!(limited[1].payload, b"d"); + } + + #[test] + fn messaging_scope_denies_off_grant_topics() { + let messaging = MockMessaging::default(); + messaging.scope_topics(["/acme/1/orders/proto"]); + + messaging.publish("/acme/1/orders/proto", b"ok").unwrap(); + let err = messaging.publish("/other", b"no").unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + let err = messaging.query("/other", None, None, None).unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + // The refused publish was never recorded. + assert_eq!(messaging.publish_count(), 1); + } + + #[test] + fn messaging_scope_matches_the_host_grant() { + // A prefix grant admits the family beneath it, bounded at `/`. + let messaging = MockMessaging::default(); + messaging.scope_topics(["/nexum/1/"]); + messaging + .publish("/nexum/1/acme-orders/proto", b"x") + .unwrap(); + messaging.publish("/nexum/1/twap/proto", b"x").unwrap(); + let err = messaging.publish("/nexum/2/acme/proto", b"x").unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + + // No trailing slash still bounds on the separator: a grant never + // leaks into a longer sibling segment. + let messaging = MockMessaging::default(); + messaging.scope_topics(["/nexum/1/acme"]); + messaging.publish("/nexum/1/acme", b"x").unwrap(); + messaging.publish("/nexum/1/acme/orders", b"x").unwrap(); + let err = messaging + .publish("/nexum/1/acme-orders/proto", b"x") + .unwrap_err(); + assert!(matches!(err, Fault::Denied(_))); + + // An empty grant is unscoped, the host's module default. + let messaging = MockMessaging::default(); + messaging.scope_topics(Vec::::new()); + messaging.publish("/anywhere/at/all", b"x").unwrap(); + } + + #[test] + fn messaging_fault_injection_fires_by_prefix() { + let messaging = MockMessaging::default(); + messaging.fail_on("/flaky", Fault::Timeout); + assert!(matches!( + messaging.publish("/flaky/topic", b"x").unwrap_err(), + Fault::Timeout, + )); + messaging.publish("/steady", b"x").unwrap(); + } + + #[test] + fn remote_store_round_trips_content_addressed_blobs() { + let store = MockRemoteStore::default(); + let reference = store.upload(b"chunk").unwrap(); + assert_eq!(reference, keccak256(b"chunk")); + assert_eq!(store.download(reference).unwrap(), b"chunk"); + assert_eq!(store.blob_count(), 1); + + let missing = store.download(B256::from([0xCC; 32])).unwrap_err(); + assert!(matches!(missing, Fault::Unavailable(ref m) if m.contains("MockRemoteStore"))); + } + + #[test] + fn remote_store_feeds_are_owner_scoped() { + let store = MockRemoteStore::default(); + let owner = Address::from([0xAA; 20]); + let topic = B256::from([0x11; 32]); + + // Writes land under the mock's own owner and stay downloadable. + store.set_owner(owner); + let reference = store.write_feed(topic, b"v1").unwrap(); + assert_eq!(store.download(reference).unwrap(), b"v1"); + assert_eq!( + store.read_feed(owner, topic).unwrap().as_deref(), + Some(&b"v1"[..]) + ); + + // Another owner's feed is a distinct slot. + let other = Address::from([0xBB; 20]); + assert_eq!(store.read_feed(other, topic).unwrap(), None); + store.seed_feed(other, topic, b"theirs"); + assert_eq!( + store.read_feed(other, topic).unwrap().as_deref(), + Some(&b"theirs"[..]), + ); + } + + #[test] + fn remote_store_fault_injection_covers_every_operation() { + let store = MockRemoteStore::default(); + store.fail_with(Fault::Timeout); + assert!(matches!(store.upload(b"x").unwrap_err(), Fault::Timeout)); + assert!(matches!( + store.download(B256::ZERO).unwrap_err(), + Fault::Timeout, + )); + assert!(matches!( + store.read_feed(Address::ZERO, B256::ZERO).unwrap_err(), + Fault::Timeout, + )); + assert!(matches!( + store.write_feed(B256::ZERO, b"x").unwrap_err(), + Fault::Timeout, + )); + } + + #[test] + fn mock_host_dispatches_through_supertrait() { + let host = MockHost::new(); + host.chain + .respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); + host.messaging.seed_payload("/t", b"m".to_vec(), 1); + + // Through the `Host` supertrait: all six seams on one value. + let _: &dyn nexum_sdk::host::Host = &host; + host.set("key", b"val").unwrap(); + assert_eq!(host.get("key").unwrap().as_deref(), Some(&b"val"[..])); + assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); + assert!(host.accounts().unwrap().is_empty()); + assert_eq!(host.query("/t", None, None, None).unwrap().len(), 1); + let reference = host.upload(b"blob").unwrap(); + assert_eq!(host.download(reference).unwrap(), b"blob"); + host.log(Level::INFO, "happy path"); + + assert_eq!(host.chain.call_count(), 1); + assert_eq!(host.logging.lines().len(), 1); + assert_eq!(host.store.len(), 1); + assert_eq!(host.remote_store.blob_count(), 1); + } + + #[test] + fn capture_message_only_event_has_empty_fields() { + let (_, logs) = capture_tracing(|| tracing::info!("hello")); + let events = logs.events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].level, Level::INFO); + assert_eq!(events[0].message, "hello"); + assert!(events[0].fields.is_empty()); + } + + #[test] + fn capture_fields_land_as_typed_values() { + let (_, logs) = capture_tracing(|| { + tracing::warn!( + name = "eth", + count = 7u64, + signed = -3i64, + ready = true, + answer = ?Some(9), + "changed", + ); + }); + let ev = logs.expect_one(|e| e.level == Level::WARN); + assert_eq!(ev.message, "changed"); + assert_eq!(ev.field("name"), Some(&FieldValue::Str("eth".to_owned()))); + assert_eq!(ev.field("count"), Some(&FieldValue::U64(7))); + assert_eq!(ev.field("signed"), Some(&FieldValue::I64(-3))); + assert_eq!(ev.field("ready"), Some(&FieldValue::Bool(true))); + assert_eq!( + ev.field("answer"), + Some(&FieldValue::Debug("Some(9)".to_owned())), + ); + } + + #[test] + fn capture_display_recorded_value_lands_as_debug() { + let (_, logs) = capture_tracing(|| tracing::info!(x = %42u32, "shown")); + let ev = logs.expect_one(|e| e.message == "shown"); + assert!(matches!(ev.field("x"), Some(FieldValue::Debug(_)))); + assert_eq!(ev.field_str("x").as_deref(), Some("42")); + } + + #[test] + fn events_outside_capture_are_dropped() { + // Prime the global default via one capture, then emit outside any. + let (_, _) = capture_tracing(|| tracing::info!("primed")); + tracing::info!("orphan"); + let (_, logs) = capture_tracing(|| tracing::info!("inside")); + let events = logs.events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].message, "inside"); + } + + #[test] + fn concurrent_captures_are_thread_isolated() { + use std::sync::Barrier; + let barrier = Arc::new(Barrier::new(2)); + let other = Arc::clone(&barrier); + let handle = std::thread::spawn(move || { + let (_, logs) = capture_tracing(|| { + other.wait(); + tracing::info!("thread-one"); + }); + logs.events() + }); + let (_, main_logs) = capture_tracing(|| { + barrier.wait(); + tracing::info!("thread-two"); + }); + let thread_events = handle.join().unwrap(); + + assert_eq!(main_logs.events().len(), 1); + assert_eq!(main_logs.events()[0].message, "thread-two"); + assert_eq!(thread_events.len(), 1); + assert_eq!(thread_events[0].message, "thread-one"); + } +} diff --git a/crates/nexum-sdk/Cargo.toml b/nexum/crates/nexum-sdk/Cargo.toml similarity index 52% rename from crates/nexum-sdk/Cargo.toml rename to nexum/crates/nexum-sdk/Cargo.toml index 8410babb..4e3ddd46 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/nexum/crates/nexum-sdk/Cargo.toml @@ -19,16 +19,39 @@ description = "Guest-side SDK for nexum runtime modules: host-neutral helpers us stderr-echo = [] [dependencies] +# Re-exported as `nexum_sdk::module`; the proc-macro emits glue that +# calls back into this crate (`bind_host_via_wit_bindgen!`, the host +# trait seam, the tracing facade). +nexum-module-macros = { path = "../nexum-module-macros" } +# The single-source vocabularies: `ChainMethod` (re-exported as +# `chain::ChainMethod`) and the fault labels. Its world-synthesis half +# is unused here, so the guest links `toml` and never calls it. +nexum-world = { path = "../nexum-world" } alloy-primitives.workspace = true +# Typed EIP-155 chain id; already in the guest graph via alloy-provider. +alloy-chains.workspace = true # The `Log` type modules receive for chain-log events is alloy's own RPC log, # assembled from the WIT record at the binding edge (see `events`). alloy-rpc-types-eth.workspace = true alloy-sol-types.workspace = true +# The `store` helpers' value codec (`TypedCell`, `TypedMap`, `Counter`). +borsh.workspace = true +# The provider seam: `HostTransport` speaks alloy's JSON-RPC packet +# vocabulary over `ChainHost::request`, and `provider()` fronts it with +# a `RootProvider`. Featureless, so no ws/ipc/reqwest transport reaches +# the wasm guest. +alloy-json-rpc.workspace = true +alloy-provider.workspace = true +alloy-rpc-client.workspace = true +alloy-transport.workspace = true +tower.workspace = true # Standard HTTP request/response/method vocabulary; the SDK adds only # the wasi:http-specific `fetch` seam on top. wstd re-exports the same # `http` types, so a request passes through to the client unconverted. http.workspace = true -serde_json.workspace = true +# `raw_value` backs the transport's pass-through of host JSON into +# alloy's `Box` payload slots. +serde_json = { workspace = true, features = ["std", "raw_value"] } strum.workspace = true thiserror.workspace = true # `tracing-core` backs the guest facade's subscriber plumbing; the @@ -39,14 +62,15 @@ tracing-core.workspace = true [dev-dependencies] proptest.workspace = true -# Dev-dependencies are excluded from Cargo's dependency-cycle check, so -# the nexum-sdk -> shepherd-sdk -> shepherd-sdk-test dev-dep chain is a -# normal, supported arrangement: the keeper stores acceptance-test -# against the same composed MockHost the flagship modules use. -shepherd-sdk-test = { path = "../shepherd-sdk-test" } +# Dev-dependencies are excluded from Cargo's dependency-cycle check, so the +# nexum-sdk <- nexum-sdk-test dev-dep is a normal, supported arrangement: the +# keeper stores acceptance-test against the composed world-neutral MockHost. +# The keeper never touches the orderbook, so a CoW-layer mock would only drag +# the domain crates into this crate's dev graph. +nexum-sdk-test = { path = "../nexum-sdk-test" } # The wasi:http client only links on the wasm guest target; host-side -# consumers (tests, backtest tooling) compile the `http` module's types +# consumers (tests, native tooling) compile the `http` module's types # without it. [target.'cfg(all(target_arch = "wasm32", target_os = "wasi"))'.dependencies] wstd.workspace = true diff --git a/crates/nexum-sdk/src/address.rs b/nexum/crates/nexum-sdk/src/address.rs similarity index 55% rename from crates/nexum-sdk/src/address.rs rename to nexum/crates/nexum-sdk/src/address.rs index 2106e4a5..2e8e8e42 100644 --- a/crates/nexum-sdk/src/address.rs +++ b/nexum/crates/nexum-sdk/src/address.rs @@ -1,63 +1,38 @@ //! EVM address parsing helpers. //! -//! Multiple Shepherd modules need to read a `[config]` value such as -//! `addresses = "0xabc..., 0xdef..."` and surface a typed error when -//! one of the entries is malformed; the offline backtest harness -//! parses single `0x...` strings out of fixture JSON. Each module -//! previously rolled its own `AddressListParseError` / -//! `AddressParseError`. The shapes were near-identical; the audit -//! pass consolidates them here so future modules pick up the same -//! `Display` wording (operator-facing log strings stay stable) and -//! the same `#[non_exhaustive]` evolution guarantee. -//! -//! The list parser stays deliberately permissive about whitespace + -//! empty trailing segments to match the wording operators have grown -//! used to (a literal trailing comma in `engine.toml` should not -//! error). +//! Parses `[config]` values such as `addresses = "0xabc..., 0xdef..."` +//! and single `0x...` strings into typed [`Address`] values. The list +//! parser is permissive about whitespace and empty segments, so a +//! trailing comma is not an error. use alloy_primitives::Address; -/// Typed errors returned by [`parse_address_list`] and -/// [`parse_address`]. Replaces the `Result<_, String>` and -/// per-module `AddressListParseError` / `AddressParseError` shapes -/// that previously lived in each strategy crate (rubric prohibits -/// stringly-typed errors). -/// -/// The Display impls preserve the exact wording the previous -/// formatters produced so any operator-facing log strings remain -/// stable across the JC5 consolidation. +/// Typed errors from [`parse_address_list`] and [`parse_address`]. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum AddressParse { - /// One of the comma-separated entries failed to parse as an - /// EVM address, or a single-address input failed to parse. For - /// the single-address case the `index` is always `0`. + /// An entry failed to parse as an EVM address; `index` is `0` for a + /// single-address parse. #[error("address #{index} ({raw:?}): {message}")] InvalidAddress { - /// Zero-based position of the offending entry in the - /// comma-separated list (`0` for single-address parses). + /// Zero-based position in the list (counts skipped empties); + /// `0` for single-address parses. index: usize, /// The trimmed source string that failed to parse. raw: String, - /// Human-readable parse-error detail from - /// `
::Err`. + /// Parse-error detail. message: String, }, - /// The whole list was empty (or contained only whitespace + - /// empty segments). Only emitted by [`parse_address_list`]. + /// The list held no non-whitespace segment. Only from + /// [`parse_address_list`]. #[error("expected at least one address")] Empty, } -/// Parse a comma-separated address list, stripping whitespace and -/// skipping empty segments (so a trailing `,` is not an error). -/// -/// Returns [`AddressParse::Empty`] if the input contains no -/// non-whitespace segment and [`AddressParse::InvalidAddress`] on -/// the first entry that does not parse as an EVM address. The -/// `index` reflects the zero-based position in the original -/// comma-separated list (i.e. it counts skipped empties), which -/// matches the wording the per-module errors used to surface. +/// Parse a comma-separated address list, trimming whitespace and +/// skipping empty segments. [`AddressParse::Empty`] on no segment, +/// [`AddressParse::InvalidAddress`] on the first bad entry (`index` +/// counts skipped empties). pub fn parse_address_list(raw: &str) -> Result, AddressParse> { let mut out = Vec::new(); for (i, part) in raw.split(',').enumerate() { @@ -80,10 +55,9 @@ pub fn parse_address_list(raw: &str) -> Result, AddressParse> { Ok(out) } -/// Parse a single `0x...` (or bare-hex) address string into a -/// typed [`Address`]. Trims surrounding whitespace before -/// delegating to `
`; failures surface as -/// [`AddressParse::InvalidAddress`] with `index = 0`. +/// Parse a single `0x...` (or bare-hex) address string, trimming +/// whitespace. Failures surface as [`AddressParse::InvalidAddress`] +/// with `index = 0`. pub fn parse_address(raw: &str) -> Result { let trimmed = raw.trim(); trimmed diff --git a/crates/nexum-sdk/src/chain/chainlink.rs b/nexum/crates/nexum-sdk/src/chain/chainlink.rs similarity index 80% rename from crates/nexum-sdk/src/chain/chainlink.rs rename to nexum/crates/nexum-sdk/src/chain/chainlink.rs index f589f96b..9ee53a34 100644 --- a/crates/nexum-sdk/src/chain/chainlink.rs +++ b/nexum/crates/nexum-sdk/src/chain/chainlink.rs @@ -1,24 +1,15 @@ //! Chainlink Aggregator V3 reader. //! -//! [`read_latest_answer`] performs the full `eth_call → decode → -//! latestRoundData.answer` flow against a Chainlink AggregatorV3 -//! oracle. Returns `Some(answer)` on success or `None` on any host / -//! decode failure (logging the failure at Warn). Used by oracle-driven -//! example modules (price-alert, stop-loss) so they consume the SDK -//! instead of redefining the `AggregatorV3` ABI + read loop locally. -//! -//! The shape is deliberately `Option` rather than -//! `Result`: every observed caller treats a fetch -//! failure as "skip this block, try next one", and `Option` makes -//! that the only path without forcing a discard pattern at the call -//! site. +//! [`read_latest_answer`] runs `eth_call` against a Chainlink +//! AggregatorV3 oracle and decodes `latestRoundData.answer`, returning +//! `None` (logging at Warn) on any host or decode failure. use alloy_primitives::{Address, I256}; use alloy_sol_types::{SolCall, sol}; use crate::Level; use crate::chain::{eth_call_params, parse_eth_call_result}; -use crate::host::Host; +use crate::host::{ChainHost, LoggingHost}; sol! { /// Chainlink AggregatorV3Interface - only the function the @@ -35,18 +26,13 @@ sol! { } /// Fetch the oracle's latest answer via `eth_call(latestRoundData)`. -/// -/// Returns `Some(answer)` on success. Logs a Warn (prefixed with -/// `domain`) and returns `None` on any of: -/// -/// - `host.request("eth_call", …)` returning `Err(ChainError)`; -/// - the JSON-RPC result not parsing as `0x`-prefixed hex bytes; -/// - the ABI decode failing. -/// -/// `domain` is embedded in the log line so a single host log stream -/// can disambiguate which module's oracle failed. +/// `None`, with a Warn line prefixed by `domain`, on an `Err` +/// response, a result that is not `0x`-hex, or an ABI decode failure. +// Bounded on the two capabilities it exercises (chain + logging), not +// the full `Host` supertrait, so modules whose worlds omit local-store +// can still call it. #[must_use] -pub fn read_latest_answer( +pub fn read_latest_answer( host: &H, chain_id: u64, oracle: Address, @@ -88,10 +74,8 @@ pub fn read_latest_answer( #[cfg(test)] mod tests { - //! `MockHost`-driven coverage of the read path. Encodes a synthetic - //! `latestRoundData` return into the `"0x..."` JSON the - //! `chain::request` mock returns, then asserts the helper - //! extracts the `answer` field. + //! Coverage of the read path over a stub host returning a synthetic + //! `latestRoundData` result. use super::*; use crate::host::{ChainError, Fault}; diff --git a/crates/nexum-sdk/src/chain/eth_call.rs b/nexum/crates/nexum-sdk/src/chain/eth_call.rs similarity index 56% rename from crates/nexum-sdk/src/chain/eth_call.rs rename to nexum/crates/nexum-sdk/src/chain/eth_call.rs index 36dd41c1..51b7e1e8 100644 --- a/crates/nexum-sdk/src/chain/eth_call.rs +++ b/nexum/crates/nexum-sdk/src/chain/eth_call.rs @@ -2,28 +2,8 @@ use alloy_primitives::Address; -/// Build the JSON params array for `eth_call`: `[{to, data}, "latest"]`. -/// -/// Returned as a `String` rather than `serde_json::Value` so the caller -/// can hand it straight to `chain::request(chain_id, "eth_call", &p)` -/// without re-serialising. -/// -/// # Example -/// -/// ``` -/// use nexum_sdk::chain::eth_call_params; -/// use nexum_sdk::prelude::Address; -/// -/// let to: Address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" -/// .parse() -/// .unwrap(); -/// let selector = [0xaa, 0xbb, 0xcc, 0xdd]; // 4-byte function selector -/// let params = eth_call_params(&to, &selector); -/// -/// assert!(params.contains("\"to\":\"0xfdafc9d1902f4e0b84f65f49f244b32b31013b74\"")); -/// assert!(params.contains("\"data\":\"0xaabbccdd\"")); -/// assert!(params.contains("\"latest\"")); -/// ``` +/// Build the JSON params array for `eth_call`: `[{to, data}, "latest"]`, +/// ready to pass to `chain::request` without re-serialising. pub fn eth_call_params(to: &Address, data: &[u8]) -> String { // Both fields are hex, which never needs JSON escaping, so the // array is written directly instead of via a serde_json DOM. @@ -31,27 +11,8 @@ pub fn eth_call_params(to: &Address, data: &[u8]) -> String { format!(r#"[{{"to":"{to:#x}","data":"{data_hex}"}},"latest"]"#) } -/// Parse the raw JSON-RPC `result` field a host's `chain::request` -/// returns for an `eth_call`. The value is a JSON string holding hex -/// like `"0x1234..."`; strip the JSON quotes, strip the `0x` prefix, -/// and hex-decode. Returns `None` on shape mismatch. -/// -/// # Example -/// -/// ``` -/// use nexum_sdk::chain::parse_eth_call_result; -/// -/// // What the host typically returns for an eth_call result: a JSON -/// // string holding 0x-prefixed hex. -/// let raw = r#""0xdeadbeef""#; -/// assert_eq!( -/// parse_eth_call_result(raw), -/// Some(vec![0xde, 0xad, 0xbe, 0xef]), -/// ); -/// -/// // Shape mismatch (not JSON-quoted) -> None. -/// assert_eq!(parse_eth_call_result("not json"), None); -/// ``` +/// Decode the JSON-RPC `result` of an `eth_call`, a JSON string holding +/// `0x`-prefixed hex, to bytes. `None` on shape mismatch. #[must_use] pub fn parse_eth_call_result(result_json: &str) -> Option> { // Borrowed deserialization: valid hex payloads never contain JSON diff --git a/nexum/crates/nexum-sdk/src/chain/mod.rs b/nexum/crates/nexum-sdk/src/chain/mod.rs new file mode 100644 index 00000000..0534b8e7 --- /dev/null +++ b/nexum/crates/nexum-sdk/src/chain/mod.rs @@ -0,0 +1,21 @@ +//! Chain access for guest modules. +//! +//! Chain identity (alloy [`Chain`]), the closed JSON-RPC read +//! surface ([`ChainMethod`]), and the alloy provider seam: a +//! [`HostTransport`] over `ChainHost::request` fronted by +//! [`ProviderHost::provider`], driven with [`block_on`]. Plus the +//! `eth_call` JSON plumbing helpers for modules that keep their own +//! `chain::request` shim. + +pub mod chainlink; +pub mod eth_call; +pub mod provider; +pub mod transport; + +pub use alloy_chains::Chain; +pub use eth_call::{eth_call_params, parse_eth_call_result}; +/// The read surface is defined once in `nexum-world`; guest and host +/// re-export the same type, so the allowlist cannot drift. +pub use nexum_world::ChainMethod; +pub use provider::{ProviderHost, block_on}; +pub use transport::HostTransport; diff --git a/nexum/crates/nexum-sdk/src/chain/provider.rs b/nexum/crates/nexum-sdk/src/chain/provider.rs new file mode 100644 index 00000000..27bba090 --- /dev/null +++ b/nexum/crates/nexum-sdk/src/chain/provider.rs @@ -0,0 +1,111 @@ +//! `host.provider(chain)`: an alloy `Provider` over the chain host. + +use std::future::{Future, IntoFuture}; +use std::pin::pin; +use std::task::{Context, Poll, Waker}; + +use alloy_provider::RootProvider; +use alloy_rpc_client::RpcClient; + +use super::{Chain, HostTransport}; +use crate::host::ChainHost; + +/// Mints an alloy [`Provider`](alloy_provider::Provider) over +/// [`ChainHost::request`], so a module calls typed provider methods +/// instead of hand-building JSON-RPC. Blanket-implemented for every +/// cloneable [`ChainHost`]; drive the returned futures with +/// [`block_on`]. +pub trait ProviderHost: ChainHost + Clone + Send + Sync + Sized + 'static { + /// Provider for `chain`, routed through the host's RPC stack. + fn provider(&self, chain: Chain) -> RootProvider { + RootProvider::new(RpcClient::new( + HostTransport::new(self.clone(), chain), + false, + )) + } +} + +impl ProviderHost for H {} + +/// Drive a host-backed provider future to completion. The host +/// transport is a synchronous WIT import, so the future resolves on the +/// first poll; a `Pending` panics. +pub fn block_on(future: F) -> F::Output { + let mut future = pin!(future.into_future()); + let mut cx = Context::from_waker(Waker::noop()); + match future.as_mut().poll(&mut cx) { + Poll::Ready(output) => output, + Poll::Pending => panic!( + "chain provider future did not resolve synchronously: the host \ + transport is a synchronous WIT import, so an alloy layer that \ + awaits a reactor or timer was added; the chain SDK must move \ + to a host-driven async surface, not a poll loop" + ), + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Bytes, address}; + use alloy_provider::Provider; + use alloy_rpc_types_eth::TransactionRequest; + + use super::{ProviderHost, block_on}; + use crate::chain::Chain; + use crate::host::{ChainError, ChainHost}; + + #[derive(Clone)] + struct StubHost; + + impl ChainHost for StubHost { + fn request( + &self, + chain_id: u64, + method: &str, + _params: &str, + ) -> Result { + assert_eq!(chain_id, 100); + match method { + "eth_blockNumber" => Ok("\"0x2a\"".into()), + "eth_call" => Ok("\"0x1234\"".into()), + other => panic!("unexpected method {other}"), + } + } + } + + #[test] + fn provider_reads_typed_values_through_the_host() { + let provider = StubHost.provider(Chain::from_id(100)); + let block = block_on(provider.get_block_number()).expect("block number"); + assert_eq!(block, 42); + } + + #[test] + fn provider_call_decodes_bytes() { + let provider = StubHost.provider(Chain::from_id(100)); + let tx = TransactionRequest::default() + .to(address!("0x9008D19f58AAbD9eD0D60971565AA8510560ab41")); + let out = block_on(provider.call(tx)).expect("eth_call"); + assert_eq!(out, Bytes::from(vec![0x12, 0x34])); + } + + #[test] + fn signing_methods_error_before_the_host() { + let provider = StubHost.provider(Chain::from_id(100)); + let err = block_on(provider.raw_request::<_, String>("eth_sendRawTransaction".into(), ())) + .expect_err("write method is rejected"); + let payload = err.as_error_resp().expect("json-rpc error response"); + assert_eq!(payload.code, -32601); + } + + #[test] + fn block_on_drives_plain_futures() { + assert_eq!(block_on(async { 7 }), 7); + } + + #[test] + #[should_panic(expected = "did not resolve synchronously")] + fn block_on_panics_when_a_future_is_not_synchronously_ready() { + block_on(std::future::pending::<()>()); + } +} diff --git a/nexum/crates/nexum-sdk/src/chain/transport.rs b/nexum/crates/nexum-sdk/src/chain/transport.rs new file mode 100644 index 00000000..ee2ef28d --- /dev/null +++ b/nexum/crates/nexum-sdk/src/chain/transport.rs @@ -0,0 +1,251 @@ +//! [`HostTransport`]: the alloy transport over [`ChainHost::request`]. + +use std::future::ready; +use std::task::{Context, Poll}; + +use alloy_json_rpc::{ + ErrorPayload, RequestPacket, Response, ResponsePacket, ResponsePayload, SerializedRequest, +}; +use alloy_transport::{TransportError, TransportErrorKind, TransportFut}; +use serde_json::value::RawValue; +use tower::Service; + +use super::{Chain, ChainMethod}; +use crate::host::{ChainError, ChainHost}; + +/// An alloy `Transport` routing JSON-RPC through the host's chain +/// interface. Dispatch is synchronous, so every returned future is +/// ready on its first poll and [`block_on`](super::block_on) drives it. +/// +/// Methods outside the typed [`ChainMethod`] surface never reach the +/// host and fail as a JSON-RPC `-32601`. A structured node error comes +/// back as the error payload (code, message, revert bytes as `0x` hex); +/// a host [`Fault`](crate::host::Fault) surfaces as a custom transport +/// error carrying the typed fault. +#[derive(Clone, Copy, Debug)] +pub struct HostTransport { + host: H, + chain: Chain, +} + +impl HostTransport +where + H: ChainHost + Clone + Send + Sync + 'static, +{ + /// Transport dispatching on `chain` through `host`. + pub const fn new(host: H, chain: Chain) -> Self { + Self { host, chain } + } + + fn dispatch(&self, packet: RequestPacket) -> Result { + match packet { + RequestPacket::Single(req) => Ok(ResponsePacket::Single(self.dispatch_single(&req)?)), + RequestPacket::Batch(reqs) => reqs + .iter() + .map(|req| self.dispatch_single(req)) + .collect::, _>>() + .map(ResponsePacket::Batch), + } + } + + fn dispatch_single(&self, req: &SerializedRequest) -> Result { + let Ok(method) = ChainMethod::try_from(req.method()) else { + return Ok(failure( + req, + ErrorPayload { + code: -32601, + message: format!( + "method outside the permitted read surface: {}", + req.method() + ) + .into(), + data: None, + }, + )); + }; + let params = req.params().map_or("[]", RawValue::get); + match self + .host + .request(self.chain.into(), method.as_str(), params) + { + Ok(result) => { + let payload = RawValue::from_string(result) + .map_err(|e| TransportError::deser_err(e, "host chain response"))?; + Ok(Response { + id: req.id().clone(), + payload: ResponsePayload::Success(payload), + }) + } + Err(ChainError::Rpc(rpc)) => Ok(failure( + req, + ErrorPayload { + code: rpc.code.into(), + message: rpc.message.into(), + data: rpc.data.and_then(|bytes| { + serde_json::value::to_raw_value(&alloy_primitives::hex::encode_prefixed( + bytes, + )) + .ok() + }), + }, + )), + Err(ChainError::Fault(fault)) => Err(TransportErrorKind::custom(fault)), + } + } +} + +fn failure(req: &SerializedRequest, payload: ErrorPayload) -> Response { + Response { + id: req.id().clone(), + payload: ResponsePayload::Failure(payload), + } +} + +impl Service for HostTransport +where + H: ChainHost + Clone + Send + Sync + 'static, +{ + type Response = ResponsePacket; + type Error = TransportError; + type Future = TransportFut<'static>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, packet: RequestPacket) -> Self::Future { + let result = self.dispatch(packet); + Box::pin(ready(result)) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use alloy_json_rpc::{Id, Request, RequestPacket, ResponsePacket, ResponsePayload}; + use alloy_transport::TransportError; + use tower::Service; + + use super::HostTransport; + use crate::chain::{Chain, block_on}; + use crate::host::{ChainError, ChainHost, Fault, RpcError}; + + type StubFn = dyn Fn(u64, &str, &str) -> Result + Send + Sync; + + #[derive(Clone)] + struct Stub(Arc); + + impl Stub { + fn new( + f: impl Fn(u64, &str, &str) -> Result + Send + Sync + 'static, + ) -> Self { + Self(Arc::new(f)) + } + } + + impl ChainHost for Stub { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { + (self.0)(chain_id, method, params) + } + } + + fn single(method: &'static str) -> RequestPacket { + let req = Request::new(method, Id::Number(1), ()) + .serialize() + .expect("request serializes"); + RequestPacket::Single(req) + } + + fn call(transport: &mut HostTransport, packet: RequestPacket) -> super::Response { + let ResponsePacket::Single(resp) = + block_on(Service::call(transport, packet)).expect("transport dispatches") + else { + panic!("single request yields a single response"); + }; + resp + } + + #[test] + fn success_passes_host_json_through() { + let stub = Stub::new(|chain_id, method, params| { + assert_eq!(chain_id, 100); + assert_eq!(method, "eth_blockNumber"); + assert_eq!(params, "[]"); + Ok("\"0x2a\"".into()) + }); + let mut transport = HostTransport::new(stub, Chain::from_id(100)); + let resp = call(&mut transport, single("eth_blockNumber")); + let ResponsePayload::Success(payload) = resp.payload else { + panic!("expected success, got {resp:?}"); + }; + assert_eq!(payload.get(), "\"0x2a\""); + } + + #[test] + fn unlisted_method_never_reaches_the_host() { + let stub = Stub::new(|_, method, _| panic!("host must not see {method}")); + let mut transport = HostTransport::new(stub, Chain::mainnet()); + let resp = call(&mut transport, single("eth_sendRawTransaction")); + let ResponsePayload::Failure(err) = resp.payload else { + panic!("expected failure, got {resp:?}"); + }; + assert_eq!(err.code, -32601); + assert!(err.message.contains("eth_sendRawTransaction")); + } + + #[test] + fn rpc_error_surfaces_code_message_and_revert_hex() { + let stub = Stub::new(|_, _, _| { + Err(ChainError::Rpc(RpcError { + code: -32000, + message: "execution reverted".into(), + data: Some(vec![0x08, 0xc3, 0x79, 0xa0].into()), + })) + }); + let mut transport = HostTransport::new(stub, Chain::mainnet()); + let resp = call(&mut transport, single("eth_call")); + let ResponsePayload::Failure(err) = resp.payload else { + panic!("expected failure, got {resp:?}"); + }; + assert_eq!(err.code, -32000); + assert_eq!(err.message, "execution reverted"); + assert_eq!(err.data.expect("revert data").get(), "\"0x08c379a0\"",); + } + + #[test] + fn fault_becomes_a_typed_transport_error() { + let stub = Stub::new(|_, _, _| Err(ChainError::Fault(Fault::Timeout))); + let mut transport = HostTransport::new(stub, Chain::mainnet()); + let err = block_on(Service::call(&mut transport, single("eth_call"))) + .expect_err("fault propagates"); + let TransportError::Transport(kind) = err else { + panic!("expected transport kind, got {err:?}"); + }; + assert!(kind.to_string().contains("timeout")); + } + + #[test] + fn batches_dispatch_per_request() { + let stub = Stub::new(|_, method, _| match method { + "eth_blockNumber" => Ok("\"0x1\"".into()), + _ => Ok("\"0x64\"".into()), + }); + let mut transport = HostTransport::new(stub, Chain::mainnet()); + let reqs = vec![ + Request::new("eth_blockNumber", Id::Number(1), ()) + .serialize() + .expect("request serializes"), + Request::new("eth_chainId", Id::Number(2), ()) + .serialize() + .expect("request serializes"), + ]; + let ResponsePacket::Batch(resps) = + block_on(Service::call(&mut transport, RequestPacket::Batch(reqs))) + .expect("batch dispatches") + else { + panic!("batch request yields a batch response"); + }; + assert_eq!(resps.len(), 2); + } +} diff --git a/crates/nexum-sdk/src/config.rs b/nexum/crates/nexum-sdk/src/config.rs similarity index 78% rename from crates/nexum-sdk/src/config.rs rename to nexum/crates/nexum-sdk/src/config.rs index 4a278cfc..b1dba85e 100644 --- a/crates/nexum-sdk/src/config.rs +++ b/nexum/crates/nexum-sdk/src/config.rs @@ -1,25 +1,16 @@ -//! Helpers for parsing the `Vec<(String, String)>` config entries a -//! module's `on_event` receives. -//! -//! Each entry is a `(key, value)` pair the runtime read from the -//! module's `[config]` table. Modules need three operations -//! repeatedly: required-key lookup, optional-key lookup, and decimal -//! parsing for thresholds / amounts. Hoisting these here keeps the -//! example modules consuming the SDK rather than re-implementing the -//! same loops around it (each copy in price-alert + stop-loss had -//! started to drift in error wording). +//! Helpers over the `Vec<(String, String)>` `[config]` entries a +//! module's `on_event` receives: required and optional key lookup, and +//! fixed-point decimal parsing. use alloy_primitives::{I256, U256}; use thiserror::Error; -/// Why a config lookup or parse failed. -/// -/// Modules wrap this into a [`Fault::InvalidInput`] at the boundary. -/// The SDK type stays host-neutral so the same parser can be -/// unit-tested without `wasm32-wasip2`. +/// Why a config lookup or parse failed. Modules wrap it into a +/// [`Fault::InvalidInput`] at the boundary. /// /// [`Fault::InvalidInput`]: crate::host::Fault::InvalidInput #[derive(Debug, Error)] +#[non_exhaustive] pub enum ConfigError { /// The key was not present in the `entries` slice. #[error("missing key {key:?}")] @@ -45,10 +36,7 @@ pub enum ConfigError { }, } -/// Look up a required `(key, value)` entry in a config table. -/// -/// Returns `Err(MissingKey)` if the key is absent. The returned -/// `&str` borrows from `entries`. +/// Look up a required entry; `Err(MissingKey)` if absent. pub fn get_required<'a>( entries: &'a [(String, String)], key: &str, @@ -62,8 +50,7 @@ pub fn get_required<'a>( }) } -/// Look up an optional `(key, value)` entry. Returns `None` when -/// absent; never errors. +/// Look up an optional entry; `None` when absent. pub fn get_optional<'a>(entries: &'a [(String, String)], key: &str) -> Option<&'a str> { entries .iter() @@ -72,18 +59,10 @@ pub fn get_optional<'a>(entries: &'a [(String, String)], key: &str) -> Option<&' } /// Parse a signed fixed-point decimal string into an `I256` scaled by -/// `10**decimals`. -/// -/// - Short fractional parts are right-padded with zeros. -/// - Long fractional parts are truncated. -/// - A leading `-` is honoured. -/// - Empty input is rejected as a parse error. -/// - Non-digit characters (other than the leading sign and a single -/// `.`) are rejected. -/// -/// `key` is the config-table key the value came from; it is embedded -/// in the returned error so the caller can surface a useful message -/// without re-passing context. +/// `10**decimals`. Short fractions are right-padded, long fractions +/// truncated, a leading `-` honoured; empty input and non-digit +/// characters (beyond the sign and one `.`) are rejected. `key` is +/// embedded in the error. pub fn scale_decimal(value: &str, decimals: u32, key: &str) -> Result { let (sign, body) = if let Some(rest) = value.strip_prefix('-') { (-1i32, rest) diff --git a/crates/nexum-sdk/src/events.rs b/nexum/crates/nexum-sdk/src/events.rs similarity index 84% rename from crates/nexum-sdk/src/events.rs rename to nexum/crates/nexum-sdk/src/events.rs index fb9cd0bc..4342faa1 100644 --- a/crates/nexum-sdk/src/events.rs +++ b/nexum/crates/nexum-sdk/src/events.rs @@ -1,24 +1,19 @@ //! Chain-log delivery at the guest WIT edge. //! //! Modules receive on-chain logs as the native [`Log`] (alloy's -//! `eth_getLogs` shape), not an SDK-invented view. The host packs each log -//! into the WIT `chain-log` record; [`ChainLogParts`] borrows that record's -//! raw fields and `From` rebuilds the alloy value. The per-module bind macro -//! emits the `From` glue that routes through this, so a strategy -//! holds `&[Log]` and decodes `sol!` events against [`Log::inner`]. +//! `eth_getLogs` shape). The host packs each log into the WIT +//! `chain-log` record; [`ChainLogParts`] borrows its raw fields and +//! `From` rebuilds the alloy value. use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, LogData}; /// The alloy RPC log delivered to modules for chain-log events. pub use alloy_rpc_types_eth::Log; -/// Borrowed raw fields of a WIT `chain-log` record, assembled into an alloy -/// [`Log`] via `From`. -/// -/// Fixed-width byte fields are right-aligned into their EVM word (20 bytes for -/// the address, 32 for topics and hashes). The host is the sole runtime and -/// the frames it emits are well-formed by construction, so an out-of-width -/// field is a host bug that traps loudly rather than being silently reshaped. +/// Borrowed raw fields of a WIT `chain-log` record, assembled into an +/// alloy [`Log`] via `From`. Fixed-width byte fields are left-padded +/// into their EVM word (20 bytes for the address, 32 for topics and +/// hashes). #[derive(Default)] pub struct ChainLogParts<'a> { /// 20-byte contract address. diff --git a/nexum/crates/nexum-sdk/src/host.rs b/nexum/crates/nexum-sdk/src/host.rs new file mode 100644 index 00000000..2621d5ef --- /dev/null +++ b/nexum/crates/nexum-sdk/src/host.rs @@ -0,0 +1,530 @@ +//! Host traits, the seam between module logic and the wit-bindgen +//! shims a module generates per-cdylib. Each trait mirrors one nexum +//! host interface ([`ChainHost`], [`IdentityHost`], [`LocalStoreHost`], +//! [`RemoteStoreHost`], [`MessagingHost`], [`LoggingHost`]); [`Host`] +//! bundles all six. +//! +//! Module logic written against these traits runs host-free against +//! the `nexum-sdk-test` mocks. The traits are world-neutral over this +//! module's [`Fault`], mirroring the per-module `Fault` that +//! `wit_bindgen::generate!` emits, so modules wire a one-line converter +//! between the two. + +use alloy_primitives::{Address, B256, Bytes, Signature}; +use strum::IntoStaticStr; +use tracing_core::Level; + +/// Shared cross-domain failure vocabulary, mirrored from +/// `nexum:host/types.fault`. Typed per-interface errors embed it as a +/// case so a caller recovers the structured cause. `#[non_exhaustive]`: +/// the WIT can grow a case. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum Fault { + /// Capability declared but not provisioned by the operator. + #[error("unsupported: {0}")] + Unsupported(String), + /// Capability temporarily unavailable (RPC down, etc). + #[error("unavailable: {0}")] + Unavailable(String), + /// Capability declined the request (auth, allowlist, …). + #[error("denied: {0}")] + Denied(String), + /// Rate-limited by an upstream service; may carry backoff guidance + /// when the host knows the retry window. + #[error("rate limited{}", .0.retry_after_ms.map_or_else(String::new, |ms| format!(", retry after {ms} ms")))] + RateLimited(RateLimit), + /// Operation took too long. + #[error("timeout")] + Timeout, + /// Caller-supplied input did not parse / validate. + #[error("invalid input: {0}")] + InvalidInput(String), + /// Catch-all for host-side bugs. + #[error("internal: {0}")] + Internal(String), +} + +/// Backoff guidance carried by [`Fault::RateLimited`], mirrored from +/// `nexum:host/types.rate-limit`. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)] +pub struct RateLimit { + /// Host's suggested wait before retrying, in milliseconds, when known. + pub retry_after_ms: Option, +} + +/// Sealing markers for [`Host`] and [`HostFault`]: implement alongside +/// the trait. +#[doc(hidden)] +pub mod sealed { + pub trait SealedHost {} + pub trait SealedHostFault {} +} + +impl sealed::SealedHost for T where + T: ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost +{ +} + +impl sealed::SealedHostFault for Fault {} +impl sealed::SealedHostFault for ChainError {} + +/// Recovers the shared [`Fault`] and a stable snake_case label from a +/// richer per-interface error. Sealed. +pub trait HostFault: sealed::SealedHostFault { + /// The embedded fault, when this value represents one. + fn fault(&self) -> Option<&Fault>; + /// Stable snake_case label for logs and metrics. + fn label(&self) -> &'static str; +} + +impl HostFault for Fault { + fn fault(&self) -> Option<&Fault> { + Some(self) + } + + fn label(&self) -> &'static str { + self.into() + } +} + +/// A structured JSON-RPC error response, mirrored from +/// `nexum:host/chain.rpc-error`. `data` holds the host-decoded +/// `error.data` revert bytes, ready for a revert decoder. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[error("rpc error {code}: {message}")] +pub struct RpcError { + /// JSON-RPC error code from the node. + pub code: i32, + /// Human-readable detail. + pub message: String, + /// Decoded `error.data` bytes, when the node returned a hex payload. + pub data: Option, +} + +/// Failure of a `nexum:host/chain` call, mirrored from +/// `nexum:host/chain.chain-error`: a shared host [`Fault`] or a +/// structured JSON-RPC [`RpcError`]. [`HostFault`] recovers the +/// embedded [`Fault`], present only on the `Fault` case. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] +pub enum ChainError { + /// A shared host fault. + #[error(transparent)] + Fault(#[from] Fault), + /// A structured JSON-RPC error response. + #[error(transparent)] + Rpc(#[from] RpcError), +} + +impl HostFault for ChainError { + fn fault(&self) -> Option<&Fault> { + match self { + ChainError::Fault(f) => Some(f), + ChainError::Rpc(_) => None, + } + } + + fn label(&self) -> &'static str { + match self { + ChainError::Fault(f) => f.label(), + ChainError::Rpc(_) => "rpc", + } + } +} + +/// Fold a [`ChainError`] into the shared [`Fault`]: the `Fault` case +/// passes through; an [`RpcError`] becomes [`Fault::Internal`] carrying +/// the code, message, and any revert bytes as a `0x` hex suffix. +impl From for Fault { + fn from(err: ChainError) -> Self { + match err { + ChainError::Fault(fault) => fault, + ChainError::Rpc(rpc) => { + let mut message = format!("rpc error {}: {}", rpc.code, rpc.message); + if let Some(data) = rpc.data { + message.push_str(" ("); + message.push_str(&alloy_primitives::hex::encode_prefixed(data)); + message.push(')'); + } + Fault::Internal(message) + } + } + } +} + +/// `nexum:host/chain` - raw JSON-RPC dispatch. +pub trait ChainHost { + /// Execute a JSON-RPC request against the given chain. + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result; +} + +/// One write in a [`LocalStoreHost::apply`] batch, mirrored from +/// `nexum:host/local-store.write-op`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WriteOp { + /// Insert or overwrite `key` with `value`. + Set { + /// Key to write. + key: String, + /// Value bytes. + value: Vec, + }, + /// Delete `key`; a no-op if absent. + Delete { + /// Key to delete. + key: String, + }, +} + +/// `nexum:host/local-store` - per-module key-value persistence. +pub trait LocalStoreHost { + /// Fetch a value. `Ok(None)` when the key is absent. + fn get(&self, key: &str) -> Result>, Fault>; + /// Insert or overwrite. + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault>; + /// Delete. No-op if the key is absent. + fn delete(&self, key: &str) -> Result<(), Fault>; + /// Enumerate keys whose raw form starts with `prefix`. + fn list_keys(&self, prefix: &str) -> Result, Fault>; + /// Apply a batch of writes; later ops on a key supersede earlier + /// ones. Atomic (every op lands or none does) only on the real + /// host adapter, which overrides this with the host's `apply` + /// verb; the default is a per-op `set`/`delete` fallback for + /// arbitrary impls such as mocks, so a mid-batch failure leaves + /// the earlier ops applied. + fn apply(&self, ops: &[WriteOp]) -> Result<(), Fault> { + for op in ops { + match op { + WriteOp::Set { key, value } => self.set(key, value)?, + WriteOp::Delete { key } => self.delete(key)?, + } + } + Ok(()) + } + /// Whether `key` exists. + fn contains(&self, key: &str) -> Result { + Ok(self.get(key)?.is_some()) + } + /// Value byte length, `Ok(None)` when absent. + fn len(&self, key: &str) -> Result, Fault> { + Ok(self.get(key)?.map(|v| v.len() as u64)) + } + /// Number of keys starting with `prefix`. + fn count(&self, prefix: &str) -> Result { + Ok(self.list_keys(prefix)?.len() as u64) + } +} + +/// `nexum:host/logging` - structured runtime logs. +pub trait LoggingHost { + /// Emit a log line at the given [`Level`]. + fn log(&self, level: Level, message: &str); +} + +/// `nexum:host/identity` - host-held accounts and signing. +pub trait IdentityHost { + /// Accounts the host is willing to sign for. Empty means no + /// signing capability. + fn accounts(&self) -> Result, Fault>; + /// Sign `message` with `personal_sign` semantics (the host + /// prepends the `"\x19Ethereum Signed Message:\n"` prefix). + fn sign(&self, account: Address, message: &[u8]) -> Result; + /// Sign a JSON-encoded EIP-712 payload. + fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result; +} + +/// One delivered message, mirrored from `nexum:host/types.message` so +/// the [`MessagingHost`] seam stays mockable without naming bindgen +/// types. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Message { + /// Content topic the message arrived on. + pub content_topic: String, + /// Opaque payload bytes. + pub payload: Vec, + /// Delivery timestamp, ms since the Unix epoch, UTC. + pub timestamp: u64, + /// Optional sender identity (protocol-dependent). + pub sender: Option>, +} + +/// `nexum:host/messaging` - publish to and query content topics, +/// confined to the component's `messaging_topics` grant; an off-scope +/// topic fails as [`Fault::Denied`]. +pub trait MessagingHost { + /// Publish a payload to a content topic + /// (`////`). + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault>; + /// Query historical messages on a topic, window bounded by the + /// optional `start_time` / `end_time` (ms since the Unix epoch, + /// UTC) and `limit`. + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault>; +} + +/// `nexum:host/remote-store` - content-addressed blobs and mutable +/// feeds on the decentralized store. +pub trait RemoteStoreHost { + /// Upload raw data; returns its 32-byte content reference. + fn upload(&self, data: &[u8]) -> Result; + /// Download the data behind a content reference. + fn download(&self, reference: B256) -> Result, Fault>; + /// Latest value of the `(owner, topic)` mutable feed, when set. + fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault>; + /// Update the host-owned feed at `topic` (the host signs with its + /// configured identity); returns the new chunk's reference. + fn write_feed(&self, topic: B256, data: &[u8]) -> Result; +} + +/// Lift a host-returned account into an [`Address`]; any length but 20 +/// folds to [`Fault::Internal`]. +pub fn account_from_wire(raw: &[u8]) -> Result { + Address::try_from(raw).map_err(|_| { + Fault::Internal(format!( + "identity returned a {}-byte account, expected 20", + raw.len() + )) + }) +} + +/// Lift a host-returned 65-byte `r || s || v` signature into a +/// [`Signature`]; a malformed buffer folds to [`Fault::Internal`]. +pub fn signature_from_wire(raw: &[u8]) -> Result { + Signature::from_raw(raw) + .map_err(|e| Fault::Internal(format!("identity returned a malformed signature: {e}"))) +} + +/// Lift a host-returned content reference into a [`B256`]; any length +/// but 32 folds to [`Fault::Internal`]. +pub fn reference_from_wire(raw: &[u8]) -> Result { + B256::try_from(raw).map_err(|_| { + Fault::Internal(format!( + "remote-store returned a {}-byte reference, expected 32", + raw.len() + )) + }) +} + +/// Supertrait bundling all six core host interfaces. Module functions +/// take `` (or bound exactly the interfaces they exercise) and +/// run against `nexum_sdk_test::MockHost` in tests. Blanket-implemented +/// for any type carrying all six; sealed, so that impl is the only one. +pub trait Host: + sealed::SealedHost + + ChainHost + + IdentityHost + + LocalStoreHost + + RemoteStoreHost + + MessagingHost + + LoggingHost +{ +} +impl Host for T where + T: ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost +{ +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, B256, U256}; + + use super::{ + ChainError, Fault, HostFault, RateLimit, RpcError, account_from_wire, reference_from_wire, + signature_from_wire, + }; + + #[test] + fn local_store_metadata_defaults_derive_from_required_methods() { + use super::LocalStoreHost; + + /// Two fixed rows; only the four required methods are written. + struct TwoRows; + impl LocalStoreHost for TwoRows { + fn get(&self, key: &str) -> Result>, Fault> { + Ok(match key { + "a" => Some(b"abc".to_vec()), + "b" => Some(Vec::new()), + _ => None, + }) + } + fn set(&self, _: &str, _: &[u8]) -> Result<(), Fault> { + Ok(()) + } + fn delete(&self, _: &str) -> Result<(), Fault> { + Ok(()) + } + fn list_keys(&self, prefix: &str) -> Result, Fault> { + Ok(["a", "b"] + .iter() + .filter(|k| k.starts_with(prefix)) + .map(|k| (*k).to_owned()) + .collect()) + } + } + + assert!(TwoRows.contains("a").unwrap()); + assert!(!TwoRows.contains("missing").unwrap()); + assert_eq!(TwoRows.len("a").unwrap(), Some(3)); + assert_eq!(TwoRows.len("b").unwrap(), Some(0)); + assert_eq!(TwoRows.len("missing").unwrap(), None); + assert_eq!(TwoRows.count("").unwrap(), 2); + assert_eq!(TwoRows.count("a").unwrap(), 1); + assert_eq!(TwoRows.count("z").unwrap(), 0); + } + + #[test] + fn local_store_default_apply_falls_back_to_one_call_per_op() { + use std::cell::RefCell; + + use super::{LocalStoreHost, WriteOp}; + + /// Records each call; only the four required methods are written. + #[derive(Default)] + struct Recorder(RefCell>); + impl LocalStoreHost for Recorder { + fn get(&self, _: &str) -> Result>, Fault> { + Ok(None) + } + fn set(&self, key: &str, _: &[u8]) -> Result<(), Fault> { + self.0.borrow_mut().push(format!("set {key}")); + Ok(()) + } + fn delete(&self, key: &str) -> Result<(), Fault> { + self.0.borrow_mut().push(format!("delete {key}")); + Ok(()) + } + fn list_keys(&self, _: &str) -> Result, Fault> { + Ok(Vec::new()) + } + } + + let recorder = Recorder::default(); + recorder + .apply(&[ + WriteOp::Set { + key: "a".into(), + value: b"1".to_vec(), + }, + WriteOp::Delete { key: "b".into() }, + WriteOp::Set { + key: "c".into(), + value: b"2".to_vec(), + }, + ]) + .unwrap(); + assert_eq!( + recorder.0.into_inner(), + ["set a", "delete b", "set c"].map(str::to_owned) + ); + } + + #[test] + fn wire_lifts_accept_exact_lengths() { + let account = account_from_wire(&[0x11; 20]).unwrap(); + assert_eq!(account, Address::from([0x11; 20])); + + let reference = reference_from_wire(&[0x22; 32]).unwrap(); + assert_eq!(reference, B256::from([0x22; 32])); + + let raw = alloy_primitives::Signature::new(U256::from(1), U256::from(2), true).as_bytes(); + let signature = signature_from_wire(&raw).unwrap(); + assert_eq!(signature.r(), U256::from(1)); + assert_eq!(signature.s(), U256::from(2)); + assert!(signature.v()); + } + + #[test] + fn wire_lifts_fold_malformed_buffers_to_internal() { + for fault in [ + account_from_wire(&[0u8; 19]).unwrap_err(), + signature_from_wire(&[0u8; 64]).unwrap_err(), + reference_from_wire(&[0u8; 31]).unwrap_err(), + ] { + assert!(matches!(fault, Fault::Internal(_)), "got {fault:?}"); + } + } + + #[test] + fn fault_labels_match_the_single_source_vocabulary() { + use nexum_world::FaultLabel as Label; + let cases: [(Fault, &str); 7] = [ + (Fault::Unsupported(String::new()), Label::Unsupported.into()), + (Fault::Unavailable(String::new()), Label::Unavailable.into()), + (Fault::Denied(String::new()), Label::Denied.into()), + ( + Fault::RateLimited(RateLimit::default()), + Label::RateLimited.into(), + ), + (Fault::Timeout, Label::Timeout.into()), + ( + Fault::InvalidInput(String::new()), + Label::InvalidInput.into(), + ), + (Fault::Internal(String::new()), Label::Internal.into()), + ]; + for (fault, label) in cases { + assert_eq!(fault.label(), label); + assert_eq!(fault.fault(), Some(&fault)); + } + } + + #[test] + fn rate_limit_display_carries_the_retry_hint() { + let hinted = Fault::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); + assert_eq!(hinted.to_string(), "rate limited, retry after 250 ms"); + assert_eq!( + Fault::RateLimited(RateLimit::default()).to_string(), + "rate limited" + ); + } + + #[test] + fn host_fault_is_object_safe() { + let boxed: Box = Box::new(Fault::Timeout); + assert_eq!(boxed.label(), "timeout"); + } + + #[test] + fn chain_error_recovers_embedded_fault() { + let fault = ChainError::Fault(Fault::Timeout); + assert_eq!(fault.fault(), Some(&Fault::Timeout)); + assert_eq!(fault.label(), "timeout"); + + let rpc = ChainError::Rpc(RpcError { + code: -32000, + message: "execution reverted".into(), + data: Some(vec![0xde, 0xad].into()), + }); + assert_eq!(rpc.fault(), None); + assert_eq!(rpc.label(), "rpc"); + } + + #[test] + fn chain_error_rpc_folds_to_internal_fault_with_hex_data() { + let fault = Fault::from(ChainError::Rpc(RpcError { + code: -32000, + message: "execution reverted".into(), + data: Some(vec![0x08, 0xc3, 0x79, 0xa0].into()), + })); + let Fault::Internal(message) = fault else { + panic!("rpc folds to internal, got {fault:?}"); + }; + assert!(message.contains("-32000")); + assert!(message.contains("0x08c379a0")); + } + + #[test] + fn chain_error_fault_folds_through_unchanged() { + let fault = Fault::from(ChainError::Fault(Fault::Unavailable("rpc down".into()))); + assert_eq!(fault, Fault::Unavailable("rpc down".into())); + } +} diff --git a/crates/nexum-sdk/src/http.rs b/nexum/crates/nexum-sdk/src/http.rs similarity index 71% rename from crates/nexum-sdk/src/http.rs rename to nexum/crates/nexum-sdk/src/http.rs index 7ca0c551..7aebce85 100644 --- a/crates/nexum-sdk/src/http.rs +++ b/nexum/crates/nexum-sdk/src/http.rs @@ -1,40 +1,29 @@ //! Outbound HTTP over wasi:http for guest modules. //! //! `fetch` performs one synchronous request through the host's -//! wasi:http outgoing handler. The host admits or denies every request -//! against the module's `[capabilities.http].allow` list before any -//! connection is made; a denial surfaces as [`FetchError::Denied`], so -//! modules can tell policy refusals from transport failures. -//! -//! Requests and responses are the standard [`http`] crate's -//! `Request>` and `Response>`; the SDK owns only what -//! wasi:http adds on top: the allowlist-aware [`FetchError`], the -//! per-phase [`FetchOptions`] timeouts, and the [`Fetch`] seam. -//! -//! [`Fetch`], [`FetchError`], and [`FetchOptions`] compile on every -//! target so strategy logic can be unit-tested host-side against the -//! seam; the `fetch` implementation itself only exists on -//! `wasm32-wasip2`. +//! wasi:http outgoing handler. The host admits or denies each request +//! against `[capabilities.http].allow` before connecting; a denial +//! surfaces as [`FetchError::Denied`], distinct from a transport +//! failure. Requests and responses are the [`http`] crate's +//! `Request>` / `Response>`. The [`Fetch`] seam, +//! [`FetchError`], and [`FetchOptions`] compile on every target for +//! host-side tests; `fetch` itself exists only on `wasm32-wasip2`. use core::time::Duration; use strum::IntoStaticStr; -/// Per-phase timeout applied to each of connect, first byte, and -/// between bytes by [`FetchOptions::default`]. Keeps an event handler -/// from hanging on a stalled upstream. +/// Per-phase timeout [`FetchOptions::default`] applies to connect, +/// first byte, and between bytes. pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); /// Per-phase wasi:http timeouts that have no home on [`http::Request`]. -/// -/// `Default` applies [`DEFAULT_TIMEOUT`] to every phase; plain -/// [`Fetch::fetch`] uses it, [`Fetch::fetch_with`] takes an override. +/// `Default` applies [`DEFAULT_TIMEOUT`] to every phase. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct FetchOptions { /// Time allowed to establish the connection. pub connect_timeout: Duration, - /// Time allowed for the first response byte after the request is - /// sent. + /// Time allowed for the first response byte. pub first_byte_timeout: Duration, /// Time allowed between consecutive response body bytes. pub between_bytes_timeout: Duration, @@ -51,14 +40,12 @@ impl Default for FetchOptions { } /// Why a fetch failed, folded down from the wasi:http error codes. -/// -/// `IntoStaticStr` yields a snake_case label per variant for log and -/// metric fields. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum FetchError { - /// The host's `[capabilities.http].allow` list refused the request - /// before any connection was made. + /// The `[capabilities.http].allow` list refused the request before + /// any connection was made. #[error("denied by the module's http allowlist")] Denied, /// The request never left the guest: malformed URL, method, or @@ -68,18 +55,16 @@ pub enum FetchError { /// A configured timeout elapsed. #[error("timeout: {0}")] Timeout(String), - /// Connection or protocol failure after the allowlist admitted the - /// request. + /// Connection or protocol failure after the request was admitted. #[error("transport failure: {0}")] Transport(String), } -/// Seam between strategy logic and the wasi:http transport: -/// strategies take `&impl Fetch` and tests slot in a stub; module glue -/// passes [`WasiFetch`]. +/// Seam between module logic and the wasi:http transport; module glue +/// passes [`WasiFetch`], tests a stub. pub trait Fetch { - /// Perform one request with `options`, blocking until the response - /// body is fully buffered. + /// Perform one request, blocking until the response body is fully + /// buffered. fn fetch_with( &self, request: http::Request>, @@ -95,11 +80,20 @@ pub trait Fetch { } } -/// [`Fetch`] adapter over the host's wasi:http outgoing handler. -/// -/// Guest-only glue: the type exists on every target so module -/// `lib.rs` glue compiles host-side for unit tests, but calling -/// [`Fetch::fetch_with`] off the wasm guest is unimplemented. +/// A shared reference forwards to its referent. +impl Fetch for &F { + fn fetch_with( + &self, + request: http::Request>, + options: FetchOptions, + ) -> Result>, FetchError> { + (**self).fetch_with(request, options) + } +} + +/// [`Fetch`] adapter over the host's wasi:http outgoing handler. Exists +/// on every target for host-side tests, but [`Fetch::fetch_with`] is +/// unimplemented off the wasm guest. #[derive(Clone, Copy, Debug, Default)] pub struct WasiFetch; @@ -134,10 +128,9 @@ mod wasi_impl { fetch_with(request, FetchOptions::default()) } - /// Perform `request` through the host's wasi:http outgoing - /// handler, blocking the (single-threaded) guest until the - /// response body is fully buffered. The buffered body is bounded - /// only by the module's memory limit. + /// Perform `request` through the host's wasi:http outgoing handler, + /// blocking until the response body is fully buffered. The body is + /// bounded only by the module's memory limit. pub fn fetch_with( request: http::Request>, options: FetchOptions, @@ -160,9 +153,9 @@ mod wasi_impl { Ok(http::Response::from_parts(parts, bytes.to_vec())) } - /// Fold the wasi:http error code carried inside the client error - /// into [`FetchError`]. Codes that do not identify a policy, - /// timeout, or request-shape failure are transport failures. + /// Fold the client error's wasi:http error code into [`FetchError`]; + /// anything not a policy, timeout, or request-shape failure is + /// transport. fn map_error(error: wstd::http::Error) -> FetchError { let Some(code) = error.downcast_ref::() else { return FetchError::Transport(format!("{error:#}")); @@ -207,9 +200,7 @@ mod tests { ); } - /// The default [`Fetch::fetch`] must delegate to `fetch_with` with - /// default options, so a stub can observe both the request and the - /// options it was handed. + /// [`Fetch::fetch`] delegates to `fetch_with` with default options. #[test] fn fetch_delegates_to_fetch_with_default_options() { use core::cell::Cell; diff --git a/nexum/crates/nexum-sdk/src/keeper.rs b/nexum/crates/nexum-sdk/src/keeper.rs new file mode 100644 index 00000000..9135489b --- /dev/null +++ b/nexum/crates/nexum-sdk/src/keeper.rs @@ -0,0 +1,596 @@ +//! Keeper stores: persistent-state conventions shared by +//! conditional-commitment modules, expressed over [`LocalStoreHost`] so +//! they compile for any world and test against the in-memory mocks. +//! +//! - [`WatchSet`] - watch-set registry, one `watch:{owner}:{hash}` row +//! per conditional commitment. +//! - [`Gates`] - `next_block:` / `next_epoch:` gate keys holding a u64 +//! little-endian threshold, with an [`is_ready`](Gates::is_ready) +//! predicate. +//! - [`Journal`] - receipt-keyed idempotency journal, with the +//! [`guard`](Journal::guard) reserve-effect-commit combinator. +//! - [`Poller`] - world-neutral poll seam: one watch in, one outcome +//! out at a [`Tick`]. +//! - [`Retrier`] - runs a [`RetryAction`] through the stores after a +//! failed run. +//! +//! [`WatchRef`] derives gate and marker keys from the verbatim hex +//! substrings of the stored watch key; [`WatchSet::remove`] drops a +//! watch with all its derived keys so no failure path orphans one. +//! +//! ``` +//! use nexum_sdk::keeper::{Gates, Journal, WatchRef, WatchSet}; +//! use nexum_sdk::host::{Fault, LocalStoreHost}; +//! use nexum_sdk::prelude::*; +//! +//! # use std::cell::RefCell; +//! # use std::collections::BTreeMap; +//! # #[derive(Default)] +//! # struct StubStore(RefCell>>); +//! # impl LocalStoreHost for StubStore { +//! # fn get(&self, key: &str) -> Result>, Fault> { +//! # Ok(self.0.borrow().get(key).cloned()) +//! # } +//! # fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { +//! # self.0.borrow_mut().insert(key.into(), value.into()); +//! # Ok(()) +//! # } +//! # fn delete(&self, key: &str) -> Result<(), Fault> { +//! # self.0.borrow_mut().remove(key); +//! # Ok(()) +//! # } +//! # fn list_keys(&self, prefix: &str) -> Result, Fault> { +//! # Ok(self +//! # .0 +//! # .borrow() +//! # .keys() +//! # .filter(|k| k.starts_with(prefix)) +//! # .cloned() +//! # .collect()) +//! # } +//! # } +//! let host = StubStore::default(); +//! let watches = WatchSet::new(&host); +//! let key = watches.put(&Address::ZERO, &B256::ZERO, b"params")?; +//! let watch = WatchRef::parse(&key).expect("well-formed key"); +//! +//! let gates = Gates::new(&host); +//! gates.set_next_block(watch, 100)?; +//! assert!(!gates.is_ready(watch, 99, 0)?); +//! assert!(gates.is_ready(watch, 100, 0)?); +//! +//! let journal = Journal::submitted(&host); +//! journal.record("0xuid")?; +//! assert!(journal.contains("0xuid")?); +//! +//! watches.remove(watch)?; +//! assert!(watches.list()?.is_empty()); +//! # Ok::<(), Fault>(()) +//! ``` + +use std::future::Future; + +use alloy_primitives::{Address, B256}; +use strum::IntoStaticStr; + +use crate::host::{Fault, LocalStoreHost}; + +/// Prefix of every watch-set row. +pub const WATCH_PREFIX: &str = "watch:"; +/// Prefix of the block-height gate row paired with a watch. +pub const NEXT_BLOCK_PREFIX: &str = "next_block:"; +/// Prefix of the Unix-seconds gate row paired with a watch. +pub const NEXT_EPOCH_PREFIX: &str = "next_epoch:"; +/// Journal prefix for receipts the module submitted upstream itself. +pub const SUBMITTED_PREFIX: &str = "submitted:"; +/// Journal prefix for receipts the module observed upstream but did not +/// submit. +pub const OBSERVED_PREFIX: &str = "observed:"; +/// First-refusal marker paired with a watch: block of the first +/// [`RetryAction::DropOnRepeat`] refusal, u64 little-endian. +pub const REFUSED_PREFIX: &str = "refused:"; + +/// Canonical watch key for an owner / commitment-hash pair; lowercase +/// `0x`-prefixed hex on both halves. +#[must_use] +pub fn watch_key(owner: &Address, hash: &B256) -> String { + format!("{WATCH_PREFIX}{owner:#x}:{hash:#x}") +} + +/// Borrowed view of a watch key's two hex halves. Derived keys reuse +/// the verbatim substrings, so parse-then-derive is byte-stable +/// regardless of the original hex casing. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WatchRef<'k> { + owner_hex: &'k str, + hash_hex: &'k str, +} + +impl<'k> WatchRef<'k> { + /// Parse a `watch:{owner}:{hash}` key. `None` when the prefix or + /// colon is missing, or either half is empty. + pub fn parse(key: &'k str) -> Option { + let rest = key.strip_prefix(WATCH_PREFIX)?; + let (owner_hex, hash_hex) = rest.split_once(':')?; + if owner_hex.is_empty() || hash_hex.is_empty() { + return None; + } + Some(Self { + owner_hex, + hash_hex, + }) + } + + /// The owner half, verbatim from the key. + pub fn owner_hex(&self) -> &'k str { + self.owner_hex + } + + /// The commitment-hash half, verbatim from the key. + pub fn hash_hex(&self) -> &'k str { + self.hash_hex + } + + /// Rebuild the full watch key. + pub fn key(&self) -> String { + format!("{WATCH_PREFIX}{}:{}", self.owner_hex, self.hash_hex) + } + + /// The `next_block:` gate key paired with this watch. + pub fn next_block_key(&self) -> String { + format!("{NEXT_BLOCK_PREFIX}{}:{}", self.owner_hex, self.hash_hex) + } + + /// The `next_epoch:` gate key paired with this watch. + pub fn next_epoch_key(&self) -> String { + format!("{NEXT_EPOCH_PREFIX}{}:{}", self.owner_hex, self.hash_hex) + } + + /// The `refused:` first-refusal marker key paired with this watch. + pub fn refused_key(&self) -> String { + format!("{REFUSED_PREFIX}{}:{}", self.owner_hex, self.hash_hex) + } +} + +/// Watch-set registry: one row per conditional commitment, keyed +/// `watch:{owner}:{hash}` with the encoded commitment parameters as +/// the value. +pub struct WatchSet<'h, H> { + host: &'h H, +} + +impl<'h, H: LocalStoreHost> WatchSet<'h, H> { + /// Registry view over the given host. + pub fn new(host: &'h H) -> Self { + Self { host } + } + + /// Canonical key for an owner / commitment-hash pair; delegates to + /// [`watch_key`]. + pub fn key(owner: &Address, hash: &B256) -> String { + watch_key(owner, hash) + } + + /// Insert or overwrite the watch row; returns the key written. + pub fn put(&self, owner: &Address, hash: &B256, value: &[u8]) -> Result { + let key = Self::key(owner, hash); + self.host.set(&key, value)?; + Ok(key) + } + + /// The stored value. `Ok(None)` when the watch is absent. + pub fn get(&self, watch: WatchRef<'_>) -> Result>, Fault> { + self.host.get(&watch.key()) + } + + /// Every watch key currently registered. + pub fn list(&self) -> Result, Fault> { + self.host.list_keys(WATCH_PREFIX) + } + + /// Drop the watch with its gate and refusal keys. Derived keys go + /// first, so a fault leaves the watch row for a retry and never + /// orphans a derived key. + pub fn remove(&self, watch: WatchRef<'_>) -> Result<(), Fault> { + Gates::new(self.host).clear(watch)?; + self.host.delete(&watch.refused_key())?; + self.host.delete(&watch.key()) + } +} + +/// `next_block:` / `next_epoch:` gate rows holding a u64 little-endian +/// threshold. A malformed or absent row reads as no gate (fail-open: a +/// corrupt value can only make a watch poll sooner, never wedge it). +pub struct Gates<'h, H> { + host: &'h H, +} + +impl<'h, H: LocalStoreHost> Gates<'h, H> { + /// Gate view over the given host. + pub fn new(host: &'h H) -> Self { + Self { host } + } + + /// Skip polls until the chain reaches `block`. + pub fn set_next_block(&self, watch: WatchRef<'_>, block: u64) -> Result<(), Fault> { + self.host.set(&watch.next_block_key(), &block.to_le_bytes()) + } + + /// Skip polls until the Unix-seconds clock reaches `epoch_s`. + pub fn set_next_epoch(&self, watch: WatchRef<'_>, epoch_s: u64) -> Result<(), Fault> { + self.host + .set(&watch.next_epoch_key(), &epoch_s.to_le_bytes()) + } + + /// Whether the watch is clear to poll. Both gates must pass; each + /// is inclusive at its threshold. + #[must_use = "the readiness verdict gates the poll; `?` alone drops the inner bool"] + pub fn is_ready(&self, watch: WatchRef<'_>, block: u64, epoch_s: u64) -> Result { + if let Some(next) = read_u64(self.host, &watch.next_block_key())? + && block < next + { + return Ok(false); + } + if let Some(next) = read_u64(self.host, &watch.next_epoch_key())? + && epoch_s < next + { + return Ok(false); + } + Ok(true) + } + + /// Delete both gate keys. No-op for gates never set. + pub fn clear(&self, watch: WatchRef<'_>) -> Result<(), Fault> { + self.host.delete(&watch.next_block_key())?; + self.host.delete(&watch.next_epoch_key()) + } +} + +/// Read a little-endian u64 row. Absent: `None`. Wrong length: warn and +/// fall open to `None`. +fn read_u64(host: &H, key: &str) -> Result, Fault> { + let Some(b) = host.get(key)? else { + return Ok(None); + }; + match <[u8; 8]>::try_from(b.as_slice()) { + Ok(bytes) => Ok(Some(u64::from_le_bytes(bytes))), + Err(_) => { + tracing::warn!(%key, len = b.len(), "stored value corrupt; treating as absent"); + Ok(None) + } + } +} + +/// RESERVED value tag: the marker owes a durable effect, its body and +/// `next_eligible` follow. +const RESERVED_TAG: u8 = 0x01; +/// COMMITTED value tag: the durable effect landed; nothing is owed. +const COMMITTED_TAG: u8 = 0x02; + +/// Presence class of a durable-effect marker. The leading tag byte +/// separates the two; a corrupt tag falls to [`Reserved`](Mark::Reserved) +/// so a reconcile resubmits rather than skips. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Mark { + /// A submit is in flight or owed; the effect is not yet durable. + Reserved, + /// The upstream effect is durable; no resubmit is owed. + Committed, +} + +/// A live reservation enumerated from the journal. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Reservation { + /// Receipt key as passed to [`Journal::reserve`], prefix stripped. + pub key: String, + /// Earliest Unix-seconds a retry is eligible; `0` when unset. + pub next_eligible: u64, + /// Opaque reservation body stored alongside the marker. + pub body: Vec, +} + +/// Encode a RESERVED value: `[0x01] ++ be64(next_eligible) ++ body`. +fn reserved_value(next_eligible: u64, body: &[u8]) -> Vec { + let mut v = Vec::with_capacity(9 + body.len()); + v.push(RESERVED_TAG); + v.extend_from_slice(&next_eligible.to_be_bytes()); + v.extend_from_slice(body); + v +} + +/// Receipt-keyed idempotency journal under a fixed prefix. The presence +/// path ([`record`](Self::record) / [`contains`](Self::contains)) stores +/// an empty marker; the durable-effect path ([`reserve`](Self::reserve) / +/// [`commit`](Self::commit)) tags the value so [`mark`](Self::mark) tells +/// RESERVED from COMMITTED. +pub struct Journal<'h, H> { + host: &'h H, + prefix: &'static str, +} + +impl<'h, H: LocalStoreHost> Journal<'h, H> { + /// Journal of receipts this module has submitted upstream + /// (`submitted:` markers). + pub fn submitted(host: &'h H) -> Self { + Self { + host, + prefix: SUBMITTED_PREFIX, + } + } + + /// Journal of receipts this module has observed upstream + /// (`observed:` markers). + pub fn observed(host: &'h H) -> Self { + Self { + host, + prefix: OBSERVED_PREFIX, + } + } + + /// Record the receipt. + pub fn record(&self, receipt: &str) -> Result<(), Fault> { + self.host.set(&format!("{}{receipt}", self.prefix), b"") + } + + /// Whether the receipt is journalled. Presence-only: MUST NOT guard + /// a reserve/commit journal (use [`mark`](Self::mark)), else a + /// corrupt marker is guard-skipped yet never reconciled. + pub fn contains(&self, receipt: &str) -> Result { + Ok(self + .host + .get(&format!("{}{receipt}", self.prefix))? + .is_some()) + } + + /// Reserve `key`: write a RESERVED marker with `next_eligible` 0. + pub fn reserve(&self, key: &str, body: &[u8]) -> Result<(), Fault> { + self.host + .set(&format!("{}{key}", self.prefix), &reserved_value(0, body)) + } + + /// Re-park a reservation: rewrite its RESERVED marker with + /// `next_eligible = until`, body unchanged. + pub fn park(&self, key: &str, body: &[u8], until: u64) -> Result<(), Fault> { + self.host.set( + &format!("{}{key}", self.prefix), + &reserved_value(until, body), + ) + } + + /// Commit `key`: overwrite with the COMMITTED marker. Idempotent. + pub fn commit(&self, key: &str) -> Result<(), Fault> { + self.host + .set(&format!("{}{key}", self.prefix), &[COMMITTED_TAG]) + } + + /// Release `key`: delete the marker. No-op when absent. + pub fn release(&self, key: &str) -> Result<(), Fault> { + self.host.delete(&format!("{}{key}", self.prefix)) + } + + /// Classify `key`'s marker. Absent: `None`. Legacy empty or leading + /// `0x02`: [`Committed`](Mark::Committed). Any other first byte: + /// [`Reserved`](Mark::Reserved), so a corrupt tag reconciles. + pub fn mark(&self, key: &str) -> Result, Fault> { + let Some(v) = self.host.get(&format!("{}{key}", self.prefix))? else { + return Ok(None); + }; + Ok(Some(match v.first() { + None | Some(&COMMITTED_TAG) => Mark::Committed, + Some(_) => Mark::Reserved, + })) + } + + /// Enumerate every live reservation (non-empty, non-COMMITTED + /// value). Fail-safe and agrees with [`mark`](Self::mark): a `0x01` + /// marker yields its `next_eligible` and body, any other + /// non-committed value yields `0` and the post-tag bytes. + pub fn pending(&self) -> Result, Fault> { + let mut out = Vec::new(); + for full in self.host.list_keys(self.prefix)? { + let Some(v) = self.host.get(&full)? else { + continue; + }; + if v.first().is_none_or(|&b| b == COMMITTED_TAG) { + continue; + } + let key = full.strip_prefix(self.prefix).unwrap_or(&full).to_owned(); + let (next_eligible, body) = if v[0] == RESERVED_TAG && v.len() >= 9 { + let mut be = [0u8; 8]; + be.copy_from_slice(&v[1..9]); + (u64::from_be_bytes(be), v[9..].to_vec()) + } else { + (0, v.get(1..).unwrap_or(&[]).to_vec()) + }; + out.push(Reservation { + key, + next_eligible, + body, + }); + } + Ok(out) + } + + /// Guard one durable effect: reserve `key` with `body`, run + /// `submit`, then dispose of the reservation as the closure's + /// [`Disposition`] directs. The reserve-effect-commit order is + /// fixed by this shape, so a caller cannot run the effect + /// unreserved or leave the marker unsettled. + /// + /// An existing marker short-circuits without running the closure: + /// COMMITTED is an idempotent duplicate, RESERVED is owned by the + /// reconcile pass. A commit-write fault is tolerated (the effect + /// landed; the marker stays RESERVED for reconcile); release and + /// park faults propagate. + /// + /// The caller chooses the `Disposition` per outcome, so `guard` + /// fixes the ordering, not the retry policy. `Keeper::run`'s + /// fresh-submit arm releases on every venue error, so wiring `guard` + /// there must supply that policy, not the reconcile pass's park. + pub async fn guard( + &self, + key: &str, + body: &[u8], + submit: F, + ) -> Result, Fault> + where + F: FnOnce() -> Fut, + Fut: Future, + { + if let Some(mark) = self.mark(key)? { + return Ok(Guarded::Skipped(mark)); + } + self.reserve(key, body)?; + let (disposition, outcome) = submit().await; + match disposition { + Disposition::Commit => { + // Best-effort: the effect is durable upstream, so a + // commit fault leaves the marker RESERVED for the next + // reconcile pass, never an abort. + if let Err(fault) = self.commit(key) { + tracing::error!(%key, %fault, "commit write failed; reconcile owns the marker"); + } + } + Disposition::Release => self.release(key)?, + Disposition::Park { until } => self.park(key, body, until)?, + Disposition::Retain => {} + } + Ok(Guarded::Ran(outcome)) + } +} + +/// How a guarded submit's outcome disposes of its reservation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Disposition { + /// Accepted: the effect is durable, commit the marker. + Commit, + /// Known synchronous non-accept (requires-signing or a terminal + /// refusal): release the marker. + Release, + /// Retryable refusal with a window: re-park the marker. + Park { + /// Earliest Unix-seconds a retry is eligible. + until: u64, + }, + /// Outcome unknown: leave the marker RESERVED for reconcile. + Retain, +} + +/// What [`Journal::guard`] did with a submission. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Guarded { + /// The submit closure ran; the reservation was disposed as the + /// closure directed. + Ran(T), + /// An existing marker skipped the closure: [`Mark::Committed`] is + /// an idempotent duplicate, [`Mark::Reserved`] is owned by the + /// reconcile pass. + Skipped(Mark), +} + +/// One poll dispatch's world view: chain, block height, block clock. +/// Gate checks and backoff read the instant the source was polled at, +/// so a watch never gates against a clock it was not judged by. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Tick { + /// Chain the dispatch targets. + pub chain_id: u64, + /// Block height at the tick. + pub block: u64, + /// Block timestamp, Unix seconds. + pub epoch_s: u64, +} + +/// A source of conditional commitments: poll one watch, produce one +/// outcome. Generic over the host for mock-testability; the source owns +/// its own wire. `poll` is infallible by contract: a transient failure +/// surfaces as a retry-flavoured outcome rather than tearing down the +/// run. +pub trait Poller { + /// What one poll produces. + type Outcome; + + /// Poll the source for `watch` at `tick`. `params` is the stored + /// watch value, passed verbatim for the source to decode. + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Self::Outcome; + + /// Short keeper name for log lines (e.g. `"twap"`). Diagnostic + /// only. + fn label(&self) -> &'static str { + "poller" + } +} + +/// What the retry ledger does to a watch after a failed run. +/// `#[non_exhaustive]`: treat an unknown variant as leave-in-place. +#[derive(Clone, Copy, Debug, Eq, PartialEq, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum RetryAction { + /// Leave the watch untouched; the next tick re-attempts. + TryNextBlock, + /// Gate the watch until `now + seconds` on the epoch clock. + Backoff { + /// Seconds to wait before retrying. + seconds: u64, + }, + /// Grant one next-block retry: the first application records the + /// block and gates to the next; a repeat at a later block removes + /// the watch. + DropOnRepeat, + /// Remove the watch and its gates; no retry can succeed. + Drop, +} + +/// Retry ledger: runs a [`RetryAction`]'s effect through the keeper +/// stores. `Backoff` saturates at `u64::MAX` on the epoch clock; +/// `DropOnRepeat` keeps a `refused:` block marker so only a repeat on +/// a later block removes the watch; `Drop` delegates to +/// [`WatchSet::remove`], so derived keys go first and no failure path +/// can orphan one. +pub struct Retrier<'h, H> { + host: &'h H, +} + +impl<'h, H: LocalStoreHost> Retrier<'h, H> { + /// Ledger view over the given host. + pub fn new(host: &'h H) -> Self { + Self { host } + } + + /// Apply `action` to the watch at `tick`: the backoff origin and + /// the repeat-detection block both read from it. + pub fn apply( + &self, + watch: WatchRef<'_>, + action: RetryAction, + tick: &Tick, + ) -> Result<(), Fault> { + match action { + RetryAction::TryNextBlock => Ok(()), + RetryAction::Backoff { seconds } => { + Gates::new(self.host).set_next_epoch(watch, tick.epoch_s.saturating_add(seconds)) + } + RetryAction::DropOnRepeat => { + let key = watch.refused_key(); + match read_u64(self.host, &key)? { + Some(block) if tick.block > block => WatchSet::new(self.host).remove(watch), + Some(_) => Ok(()), + None => { + self.host.set(&key, &tick.block.to_le_bytes())?; + Gates::new(self.host).set_next_block(watch, tick.block.saturating_add(1)) + } + } + } + RetryAction::Drop => WatchSet::new(self.host).remove(watch), + } + } + + /// Clear the first-refusal marker, so a later + /// [`RetryAction::DropOnRepeat`] earns a fresh one-block grace. + /// No-op when unset. + pub fn clear_refusal(&self, watch: WatchRef<'_>) -> Result<(), Fault> { + self.host.delete(&watch.refused_key()) + } +} diff --git a/nexum/crates/nexum-sdk/src/lib.rs b/nexum/crates/nexum-sdk/src/lib.rs new file mode 100644 index 00000000..9023a599 --- /dev/null +++ b/nexum/crates/nexum-sdk/src/lib.rs @@ -0,0 +1,48 @@ +//! Guest-side SDK for nexum runtime modules: host-neutral, domain-free +//! helpers usable by any module regardless of the world it exports. +//! Domain layers such as the CoW SDK build on top. +//! +//! Modules keep their own `wit_bindgen::generate!` call and pull helpers +//! and canonical primitive types from here; this crate takes primitive +//! types (`&[u8]`, slices) rather than the per-module `Fault`, so it +//! emits no wit-bindgen output of its own. +//! +//! Modules: +//! - [`prelude`] - alloy primitive re-exports. +//! - [`host`] - the [`Host`](host::Host) seam over the six core host interfaces, plus the [`Fault`](host::Fault) vocabulary. +//! - [`keeper`] - keeper stores ([`WatchSet`](keeper::WatchSet), [`Gates`](keeper::Gates), [`Journal`](keeper::Journal)), the [`Poller`](keeper::Poller) seam, and the [`Retrier`](keeper::Retrier). +//! - [`chain`] - typed chain access and the alloy provider seam. +//! - [`events`] - chain-log delivery. +//! - [`store`] - typed local-store helpers ([`WriteBatch`](store::WriteBatch), [`TypedCell`](store::TypedCell), [`TypedMap`](store::TypedMap), [`Counter`](store::Counter)). +//! - [`config`] - config-table lookups and decimal scaling. +//! - [`address`] - EVM address parsing. +//! - [`http`] - outbound HTTP over wasi:http. +//! - [`tracing`] - guest-side `tracing` facade. +//! - [`module`] and [`bind_host_via_wit_bindgen!`](crate::bind_host_via_wit_bindgen) generate the per-cdylib glue. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] + +/// Generate the per-cdylib module glue from an `impl` block of named +/// handlers. See [`nexum_module_macros::module`]. +pub use nexum_module_macros::module; + +pub mod address; +pub mod chain; +pub mod config; +pub mod events; +pub mod host; +pub mod http; +pub mod keeper; +pub mod prelude; +pub mod store; +pub mod tracing; +pub mod wit_bindgen_macro; + +/// Shared log-level vocabulary for every SDK log path. `Ord` is +/// filter-oriented (`ERROR` is least verbose), not severity-ordered. +pub use tracing_core::Level; + +#[cfg(test)] +mod proptests; diff --git a/nexum/crates/nexum-sdk/src/prelude.rs b/nexum/crates/nexum-sdk/src/prelude.rs new file mode 100644 index 00000000..d587dda1 --- /dev/null +++ b/nexum/crates/nexum-sdk/src/prelude.rs @@ -0,0 +1,5 @@ +//! Alloy address, hash, and numeric primitives the chain helpers +//! consume. The wit-bindgen-generated types are not re-exported here; +//! they live in each module's own crate. + +pub use alloy_primitives::{Address, B256, Bytes, Signature, U256, address, b256, hex, keccak256}; diff --git a/crates/nexum-sdk/src/proptests.rs b/nexum/crates/nexum-sdk/src/proptests.rs similarity index 74% rename from crates/nexum-sdk/src/proptests.rs rename to nexum/crates/nexum-sdk/src/proptests.rs index 0f3e24b9..634d4df6 100644 --- a/crates/nexum-sdk/src/proptests.rs +++ b/nexum/crates/nexum-sdk/src/proptests.rs @@ -1,17 +1,6 @@ -//! Property-based regression tests for the SDK's codec round-trips -//! and validation functions. Lives behind `#[cfg(test)]` so neither -//! the wasm32-wasip2 builds nor downstream consumers pay the -//! proptest dep cost. -//! -//! Covered here: -//! -//! - `eth_call_params` / `parse_eth_call_result` round-trip. -//! - `config::scale_decimal` decimal scaling round-trip. -//! - `U256` little-endian byte round-trip (mirrored from -//! `balance-tracker`'s persistence path). -//! -//! The CoW-domain properties (`decode_revert`, the -//! `gpv2_to_order_data` marker guard) live in `shepherd-sdk`. +//! Property-based round-trip tests for the SDK codecs: `eth_call` +//! params/result, `config::scale_decimal`, and `U256` little-endian +//! bytes. #![cfg(test)] @@ -60,10 +49,8 @@ fn decimal_string() -> impl Strategy { // ---- properties --------------------------------------------------- proptest! { - /// `eth_call_params(to, data)` produces a JSON string that - /// alloy's transport will accept; `parse_eth_call_result` round- - /// trips through any 0x-prefixed hex blob the result field can - /// carry. + /// `eth_call_params` embeds the address; `parse_eth_call_result` + /// round-trips any `0x`-hex body. #[test] fn eth_call_round_trip_hex( addr in any_address(), @@ -83,9 +70,7 @@ proptest! { prop_assert_eq!(parsed, body); } - /// `parse_eth_call_result` returns `None` on a non-quoted or - /// non-hex shape. Catches accidental "string contains 0x" - /// false positives. + /// `parse_eth_call_result` returns `None` on a non-quoted shape. #[test] fn parse_eth_call_result_rejects_unquoted( s in "[a-zA-Z0-9]{0,32}", @@ -94,9 +79,8 @@ proptest! { prop_assert!(parse_eth_call_result(&s).is_none() || s.starts_with('"')); } - /// `config::scale_decimal` round-trips: scaling by 10^d then - /// reversing the integer division reproduces the unsigned - /// portion. The reverse uses I256 to U256 cast guarded by sign. + /// `config::scale_decimal` round-trips: dividing the scaled value + /// by `10^decimals` reproduces the integer part and the sign. #[test] fn scale_decimal_round_trip( (value, decimals) in decimal_string(), @@ -131,10 +115,7 @@ proptest! { } } - /// `U256` round-trips through little-endian 32-byte - /// serialisation. Mirrored from balance-tracker's persistence - /// path; the SDK does not own this function but the property - /// belongs here since the same shape is reused across modules. + /// `U256` round-trips through little-endian 32-byte bytes. #[test] fn u256_le_round_trip(v in any_u256()) { let bytes = v.to_le_bytes::<32>(); diff --git a/nexum/crates/nexum-sdk/src/store.rs b/nexum/crates/nexum-sdk/src/store.rs new file mode 100644 index 00000000..bd115345 --- /dev/null +++ b/nexum/crates/nexum-sdk/src/store.rs @@ -0,0 +1,228 @@ +//! Typed local-store helpers over the [`LocalStoreHost`] seam: +//! [`WriteBatch`], [`clear_prefix`], [`TypedCell`], [`TypedMap`], and +//! [`Counter`], so module code hand-rolls neither serialization nor +//! batching. Typed values cross the store as borsh bytes. +//! +//! Batch atomicity follows [`LocalStoreHost::apply`]: all-or-nothing on +//! the real host adapter, per-op on the trait's fallback. + +use core::marker::PhantomData; + +use borsh::{BorshDeserialize, BorshSerialize}; + +use crate::host::{Fault, LocalStoreHost, WriteOp}; + +/// Encode as borsh bytes; a failure folds to [`Fault::Internal`]. +fn encode(value: &T) -> Result, Fault> { + borsh::to_vec(value).map_err(|e| Fault::Internal(format!("borsh encode failed: {e}"))) +} + +/// Decode borsh bytes (trailing bytes are malformed); a failure folds +/// to [`Fault::Internal`] naming the key. +fn decode(key: &str, bytes: &[u8]) -> Result { + borsh::from_slice(bytes) + .map_err(|e| Fault::Internal(format!("stored value at `{key}` failed to decode: {e}"))) +} + +/// Stages set/delete ops, flushed in one [`LocalStoreHost::apply`] +/// call. Dropping an unflushed batch discards it. The host caps a +/// batch at 1024 ops and 4 MiB; a larger batch fails whole. +pub struct WriteBatch<'h, H> { + host: &'h H, + ops: Vec, +} + +impl<'h, H: LocalStoreHost> WriteBatch<'h, H> { + /// Empty batch over the given host. + pub fn new(host: &'h H) -> Self { + Self { + host, + ops: Vec::new(), + } + } + + /// Stage an insert-or-overwrite. + pub fn set(&mut self, key: impl Into, value: impl Into>) -> &mut Self { + self.ops.push(WriteOp::Set { + key: key.into(), + value: value.into(), + }); + self + } + + /// Stage a delete. + pub fn delete(&mut self, key: impl Into) -> &mut Self { + self.ops.push(WriteOp::Delete { key: key.into() }); + self + } + + /// Staged op count. + pub fn len(&self) -> usize { + self.ops.len() + } + + /// Whether nothing is staged. + pub fn is_empty(&self) -> bool { + self.ops.is_empty() + } + + /// Flush every staged op in one [`LocalStoreHost::apply`] call; a + /// no-op when nothing is staged. + pub fn flush(self) -> Result<(), Fault> { + if self.ops.is_empty() { + return Ok(()); + } + self.host.apply(&self.ops) + } +} + +/// Delete every key under `prefix` in one [`LocalStoreHost::apply`] +/// call; returns the number of keys deleted. Fails whole past the +/// host's 1024-key batch cap; chunk manually for larger prefixes. +pub fn clear_prefix(host: &impl LocalStoreHost, prefix: &str) -> Result { + let ops: Vec = host + .list_keys(prefix)? + .into_iter() + .map(|key| WriteOp::Delete { key }) + .collect(); + if ops.is_empty() { + return Ok(0); + } + let count = ops.len() as u64; + host.apply(&ops)?; + Ok(count) +} + +/// One borsh-typed value under one key. +pub struct TypedCell<'h, H, T> { + host: &'h H, + key: String, + _value: PhantomData T>, +} + +impl<'h, H: LocalStoreHost, T: BorshSerialize + BorshDeserialize> TypedCell<'h, H, T> { + /// Cell over the given key. + pub fn new(host: &'h H, key: impl Into) -> Self { + Self { + host, + key: key.into(), + _value: PhantomData, + } + } + + /// Decoded value, `Ok(None)` when absent. + pub fn get(&self) -> Result, Fault> { + self.host + .get(&self.key)? + .map(|bytes| decode(&self.key, &bytes)) + .transpose() + } + + /// Encode and store `value`. + pub fn set(&self, value: &T) -> Result<(), Fault> { + self.host.set(&self.key, &encode(value)?) + } + + /// Delete the value; a no-op if absent. + pub fn clear(&self) -> Result<(), Fault> { + self.host.delete(&self.key) + } +} + +/// Borsh-typed values keyed under a prefix: a keyed collection for +/// sets of things such as watches or orders. +pub struct TypedMap<'h, H, T> { + host: &'h H, + prefix: String, + _value: PhantomData T>, +} + +impl<'h, H: LocalStoreHost, T: BorshSerialize + BorshDeserialize> TypedMap<'h, H, T> { + /// Collection under the given key prefix. + pub fn new(host: &'h H, prefix: impl Into) -> Self { + Self { + host, + prefix: prefix.into(), + _value: PhantomData, + } + } + + fn full_key(&self, key: &str) -> String { + format!("{}{key}", self.prefix) + } + + /// Encode and store `value` under `key`. + pub fn insert(&self, key: &str, value: &T) -> Result<(), Fault> { + self.host.set(&self.full_key(key), &encode(value)?) + } + + /// Decoded value at `key`, `Ok(None)` when absent. + pub fn get(&self, key: &str) -> Result, Fault> { + let full = self.full_key(key); + self.host + .get(&full)? + .map(|bytes| decode(&full, &bytes)) + .transpose() + } + + /// Delete `key`; a no-op if absent. + pub fn remove(&self, key: &str) -> Result<(), Fault> { + self.host.delete(&self.full_key(key)) + } + + /// Keys in the collection, prefix stripped. + pub fn keys(&self) -> Result, Fault> { + Ok(self + .host + .list_keys(&self.prefix)? + .into_iter() + .map(|key| match key.strip_prefix(&self.prefix) { + Some(stripped) => stripped.to_owned(), + None => key, + }) + .collect()) + } + + /// Delete every entry in one [`LocalStoreHost::apply`] call; + /// returns the number deleted. Fails whole past the 1024-entry cap. + pub fn clear(&self) -> Result { + clear_prefix(self.host, &self.prefix) + } +} + +/// A `u64` under one key. Read-modify-write, so safe under the +/// runtime's single-actor serialized dispatch; there is no cross-call +/// lock. +pub struct Counter<'h, H> { + host: &'h H, + key: String, +} + +impl<'h, H: LocalStoreHost> Counter<'h, H> { + /// Counter over the given key. + pub fn new(host: &'h H, key: impl Into) -> Self { + Self { + host, + key: key.into(), + } + } + + /// Current value; 0 when absent. + pub fn get(&self) -> Result { + self.host + .get(&self.key)? + .map_or(Ok(0), |bytes| decode(&self.key, &bytes)) + } + + /// Add `delta` (saturating) and store; returns the new value. + pub fn add(&self, delta: u64) -> Result { + let next = self.get()?.saturating_add(delta); + self.set(next)?; + Ok(next) + } + + /// Store `value`. + pub fn set(&self, value: u64) -> Result<(), Fault> { + self.host.set(&self.key, &encode(&value)?) + } +} diff --git a/crates/nexum-sdk/src/tracing.rs b/nexum/crates/nexum-sdk/src/tracing.rs similarity index 83% rename from crates/nexum-sdk/src/tracing.rs rename to nexum/crates/nexum-sdk/src/tracing.rs index 81d1bb43..3709f61b 100644 --- a/crates/nexum-sdk/src/tracing.rs +++ b/nexum/crates/nexum-sdk/src/tracing.rs @@ -1,16 +1,11 @@ -//! Guest-side `tracing` facade: routes `tracing` events to a host log -//! sink so module authors write `tracing::info!(...)` with no host -//! parameter to thread. +//! Guest-side `tracing` facade routing events to a host log sink, so +//! module authors write `tracing::info!(...)` with no host parameter. //! -//! The subscriber is events-only: it renders each event's fields into -//! one line and forwards it at the event's [`Level`]. Spans are inert -//! no-ops. It links `tracing-core` alone, not the subscriber registry, -//! so the wasm module stays small. -//! -//! The [`init`] call also installs a panic hook that writes the panic -//! to stderr and then reports it over the same sink. Stderr is written -//! first on purpose: host-side stderr capture still records the panic -//! even if the sink's host call traps before `panic = abort` fires. +//! Events-only: each event's fields render to one line, forwarded at +//! the event's [`Level`]; spans are inert. [`init`] also installs a +//! panic hook that writes the panic to stderr, then reports it over the +//! sink (stderr first, so the panic is captured even if the host call +//! traps before `panic = abort`). use core::fmt::{self, Write as _}; use core::sync::atomic::{AtomicU64, Ordering}; @@ -21,16 +16,16 @@ use tracing_core::field::{Field, Visit}; use tracing_core::span::{Attributes, Id, Record}; use tracing_core::{Event, Level, LevelFilter, Metadata, Subscriber}; -/// Sink the facade forwards rendered events to. Implementors carry the -/// bound host logging call; the host decides how each line is handled. +/// Sink the facade forwards rendered event lines to; implementors carry +/// the bound host logging call. pub trait LogSink: Send + Sync { /// Forward one rendered line at `level`. fn log(&self, level: Level, message: &str); } /// Install the facade as the global subscriber and register the panic -/// hook, both forwarding to `sink`. The subscriber is set once; a -/// second call leaves it in place and only re-registers the panic hook. +/// hook over `sink`. The subscriber is set once; a second call only +/// re-registers the panic hook. pub fn init(sink: impl LogSink + 'static) { let sink: Arc = Arc::new(sink); let dispatch = tracing_core::Dispatch::new(FacadeSubscriber::new(Arc::clone(&sink))); @@ -39,8 +34,8 @@ pub fn init(sink: impl LogSink + 'static) { set_panic_hook(sink); } -/// Build the events-only subscriber over `sink` without touching global -/// state. Test harnesses scope it with `tracing::subscriber::with_default`. +/// The events-only subscriber over `sink`, without touching global +/// state. pub fn subscriber(sink: impl LogSink + 'static) -> impl Subscriber { FacadeSubscriber::new(Arc::new(sink)) } @@ -68,8 +63,7 @@ fn panic_payload(info: &PanicHookInfo<'_>) -> String { } } -/// Render a panic into the reported line. Pure so it is unit-testable -/// apart from the abort path. +/// Render a panic into the reported line. fn format_panic(payload: &str, location: Option<(&str, u32)>) -> String { match location { Some((file, line)) => format!("panic: {payload} at {file}:{line}"), @@ -126,9 +120,7 @@ impl Subscriber for FacadeSubscriber { fn exit(&self, _span: &Id) {} } -/// Flattens an event into ` key=value ...`: the `message` -/// field becomes the line body, every other field is appended as a -/// space-separated `key=value` pair in record order. +/// Flattens an event into ` key=value ...` in record order. #[derive(Default)] struct LineVisitor { message: String, @@ -182,7 +174,7 @@ mod tests { use super::*; - /// Capturing sink for the with_default-scoped subscriber. + /// Capturing sink for the scoped subscriber. #[derive(Default)] struct Captured { lines: Mutex>, diff --git a/nexum/crates/nexum-sdk/src/wit_bindgen_macro.rs b/nexum/crates/nexum-sdk/src/wit_bindgen_macro.rs new file mode 100644 index 00000000..39558dea --- /dev/null +++ b/nexum/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -0,0 +1,368 @@ +//! Declarative macro generating the `WitBindgenHost` adapter a module +//! ships in `lib.rs`: `struct WitBindgenHost;` plus the core trait +//! impls and the fault, chain-error, and level conversions. +//! +//! Capability-selected: `caps: [...]` emits only the pieces backed by +//! the listed capabilities (how `#[nexum_sdk::module]` invokes it); the +//! zero-argument form emits the full six-interface set. Either way the +//! wit-bindgen output for the world must already be in scope, so +//! selecting a capability the world does not import is a compile error. +//! A domain SDK layers its own interfaces on the same `WitBindgenHost`. +//! +//! ```ignore +//! wit_bindgen::generate!({ /* ... */ }); +//! nexum_sdk::bind_host_via_wit_bindgen!(); +//! // or capability-selected: +//! nexum_sdk::bind_host_via_wit_bindgen!(caps: [chain, logging]); +//! // Call `install_tracing()` once at the top of `Guest::init`. +//! ``` + +/// Generate `WitBindgenHost`, the `*Host` trait impls, and the error / +/// level `From` impls for the selected capabilities. See module docs. +/// +/// The generated names `WitBindgenHost`, `convert_chain_err`, +/// `HostLogSink`, and `install_tracing` are visible in the caller's +/// scope (`macro_rules!` is not hygienic for items). +#[macro_export] +macro_rules! bind_host_via_wit_bindgen { + // Blanket-world form: every core interface is in scope, emit the + // full adapter. + () => { + $crate::bind_host_via_wit_bindgen!( + caps: [chain, identity, local_store, remote_store, messaging, logging] + ); + }; + // Capability-selected form: the base pieces (which need only the + // always-present `nexum:host/types`) plus one block per listed + // capability. + (caps: [$($cap:ident),* $(,)?]) => { + /// Wraps the module's per-cdylib wit-bindgen imports so a + /// module can hold a `&impl Host`. + struct WitBindgenHost; + + /// Lift the wit-bindgen `types.fault` into the SDK's `Fault`. + impl ::core::convert::From for $crate::host::Fault { + fn from(f: nexum::host::types::Fault) -> Self { + match f { + nexum::host::types::Fault::Unsupported(s) => Self::Unsupported(s), + nexum::host::types::Fault::Unavailable(s) => Self::Unavailable(s), + nexum::host::types::Fault::Denied(s) => Self::Denied(s), + nexum::host::types::Fault::RateLimited(rl) => { + Self::RateLimited($crate::host::RateLimit { + retry_after_ms: rl.retry_after_ms, + }) + } + nexum::host::types::Fault::Timeout => Self::Timeout, + nexum::host::types::Fault::InvalidInput(s) => Self::InvalidInput(s), + nexum::host::types::Fault::Internal(s) => Self::Internal(s), + } + } + } + + /// Lower the SDK `Fault` back into the wit-bindgen `Fault` for + /// the export signature. A future `#[non_exhaustive]` SDK case + /// falls back to `internal` carrying the `Display` detail. + impl ::core::convert::From<$crate::host::Fault> for nexum::host::types::Fault { + fn from(f: $crate::host::Fault) -> Self { + match f { + $crate::host::Fault::Unsupported(s) => Self::Unsupported(s), + $crate::host::Fault::Unavailable(s) => Self::Unavailable(s), + $crate::host::Fault::Denied(s) => Self::Denied(s), + $crate::host::Fault::RateLimited(rl) => { + Self::RateLimited(nexum::host::types::RateLimit { + retry_after_ms: rl.retry_after_ms, + }) + } + $crate::host::Fault::Timeout => Self::Timeout, + $crate::host::Fault::InvalidInput(s) => Self::InvalidInput(s), + $crate::host::Fault::Internal(s) => Self::Internal(s), + // `$crate::host::Fault` is `#[non_exhaustive]`; a + // future SDK case lands here as `internal`. + other => Self::Internal(::std::string::ToString::to_string(&other)), + } + } + } + + /// Rebuild the native alloy log from the wit-bindgen `chain-log` + /// record; assembly lives in `nexum_sdk::events`. + impl ::core::convert::From for $crate::events::Log { + fn from(log: nexum::host::types::ChainLog) -> Self { + $crate::events::ChainLogParts { + address: &log.address, + topics: &log.topics, + data: &log.data, + block_hash: log.block_hash.as_deref(), + block_number: log.block_number, + block_timestamp: log.block_timestamp, + transaction_hash: log.transaction_hash.as_deref(), + transaction_index: log.transaction_index, + log_index: log.log_index, + removed: log.removed, + } + .into() + } + } + + /// Rebuild the SDK `Message` from the wit-bindgen `message` + /// record. + impl ::core::convert::From for $crate::host::Message { + fn from(message: nexum::host::types::Message) -> Self { + Self { + content_topic: message.content_topic, + payload: message.payload, + timestamp: message.timestamp, + sender: message.sender, + } + } + } + + $($crate::__bind_host_cap_via_wit_bindgen!($cap);)* + }; +} + +/// One capability's slice of the `WitBindgenHost` adapter. Invoked by +/// [`bind_host_via_wit_bindgen!`]; not part of the public surface. +#[doc(hidden)] +#[macro_export] +macro_rules! __bind_host_cap_via_wit_bindgen { + (chain) => { + impl $crate::host::ChainHost for WitBindgenHost { + fn request( + &self, + chain_id: u64, + method: &str, + params: &str, + ) -> ::core::result::Result<::std::string::String, $crate::host::ChainError> { + nexum::host::chain::request(chain_id, method, params).map_err(convert_chain_err) + } + } + + /// Lift the wit-bindgen `chain.chain-error` into the SDK's + /// host-neutral `ChainError`. + fn convert_chain_err(e: nexum::host::chain::ChainError) -> $crate::host::ChainError { + match e { + nexum::host::chain::ChainError::Fault(f) => { + $crate::host::ChainError::Fault(::core::convert::Into::into(f)) + } + nexum::host::chain::ChainError::Rpc(r) => { + $crate::host::ChainError::Rpc($crate::host::RpcError { + code: r.code, + message: r.message, + data: r.data.map(::core::convert::Into::into), + }) + } + } + } + }; + (identity) => { + impl $crate::host::IdentityHost for WitBindgenHost { + fn accounts( + &self, + ) -> ::core::result::Result< + ::std::vec::Vec<$crate::prelude::Address>, + $crate::host::Fault, + > { + nexum::host::identity::accounts() + .map_err($crate::host::Fault::from)? + .iter() + .map(|account| $crate::host::account_from_wire(account)) + .collect() + } + fn sign( + &self, + account: $crate::prelude::Address, + message: &[u8], + ) -> ::core::result::Result<$crate::prelude::Signature, $crate::host::Fault> { + let raw = nexum::host::identity::sign(account.as_slice(), message) + .map_err($crate::host::Fault::from)?; + $crate::host::signature_from_wire(&raw) + } + fn sign_typed_data( + &self, + account: $crate::prelude::Address, + typed_data: &str, + ) -> ::core::result::Result<$crate::prelude::Signature, $crate::host::Fault> { + let raw = nexum::host::identity::sign_typed_data(account.as_slice(), typed_data) + .map_err($crate::host::Fault::from)?; + $crate::host::signature_from_wire(&raw) + } + } + }; + (local_store) => { + impl $crate::host::LocalStoreHost for WitBindgenHost { + fn get( + &self, + key: &str, + ) -> ::core::result::Result< + ::core::option::Option<::std::vec::Vec>, + $crate::host::Fault, + > { + nexum::host::local_store::get(key).map_err($crate::host::Fault::from) + } + fn set( + &self, + key: &str, + value: &[u8], + ) -> ::core::result::Result<(), $crate::host::Fault> { + nexum::host::local_store::set(key, value).map_err($crate::host::Fault::from) + } + fn delete(&self, key: &str) -> ::core::result::Result<(), $crate::host::Fault> { + nexum::host::local_store::delete(key).map_err($crate::host::Fault::from) + } + // Overrides the trait's per-op fallback with the host's + // atomic batch verb. + fn apply( + &self, + ops: &[$crate::host::WriteOp], + ) -> ::core::result::Result<(), $crate::host::Fault> { + let ops: ::std::vec::Vec = ops + .iter() + .map(|op| match op { + $crate::host::WriteOp::Set { key, value } => { + nexum::host::local_store::WriteOp::Set( + nexum::host::local_store::KeyValue { + key: ::std::clone::Clone::clone(key), + value: ::std::clone::Clone::clone(value), + }, + ) + } + $crate::host::WriteOp::Delete { key } => { + nexum::host::local_store::WriteOp::Delete(::std::clone::Clone::clone( + key, + )) + } + }) + .collect(); + nexum::host::local_store::apply(&ops).map_err($crate::host::Fault::from) + } + fn list_keys( + &self, + prefix: &str, + ) -> ::core::result::Result<::std::vec::Vec<::std::string::String>, $crate::host::Fault> + { + nexum::host::local_store::list_keys(prefix).map_err($crate::host::Fault::from) + } + fn contains(&self, key: &str) -> ::core::result::Result { + nexum::host::local_store::contains(key).map_err($crate::host::Fault::from) + } + fn len( + &self, + key: &str, + ) -> ::core::result::Result<::core::option::Option, $crate::host::Fault> { + nexum::host::local_store::len(key).map_err($crate::host::Fault::from) + } + fn count(&self, prefix: &str) -> ::core::result::Result { + nexum::host::local_store::count(prefix).map_err($crate::host::Fault::from) + } + } + }; + (remote_store) => { + impl $crate::host::RemoteStoreHost for WitBindgenHost { + fn upload( + &self, + data: &[u8], + ) -> ::core::result::Result<$crate::prelude::B256, $crate::host::Fault> { + let raw = + nexum::host::remote_store::upload(data).map_err($crate::host::Fault::from)?; + $crate::host::reference_from_wire(&raw) + } + fn download( + &self, + reference: $crate::prelude::B256, + ) -> ::core::result::Result<::std::vec::Vec, $crate::host::Fault> { + nexum::host::remote_store::download(reference.as_slice()) + .map_err($crate::host::Fault::from) + } + fn read_feed( + &self, + owner: $crate::prelude::Address, + topic: $crate::prelude::B256, + ) -> ::core::result::Result< + ::core::option::Option<::std::vec::Vec>, + $crate::host::Fault, + > { + nexum::host::remote_store::read_feed(owner.as_slice(), topic.as_slice()) + .map_err($crate::host::Fault::from) + } + fn write_feed( + &self, + topic: $crate::prelude::B256, + data: &[u8], + ) -> ::core::result::Result<$crate::prelude::B256, $crate::host::Fault> { + let raw = nexum::host::remote_store::write_feed(topic.as_slice(), data) + .map_err($crate::host::Fault::from)?; + $crate::host::reference_from_wire(&raw) + } + } + }; + (messaging) => { + impl $crate::host::MessagingHost for WitBindgenHost { + fn publish( + &self, + content_topic: &str, + payload: &[u8], + ) -> ::core::result::Result<(), $crate::host::Fault> { + nexum::host::messaging::publish(content_topic, payload) + .map_err($crate::host::Fault::from) + } + fn query( + &self, + content_topic: &str, + start_time: ::core::option::Option, + end_time: ::core::option::Option, + limit: ::core::option::Option, + ) -> ::core::result::Result<::std::vec::Vec<$crate::host::Message>, $crate::host::Fault> + { + let messages = + nexum::host::messaging::query(content_topic, start_time, end_time, limit) + .map_err($crate::host::Fault::from)?; + ::core::result::Result::Ok( + messages + .into_iter() + .map(::core::convert::Into::into) + .collect(), + ) + } + } + }; + (logging) => { + impl $crate::host::LoggingHost for WitBindgenHost { + fn log(&self, level: $crate::Level, message: &str) { + nexum::host::logging::log(nexum::host::logging::Level::from(level), message); + } + } + + /// Translate a `tracing_core::Level` into the wit-bindgen + /// `logging::Level` wire enum. + impl ::core::convert::From<$crate::Level> for nexum::host::logging::Level { + fn from(level: $crate::Level) -> Self { + if level == $crate::Level::ERROR { + Self::Error + } else if level == $crate::Level::WARN { + Self::Warn + } else if level == $crate::Level::INFO { + Self::Info + } else if level == $crate::Level::DEBUG { + Self::Debug + } else { + Self::Trace + } + } + } + + /// Routes guest `tracing` events to the bound host logging call. + struct HostLogSink; + + impl $crate::tracing::LogSink for HostLogSink { + fn log(&self, level: $crate::Level, message: &str) { + ::log(&WitBindgenHost, level, message); + } + } + + /// Install the guest tracing facade and panic hook over the + /// bound host logging call. Call once at the top of `Guest::init`. + fn install_tracing() { + $crate::tracing::init(HostLogSink); + } + }; +} diff --git a/nexum/crates/nexum-sdk/tests/keeper.rs b/nexum/crates/nexum-sdk/tests/keeper.rs new file mode 100644 index 00000000..a67ab99e --- /dev/null +++ b/nexum/crates/nexum-sdk/tests/keeper.rs @@ -0,0 +1,867 @@ +//! Keeper-store acceptance tests against `nexum_sdk_test::MockHost`. +//! Integration-test placement (not `#[cfg(test)]`) keeps the host +//! traits linked externally, as a module would see them. + +use alloy_primitives::{Address, B256, address, b256}; +use nexum_sdk::host::{Fault, LocalStoreHost as _}; +use nexum_sdk::keeper::{ + Disposition, Gates, Guarded, Journal, Mark, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, Poller, + REFUSED_PREFIX, Reservation, Retrier, RetryAction, Tick, WATCH_PREFIX, WatchRef, WatchSet, + watch_key, +}; +use nexum_sdk_test::MockHost; + +fn sample_owner() -> Address { + address!("00112233445566778899aabbccddeeff00112233") +} + +fn sample_hash() -> B256 { + b256!("0202020202020202020202020202020202020202020202020202020202020202") +} + +#[test] +fn watch_key_is_lowercase_prefixed_hex() { + let key = watch_key(&sample_owner(), &sample_hash()); + assert_eq!( + key, + concat!( + "watch:0x00112233445566778899aabbccddeeff00112233:", + "0x0202020202020202020202020202020202020202020202020202020202020202", + ), + ); +} + +#[test] +fn watch_key_round_trips_via_parse() { + let key = watch_key(&sample_owner(), &sample_hash()); + let watch = WatchRef::parse(&key).expect("parse"); + assert_eq!( + watch.owner_hex().parse::
().unwrap(), + sample_owner() + ); + assert_eq!(watch.hash_hex().parse::().unwrap(), sample_hash()); + assert_eq!(watch.key(), key); +} + +#[test] +fn parse_rejects_missing_prefix_or_separator() { + assert_eq!(WatchRef::parse("gate:0xaa:0xbb"), None); + assert_eq!(WatchRef::parse("watch:0xaa0xbb"), None); + assert_eq!(WatchRef::parse(""), None); +} + +#[test] +fn parse_rejects_empty_halves() { + // `watch::` splits into two empty halves, which would derive + // degenerate gate keys like `next_block::`; reject it outright. + assert_eq!(WatchRef::parse("watch::"), None); + assert_eq!(WatchRef::parse("watch:0xaa:"), None); + assert_eq!(WatchRef::parse("watch::0xbb"), None); + // A well-formed key with both halves still parses. + assert!(WatchRef::parse("watch:0xaa:0xbb").is_some()); +} + +#[test] +fn parse_preserves_key_substrings_verbatim() { + // A foreign writer may have cased the hex differently; gate keys + // must derive from the stored substrings, not from a re-rendered + // canonical form. + let watch = WatchRef::parse("watch:0xAABB:0xCCDD").expect("parse"); + assert_eq!(watch.owner_hex(), "0xAABB"); + assert_eq!(watch.hash_hex(), "0xCCDD"); + assert_eq!(watch.next_block_key(), "next_block:0xAABB:0xCCDD"); + assert_eq!(watch.next_epoch_key(), "next_epoch:0xAABB:0xCCDD"); +} + +#[test] +fn put_get_list_round_trip() { + let host = MockHost::new(); + let watches = WatchSet::new(&host); + + let key = watches + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap(); + assert_eq!(watches.list().unwrap(), vec![key.clone()]); + + let watch = WatchRef::parse(&key).unwrap(); + assert_eq!(watches.get(watch).unwrap().as_deref(), Some(&b"params"[..])); +} + +#[test] +fn put_overwrites_in_place() { + let host = MockHost::new(); + let watches = WatchSet::new(&host); + + watches + .put(&sample_owner(), &sample_hash(), b"one") + .unwrap(); + let key = watches + .put(&sample_owner(), &sample_hash(), b"two") + .unwrap(); + + assert_eq!(host.store.len(), 1, "re-put must not duplicate the row"); + let watch = WatchRef::parse(&key).unwrap(); + assert_eq!(watches.get(watch).unwrap().as_deref(), Some(&b"two"[..])); +} + +#[test] +fn get_absent_watch_is_none() { + let host = MockHost::new(); + let watches = WatchSet::new(&host); + let key = watch_key(&sample_owner(), &sample_hash()); + let watch = WatchRef::parse(&key).unwrap(); + assert_eq!(watches.get(watch).unwrap(), None); +} + +#[test] +fn list_scans_only_the_watch_prefix() { + let host = MockHost::new(); + let watches = WatchSet::new(&host); + let key = watches + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap(); + Journal::submitted(&host).record("0xuid").unwrap(); + + assert_eq!(watches.list().unwrap(), vec![key]); +} + +#[test] +fn remove_drops_watch_and_all_gate_keys() { + let host = MockHost::new(); + let watches = WatchSet::new(&host); + let gates = Gates::new(&host); + + let key = watches + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap(); + let watch = WatchRef::parse(&key).unwrap(); + gates.set_next_block(watch, 500).unwrap(); + gates.set_next_epoch(watch, 1_700_000_000).unwrap(); + assert_eq!(host.store.len(), 3); + + watches.remove(watch).unwrap(); + + assert!(host.store.is_empty(), "watch and both gates must go"); +} + +#[test] +fn remove_without_gates_is_clean() { + let host = MockHost::new(); + let watches = WatchSet::new(&host); + let key = watches + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap(); + watches.remove(WatchRef::parse(&key).unwrap()).unwrap(); + assert!(host.store.is_empty()); +} + +#[test] +fn remove_clears_gates_before_the_watch_row() { + // A fault on the watch delete must still find the gates gone: the + // retryable leftover is the watch row, never an orphaned gate. + let host = MockHost::new(); + let watches = WatchSet::new(&host); + let gates = Gates::new(&host); + let key = watches + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap(); + let watch = WatchRef::parse(&key).unwrap(); + gates.set_next_block(watch, 500).unwrap(); + gates.set_next_epoch(watch, 1_700_000_000).unwrap(); + + host.store + .fail_on(WATCH_PREFIX, Fault::Unavailable("injected".into())); + + watches.remove(watch).unwrap_err(); + + let snapshot = host.store.snapshot(); + assert!( + !snapshot + .keys() + .any(|k| k.starts_with(NEXT_BLOCK_PREFIX) || k.starts_with(NEXT_EPOCH_PREFIX)), + "gates must already be gone when the watch delete faults", + ); + assert!( + snapshot.contains_key(&key), + "the watch row stays behind so a retry can re-drop it", + ); +} + +#[test] +fn remove_propagates_a_gate_delete_fault_and_keeps_the_watch() { + let host = MockHost::new(); + let watches = WatchSet::new(&host); + let key = watches + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap(); + let watch = WatchRef::parse(&key).unwrap(); + + host.store + .fail_on(NEXT_BLOCK_PREFIX, Fault::Unavailable("injected".into())); + + watches.remove(watch).unwrap_err(); + + assert!( + host.store.snapshot().contains_key(&key), + "a gate-delete fault must leave the watch for a retry", + ); +} + +#[test] +fn ready_with_no_gates_set() { + let host = MockHost::new(); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + assert!(Gates::new(&host).is_ready(watch, 0, 0).unwrap()); +} + +#[test] +fn next_block_gate_is_inclusive_at_threshold() { + let host = MockHost::new(); + let gates = Gates::new(&host); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + gates.set_next_block(watch, 500).unwrap(); + + assert!(!gates.is_ready(watch, 499, u64::MAX).unwrap()); + assert!(gates.is_ready(watch, 500, u64::MAX).unwrap()); + assert!(gates.is_ready(watch, 501, u64::MAX).unwrap()); +} + +#[test] +fn next_epoch_gate_is_inclusive_at_threshold() { + let host = MockHost::new(); + let gates = Gates::new(&host); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + gates.set_next_epoch(watch, 1_700_000_000).unwrap(); + + assert!(!gates.is_ready(watch, u64::MAX, 1_699_999_999).unwrap()); + assert!(gates.is_ready(watch, u64::MAX, 1_700_000_000).unwrap()); +} + +#[test] +fn both_gates_must_pass() { + let host = MockHost::new(); + let gates = Gates::new(&host); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + gates.set_next_block(watch, 100).unwrap(); + gates.set_next_epoch(watch, 2_000).unwrap(); + + assert!(!gates.is_ready(watch, 100, 1_999).unwrap()); + assert!(!gates.is_ready(watch, 99, 2_000).unwrap()); + assert!(gates.is_ready(watch, 100, 2_000).unwrap()); +} + +#[test] +fn gate_values_are_u64_le() { + let host = MockHost::new(); + let gates = Gates::new(&host); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + gates.set_next_block(watch, 0x0102_0304_0506_0708).unwrap(); + + assert_eq!( + host.store.snapshot().get("next_block:0xaa:0xbb").unwrap(), + &0x0102_0304_0506_0708_u64.to_le_bytes().to_vec(), + ); +} + +#[test] +fn malformed_gate_value_reads_as_no_gate() { + let host = MockHost::new(); + let gates = Gates::new(&host); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + host.store.set("next_block:0xaa:0xbb", b"not8b").unwrap(); + + assert!( + gates.is_ready(watch, 0, 0).unwrap(), + "a corrupt gate can only make the watch poll sooner", + ); +} + +#[test] +fn clear_removes_both_gate_keys() { + let host = MockHost::new(); + let gates = Gates::new(&host); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + gates.set_next_block(watch, 1).unwrap(); + gates.set_next_epoch(watch, 2).unwrap(); + + gates.clear(watch).unwrap(); + + assert!(host.store.is_empty()); + // And clearing again stays a no-op. + gates.clear(watch).unwrap(); +} + +#[test] +fn gate_fault_propagates_from_is_ready() { + let host = MockHost::new(); + let gates = Gates::new(&host); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + host.store + .fail_on(NEXT_EPOCH_PREFIX, Fault::Unavailable("injected".into())); + + gates.is_ready(watch, 0, 0).unwrap_err(); +} + +#[test] +fn journal_round_trips_a_receipt() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + assert!(!journal.contains("0xuid").unwrap()); + journal.record("0xuid").unwrap(); + assert!(journal.contains("0xuid").unwrap()); +} + +#[test] +fn journal_marker_is_an_empty_presence_row() { + let host = MockHost::new(); + Journal::submitted(&host).record("0xuid").unwrap(); + assert_eq!( + host.store.snapshot().get("submitted:0xuid").unwrap(), + &Vec::::new(), + ); +} + +#[test] +fn journal_record_is_idempotent() { + let host = MockHost::new(); + let journal = Journal::observed(&host); + journal.record("0xuid").unwrap(); + journal.record("0xuid").unwrap(); + assert_eq!(host.store.len(), 1); +} + +#[test] +fn submitted_and_observed_keyspaces_are_disjoint() { + let host = MockHost::new(); + Journal::submitted(&host).record("0xuid").unwrap(); + + assert!(!Journal::observed(&host).contains("0xuid").unwrap()); + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key("submitted:0xuid")); + assert!(!snapshot.contains_key("observed:0xuid")); +} + +#[test] +fn mark_distinguishes_reserved_committed_absent_and_legacy() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + // Absent. + assert_eq!(journal.mark("absent").unwrap(), None); + + // Reserved. + journal.reserve("res", b"body").unwrap(); + assert_eq!(journal.mark("res").unwrap(), Some(Mark::Reserved)); + + // Committed. + journal.commit("com").unwrap(); + assert_eq!(journal.mark("com").unwrap(), Some(Mark::Committed)); + + // Legacy empty presence marker reads as Committed. + host.store.set("submitted:legacy", b"").unwrap(); + assert_eq!(journal.mark("legacy").unwrap(), Some(Mark::Committed)); + + // A corrupt single-byte tag reads as Reserved. + host.store.set("submitted:corrupt", b"\x7f").unwrap(); + assert_eq!(journal.mark("corrupt").unwrap(), Some(Mark::Reserved)); +} + +#[test] +fn reserve_then_commit_marks_committed() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + journal.reserve("uid", b"body").unwrap(); + journal.commit("uid").unwrap(); + assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Committed)); +} + +#[test] +fn reserve_then_release_marks_absent() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + journal.reserve("uid", b"body").unwrap(); + journal.release("uid").unwrap(); + assert_eq!(journal.mark("uid").unwrap(), None); + assert!(host.store.is_empty()); +} + +#[test] +fn mark_and_pending_agree_on_every_non_committed_value() { + // The invariant: every value mark() calls Reserved, pending() must + // enumerate - including a corrupt single-byte tag with empty body. + // A guard-skipped-yet-never-reconciled marker is a dropped effect. + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + journal.reserve("well_formed", b"body").unwrap(); + host.store.set("submitted:corrupt_tag", b"\x7f").unwrap(); + host.store.set("submitted:short_01", b"\x01").unwrap(); + + let listed: std::collections::BTreeSet = journal + .pending() + .unwrap() + .into_iter() + .map(|r| r.key) + .collect(); + + for key in ["well_formed", "corrupt_tag", "short_01"] { + assert_eq!( + journal.mark(key).unwrap(), + Some(Mark::Reserved), + "{key} must mark Reserved", + ); + assert!(listed.contains(key), "{key} must be enumerated"); + } + + // The corrupt tag enumerates with a zero next_eligible and empty body. + let corrupt = journal + .pending() + .unwrap() + .into_iter() + .find(|r| r.key == "corrupt_tag") + .unwrap(); + assert_eq!(corrupt.next_eligible, 0); + assert!(corrupt.body.is_empty()); +} + +#[test] +fn pending_ignores_committed_and_legacy_and_returns_bodies() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + journal.park("a", b"aa", 1_700_000_000).unwrap(); + journal.reserve("b", b"bb").unwrap(); + journal.commit("c").unwrap(); + host.store.set("submitted:d", b"").unwrap(); + + let mut listed = journal.pending().unwrap(); + listed.sort_by(|x, y| x.key.cmp(&y.key)); + + assert_eq!( + listed, + vec![ + Reservation { + key: "a".into(), + next_eligible: 1_700_000_000, + body: b"aa".to_vec(), + }, + Reservation { + key: "b".into(), + next_eligible: 0, + body: b"bb".to_vec(), + }, + ], + ); +} + +#[test] +fn park_updates_next_eligible_without_touching_body() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + journal.reserve("uid", b"body").unwrap(); + journal.park("uid", b"body", 1_700_000_000).unwrap(); + + let listed = journal.pending().unwrap(); + assert_eq!( + listed, + vec![Reservation { + key: "uid".into(), + next_eligible: 1_700_000_000, + body: b"body".to_vec(), + }], + ); +} + +#[test] +fn double_commit_is_idempotent() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + journal.reserve("uid", b"body").unwrap(); + journal.commit("uid").unwrap(); + journal.commit("uid").unwrap(); + assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Committed)); + assert_eq!(host.store.len(), 1); + assert_eq!( + host.store.snapshot().get("submitted:uid").unwrap(), + &vec![0x02], + ); +} + +#[test] +fn release_of_absent_is_a_no_op() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + journal.release("nope").unwrap(); + assert!(host.store.is_empty()); +} + +fn seeded_watch(host: &MockHost) -> String { + WatchSet::new(host) + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap() +} + +fn tick_at(block: u64, epoch_s: u64) -> Tick { + Tick { + chain_id: 1, + block, + epoch_s, + } +} + +#[test] +fn ledger_try_next_block_leaves_the_store_untouched() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let before = host.store.snapshot(); + + Retrier::new(&host) + .apply( + WatchRef::parse(&key).unwrap(), + RetryAction::TryNextBlock, + &tick_at(100, 1_000), + ) + .unwrap(); + + assert_eq!(host.store.snapshot(), before); +} + +#[test] +fn ledger_backoff_gates_the_watch_on_the_epoch_clock() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let ledger = Retrier::new(&host); + + ledger + .apply( + watch, + RetryAction::Backoff { seconds: 30 }, + &tick_at(100, 1_000), + ) + .unwrap(); + + let gates = Gates::new(&host); + assert!(!gates.is_ready(watch, u64::MAX, 1_029).unwrap()); + assert!(gates.is_ready(watch, u64::MAX, 1_030).unwrap()); + assert_eq!( + host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), + &1_030_u64.to_le_bytes().to_vec(), + ); + assert!( + host.store.snapshot().contains_key(&key), + "backoff must keep the watch", + ); +} + +#[test] +fn ledger_backoff_saturates_on_the_epoch_clock() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + Retrier::new(&host) + .apply( + watch, + RetryAction::Backoff { seconds: u64::MAX }, + &tick_at(100, 1_000), + ) + .unwrap(); + + assert_eq!( + host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), + &u64::MAX.to_le_bytes().to_vec(), + ); +} + +#[test] +fn ledger_drop_removes_the_watch_and_its_gates() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + Gates::new(&host).set_next_block(watch, 500).unwrap(); + + Retrier::new(&host) + .apply(watch, RetryAction::Drop, &tick_at(100, 1_000)) + .unwrap(); + + assert!(host.store.is_empty(), "watch and gates must go"); +} + +#[test] +fn ledger_drop_on_repeat_grants_one_next_block_retry() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let ledger = Retrier::new(&host); + + // First refusal: the block is recorded and the watch gates to the + // next block instead of dropping. + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) + .unwrap(); + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key), "first refusal keeps the watch"); + assert_eq!( + snapshot.get(&watch.refused_key()).unwrap(), + &100_u64.to_le_bytes().to_vec(), + ); + assert_eq!( + snapshot.get(&watch.next_block_key()).unwrap(), + &101_u64.to_le_bytes().to_vec(), + ); + + // A repeat at the same block leaves the store untouched. + let before = host.store.snapshot(); + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) + .unwrap(); + assert_eq!(host.store.snapshot(), before); + + // A repeat on a later block removes the watch and every derived key. + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(101, 1_012)) + .unwrap(); + assert!(host.store.is_empty(), "watch, gates, and marker must go"); +} + +#[test] +fn ledger_clear_refusal_resets_the_one_block_grace() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let ledger = Retrier::new(&host); + + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) + .unwrap(); + ledger.clear_refusal(watch).unwrap(); + assert!(!host.store.snapshot().contains_key(&watch.refused_key())); + + // A refusal at a later block after the clear is a fresh first + // refusal: the watch survives and the marker records the new block. + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(105, 1_060)) + .unwrap(); + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key), "the watch must survive"); + assert_eq!( + snapshot.get(&watch.refused_key()).unwrap(), + &105_u64.to_le_bytes().to_vec(), + ); + assert_eq!( + snapshot.get(&watch.next_block_key()).unwrap(), + &106_u64.to_le_bytes().to_vec(), + ); +} + +#[test] +fn ledger_drop_removes_the_refused_marker() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + let ledger = Retrier::new(&host); + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) + .unwrap(); + ledger + .apply(watch, RetryAction::Drop, &tick_at(100, 1_000)) + .unwrap(); + + assert!( + !host + .store + .snapshot() + .keys() + .any(|k| k.starts_with(REFUSED_PREFIX)), + "drop must not orphan the refusal marker", + ); +} + +#[test] +fn retry_action_labels_are_stable_snake_case() { + let cases: [(RetryAction, &str); 4] = [ + (RetryAction::TryNextBlock, "try_next_block"), + (RetryAction::Backoff { seconds: 1 }, "backoff"), + (RetryAction::DropOnRepeat, "drop_on_repeat"), + (RetryAction::Drop, "drop"), + ]; + for (action, label) in cases { + assert_eq!(<&'static str>::from(action), label); + } +} + +/// The keeper passes stored params and the judged tick verbatim. +#[test] +fn poller_sees_params_and_tick_verbatim() { + struct EchoSource; + impl Poller for EchoSource { + type Outcome = (usize, u64, u64, u64, String); + fn poll( + &self, + _host: &H, + watch: WatchRef<'_>, + params: &[u8], + tick: &Tick, + ) -> Self::Outcome { + ( + params.len(), + tick.chain_id, + tick.block, + tick.epoch_s, + watch.key(), + ) + } + } + + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let tick = Tick { + chain_id: 1, + block: 42, + epoch_s: 1_700_000_000, + }; + + let (len, chain_id, block, epoch_s, echoed) = EchoSource.poll(&host, watch, b"params", &tick); + assert_eq!(len, b"params".len()); + assert_eq!(chain_id, 1); + assert_eq!(block, 42); + assert_eq!(epoch_s, 1_700_000_000); + assert_eq!(echoed, key); +} + +/// Drive a guard future on the test's synchronous boundary. +fn drive(future: F) -> F::Output { + let mut future = std::pin::pin!(future); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + match future.as_mut().poll(&mut cx) { + std::task::Poll::Ready(output) => output, + std::task::Poll::Pending => panic!("guard futures complete in one poll"), + } +} + +#[test] +fn guard_reserves_before_the_effect_and_commits_after() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + let out = drive(journal.guard("uid", b"body", || async { + // The reservation is durable before the effect runs. + assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Reserved)); + (Disposition::Commit, 7u32) + })) + .unwrap(); + + assert_eq!(out, Guarded::Ran(7)); + assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Committed)); + assert!(journal.pending().unwrap().is_empty()); +} + +#[test] +fn guard_skips_a_committed_marker_without_running_the_closure() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + journal.commit("uid").unwrap(); + + let ran = std::cell::Cell::new(false); + let out = drive(journal.guard("uid", b"body", || async { + ran.set(true); + (Disposition::Commit, ()) + })) + .unwrap(); + + assert_eq!(out, Guarded::Skipped(Mark::Committed)); + assert!(!ran.get(), "a COMMITTED marker must not re-run the effect"); +} + +#[test] +fn guard_skips_a_reserved_marker_for_reconcile() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + journal.reserve("uid", b"body").unwrap(); + + let ran = std::cell::Cell::new(false); + let out = drive(journal.guard("uid", b"body", || async { + ran.set(true); + (Disposition::Commit, ()) + })) + .unwrap(); + + assert_eq!(out, Guarded::Skipped(Mark::Reserved)); + assert!(!ran.get(), "a RESERVED marker is owned by reconcile"); + assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Reserved)); +} + +#[test] +fn guard_releases_on_a_known_non_accept() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + let out = + drive(journal.guard("uid", b"body", || async { (Disposition::Release, ()) })).unwrap(); + + assert_eq!(out, Guarded::Ran(())); + assert_eq!(journal.mark("uid").unwrap(), None); + assert!(journal.pending().unwrap().is_empty()); +} + +#[test] +fn guard_parks_a_retryable_outcome_with_its_body() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + let out = drive(journal.guard("uid", b"body", || async { + (Disposition::Park { until: 1_234 }, ()) + })) + .unwrap(); + + assert_eq!(out, Guarded::Ran(())); + assert_eq!( + journal.pending().unwrap(), + vec![Reservation { + key: "uid".into(), + next_eligible: 1_234, + body: b"body".to_vec(), + }], + ); +} + +#[test] +fn guard_retains_an_unknown_outcome_reserved() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + let out = drive(journal.guard("uid", b"body", || async { (Disposition::Retain, ()) })).unwrap(); + + assert_eq!(out, Guarded::Ran(())); + assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Reserved)); + assert_eq!( + journal.pending().unwrap(), + vec![Reservation { + key: "uid".into(), + next_eligible: 0, + body: b"body".to_vec(), + }], + "the body must stay enumerable for reconcile", + ); +} + +#[test] +fn guard_propagates_a_reserve_fault_without_running_the_closure() { + let host = MockHost::new(); + host.store + .fail_on("submitted:", Fault::Unavailable("store down".into())); + let journal = Journal::submitted(&host); + + let ran = std::cell::Cell::new(false); + let out = drive(journal.guard("uid", b"body", || async { + ran.set(true); + (Disposition::Commit, ()) + })); + + assert!(out.is_err(), "a reserve fault must abort the guard"); + assert!(!ran.get(), "no effect may run unreserved"); +} diff --git a/nexum/crates/nexum-sdk/tests/store.rs b/nexum/crates/nexum-sdk/tests/store.rs new file mode 100644 index 00000000..851b3b2f --- /dev/null +++ b/nexum/crates/nexum-sdk/tests/store.rs @@ -0,0 +1,135 @@ +//! Store-helper acceptance tests over the world-neutral mock local +//! store, which exercises the trait's default per-op `apply` fallback. + +use borsh::{BorshDeserialize, BorshSerialize}; +use nexum_sdk::host::{Fault, LocalStoreHost}; +use nexum_sdk::store::{Counter, TypedCell, TypedMap, WriteBatch, clear_prefix}; +use nexum_sdk_test::MockLocalStore; + +#[derive(BorshSerialize, BorshDeserialize, Debug, Eq, PartialEq)] +struct Row { + label: String, + size: u64, +} + +fn row(label: &str, size: u64) -> Row { + Row { + label: label.to_owned(), + size, + } +} + +#[test] +fn write_batch_flushes_staged_sets_and_deletes() { + let store = MockLocalStore::default(); + store.set("stale", b"x").unwrap(); + + let mut batch = WriteBatch::new(&store); + batch.set("a", b"1".to_vec()).set("b", b"2".to_vec()); + batch.delete("stale"); + assert_eq!(batch.len(), 3); + assert!(!batch.is_empty()); + batch.flush().unwrap(); + + assert_eq!(store.get("a").unwrap(), Some(b"1".to_vec())); + assert_eq!(store.get("b").unwrap(), Some(b"2".to_vec())); + assert_eq!(store.get("stale").unwrap(), None); +} + +#[test] +fn write_batch_dropped_before_flush_writes_nothing() { + let store = MockLocalStore::default(); + let mut batch = WriteBatch::new(&store); + batch.set("a", b"1".to_vec()); + drop(batch); + assert!(store.is_empty()); +} + +#[test] +fn write_batch_empty_flush_is_a_no_op() { + let store = MockLocalStore::default(); + WriteBatch::new(&store).flush().unwrap(); + assert!(store.is_empty()); +} + +#[test] +fn default_apply_fallback_is_per_op_so_a_mid_batch_fault_leaves_earlier_ops() { + let store = MockLocalStore::default(); + store.fail_on("poison", Fault::Internal("injected".into())); + + let mut batch = WriteBatch::new(&store); + batch.set("a", b"1".to_vec()); + batch.set("poison", b"2".to_vec()); + batch.set("z", b"3".to_vec()); + batch.flush().unwrap_err(); + + // The mock exercises the trait default, so the pre-fault op landed + // and the post-fault op did not. + assert_eq!(store.get("a").unwrap(), Some(b"1".to_vec())); + assert_eq!(store.get("z").unwrap(), None); +} + +#[test] +fn clear_prefix_deletes_only_the_prefix_and_counts() { + let store = MockLocalStore::default(); + store.set("watch:a", b"1").unwrap(); + store.set("watch:b", b"2").unwrap(); + store.set("gate:a", b"3").unwrap(); + + assert_eq!(clear_prefix(&store, "watch:").unwrap(), 2); + assert_eq!(store.len(), 1); + assert_eq!(store.get("gate:a").unwrap(), Some(b"3".to_vec())); + assert_eq!(clear_prefix(&store, "watch:").unwrap(), 0); +} + +#[test] +fn typed_cell_round_trips_and_clears() { + let store = MockLocalStore::default(); + let cell: TypedCell<'_, _, Row> = TypedCell::new(&store, "cursor"); + + assert_eq!(cell.get().unwrap(), None); + cell.set(&row("head", 7)).unwrap(); + assert_eq!(cell.get().unwrap(), Some(row("head", 7))); + cell.clear().unwrap(); + assert_eq!(cell.get().unwrap(), None); +} + +#[test] +fn typed_cell_folds_a_corrupt_value_to_internal() { + let store = MockLocalStore::default(); + store.set("cursor", b"not borsh").unwrap(); + let cell: TypedCell<'_, _, Row> = TypedCell::new(&store, "cursor"); + assert!(matches!(cell.get().unwrap_err(), Fault::Internal(_))); +} + +#[test] +fn typed_map_inserts_gets_removes_and_strips_prefix_from_keys() { + let store = MockLocalStore::default(); + store.set("other", b"x").unwrap(); + let map: TypedMap<'_, _, Row> = TypedMap::new(&store, "order:"); + + assert_eq!(map.keys().unwrap(), Vec::::new()); + map.insert("a", &row("first", 1)).unwrap(); + map.insert("b", &row("second", 2)).unwrap(); + assert_eq!(map.get("a").unwrap(), Some(row("first", 1))); + assert_eq!(map.get("missing").unwrap(), None); + assert_eq!(map.keys().unwrap(), vec!["a".to_owned(), "b".to_owned()]); + + map.remove("a").unwrap(); + assert_eq!(map.get("a").unwrap(), None); + assert_eq!(map.clear().unwrap(), 1); + assert!(map.keys().unwrap().is_empty()); + assert_eq!(store.get("other").unwrap(), Some(b"x".to_vec())); +} + +#[test] +fn counter_defaults_to_zero_adds_and_saturates() { + let store = MockLocalStore::default(); + let counter = Counter::new(&store, "submitted"); + + assert_eq!(counter.get().unwrap(), 0); + assert_eq!(counter.add(2).unwrap(), 2); + assert_eq!(counter.add(3).unwrap(), 5); + counter.set(u64::MAX).unwrap(); + assert_eq!(counter.add(1).unwrap(), u64::MAX); +} diff --git a/crates/nexum-tasks/Cargo.toml b/nexum/crates/nexum-tasks/Cargo.toml similarity index 100% rename from crates/nexum-tasks/Cargo.toml rename to nexum/crates/nexum-tasks/Cargo.toml diff --git a/crates/nexum-tasks/src/lib.rs b/nexum/crates/nexum-tasks/src/lib.rs similarity index 66% rename from crates/nexum-tasks/src/lib.rs rename to nexum/crates/nexum-tasks/src/lib.rs index 81e5b3d0..81eb72c2 100644 --- a/crates/nexum-tasks/src/lib.rs +++ b/nexum/crates/nexum-tasks/src/lib.rs @@ -1,9 +1,7 @@ //! Task lifecycle and graceful shutdown: every runtime task is spawned //! through a [`TaskExecutor`] minted by a [`TaskManager`], which owns the -//! shutdown signal and the bounded drain. -//! -//! This crate is the only place a raw `tokio` spawn appears; consumers -//! route every task through the executor so shutdown reaches all of them. +//! shutdown signal and the bounded drain. The only crate a raw `tokio` +//! spawn appears in, so shutdown reaches every task. mod manager; mod shutdown; diff --git a/crates/nexum-tasks/src/manager.rs b/nexum/crates/nexum-tasks/src/manager.rs similarity index 100% rename from crates/nexum-tasks/src/manager.rs rename to nexum/crates/nexum-tasks/src/manager.rs diff --git a/crates/nexum-tasks/src/shutdown.rs b/nexum/crates/nexum-tasks/src/shutdown.rs similarity index 100% rename from crates/nexum-tasks/src/shutdown.rs rename to nexum/crates/nexum-tasks/src/shutdown.rs diff --git a/crates/nexum-tasks/src/task.rs b/nexum/crates/nexum-tasks/src/task.rs similarity index 100% rename from crates/nexum-tasks/src/task.rs rename to nexum/crates/nexum-tasks/src/task.rs diff --git a/nexum/crates/nexum-world/Cargo.toml b/nexum/crates/nexum-world/Cargo.toml new file mode 100644 index 00000000..8b33d24b --- /dev/null +++ b/nexum/crates/nexum-world/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "nexum-world" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Per-module WIT world synthesis: the core capability table, registry-driven extension rows, manifest parsing, and crate-local WIT package resolution." + +[lints] +workspace = true + +[features] +# The syn-typed helper (`is_plain_type`), for the proc-macro crates only: +# non-macro consumers take nexum-world without `syn`. +macros = ["dep:syn"] + +[dependencies] +# Derives the closed capability / fault-label vocabularies: `VariantNames` +# supersedes a hand-maintained list, `EnumString` parses fail-closed. +strum.workspace = true +syn = { workspace = true, optional = true } +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/nexum/crates/nexum-world/src/lib.rs b/nexum/crates/nexum-world/src/lib.rs new file mode 100644 index 00000000..f8595f9b --- /dev/null +++ b/nexum/crates/nexum-world/src/lib.rs @@ -0,0 +1,917 @@ +//! Per-module world synthesis: turn a manifest's `[capabilities]` +//! declarations into an inline WIT world whose imports are exactly the +//! declared capability interfaces. +//! +//! Invariant: the capability rows must agree with the runtime's +//! capability registry on both names and WIT interfaces, since the +//! runtime cross-checks a component's imports against the manifest at +//! load time. [`CORE`] carries only the core `nexum:host` rows; +//! per-namespace rows come from a composition root's `extensions.toml` +//! ([`manifest_extensions`]) and are passed to [`synthesize`]. + +use std::path::{Path, PathBuf}; +use strum::{EnumString, IntoStaticStr, VariantNames}; + +/// A core capability name; the single source [`CORE`] and the runtime's +/// capability registry emit from. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, EnumString, VariantNames)] +#[strum(serialize_all = "kebab-case")] +#[non_exhaustive] +pub enum Cap { + /// `nexum:host/chain`. + Chain, + /// `nexum:host/identity`. + Identity, + /// `nexum:host/local-store`. + LocalStore, + /// `nexum:host/remote-store`. + RemoteStore, + /// `nexum:host/messaging`. + Messaging, + /// `nexum:host/logging`. + Logging, + /// Gates `wasi:http/*`; no world import. + Http, +} + +impl Cap { + /// The declared name, as a manifest spells it. Const so [`CORE`] and + /// [`CORE_IFACES`] can evaluate it. + pub const fn as_str(self) -> &'static str { + match self { + Self::Chain => "chain", + Self::Identity => "identity", + Self::LocalStore => "local-store", + Self::RemoteStore => "remote-store", + Self::Messaging => "messaging", + Self::Logging => "logging", + Self::Http => "http", + } + } +} + +/// A `nexum:host/types.fault` case as a stable snake_case label, in WIT +/// declaration order; the single source every label mirror emits from. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, EnumString, IntoStaticStr, VariantNames)] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum FaultLabel { + /// `fault.unsupported`. + Unsupported, + /// `fault.unavailable`. + Unavailable, + /// `fault.denied`. + Denied, + /// `fault.rate-limited`. + RateLimited, + /// `fault.timeout`. + Timeout, + /// `fault.invalid-input`. + InvalidInput, + /// `fault.internal`. + Internal, +} + +/// The permitted JSON-RPC read surface as a closed type; the single +/// source the guest allowlist and host dispatch table emit from. +/// Signing and state-mutating methods have no variant, so cannot cross +/// the WIT edge. This is the structural ceiling; an operator allowlist +/// narrows within it, never widens it. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, EnumString, IntoStaticStr)] +#[non_exhaustive] +pub enum ChainMethod { + /// `eth_blockNumber`. + #[strum(serialize = "eth_blockNumber")] + EthBlockNumber, + /// `eth_call`. + #[strum(serialize = "eth_call")] + EthCall, + /// `eth_chainId`. + #[strum(serialize = "eth_chainId")] + EthChainId, + /// `eth_estimateGas`. + #[strum(serialize = "eth_estimateGas")] + EthEstimateGas, + /// `eth_feeHistory`. + #[strum(serialize = "eth_feeHistory")] + EthFeeHistory, + /// `eth_gasPrice`. + #[strum(serialize = "eth_gasPrice")] + EthGasPrice, + /// `eth_maxPriorityFeePerGas`. + #[strum(serialize = "eth_maxPriorityFeePerGas")] + EthMaxPriorityFeePerGas, + /// `eth_getBalance`. + #[strum(serialize = "eth_getBalance")] + EthGetBalance, + /// `eth_getBlockByHash`. + #[strum(serialize = "eth_getBlockByHash")] + EthGetBlockByHash, + /// `eth_getBlockByNumber`. + #[strum(serialize = "eth_getBlockByNumber")] + EthGetBlockByNumber, + /// `eth_getBlockReceipts`. + #[strum(serialize = "eth_getBlockReceipts")] + EthGetBlockReceipts, + /// `eth_getCode`. + #[strum(serialize = "eth_getCode")] + EthGetCode, + /// `eth_getLogs`. + #[strum(serialize = "eth_getLogs")] + EthGetLogs, + /// `eth_getProof`. + #[strum(serialize = "eth_getProof")] + EthGetProof, + /// `eth_getStorageAt`. + #[strum(serialize = "eth_getStorageAt")] + EthGetStorageAt, + /// `eth_getTransactionByHash`. + #[strum(serialize = "eth_getTransactionByHash")] + EthGetTransactionByHash, + /// `eth_getTransactionCount`. + #[strum(serialize = "eth_getTransactionCount")] + EthGetTransactionCount, + /// `eth_getTransactionReceipt`. + #[strum(serialize = "eth_getTransactionReceipt")] + EthGetTransactionReceipt, + /// `net_version`. + #[strum(serialize = "net_version")] + NetVersion, +} + +impl ChainMethod { + /// The wire method name. + pub fn as_str(self) -> &'static str { + self.into() + } +} + +/// One manifest capability and its world wiring. +pub struct Capability { + /// The name declared under `[capabilities].required` / `optional`. + pub name: Cap, + /// The WIT import the declaration turns into; `None` for a + /// capability with no world import (`http`). + pub import: Option<&'static str>, + /// WIT package directories the import needs on the resolve path, + /// beyond `nexum-host`. + pub packages: &'static [&'static str], + /// The `bind_host_via_wit_bindgen!` capability ident for this + /// capability's host-adapter pieces, if it has a trait seam. + pub adapter: Option<&'static str>, +} + +/// The core capability rows, in emission order. Mirrors the runtime's +/// core registry and nothing else; extension rows are the caller's. +pub const CORE: &[Capability] = &[ + Capability { + name: Cap::Chain, + import: Some("nexum:host/chain@0.1.0"), + packages: &[], + adapter: Some("chain"), + }, + Capability { + name: Cap::Identity, + import: Some("nexum:host/identity@0.1.0"), + packages: &[], + adapter: Some("identity"), + }, + Capability { + name: Cap::LocalStore, + import: Some("nexum:host/local-store@0.1.0"), + packages: &[], + adapter: Some("local_store"), + }, + Capability { + name: Cap::RemoteStore, + import: Some("nexum:host/remote-store@0.1.0"), + packages: &[], + adapter: Some("remote_store"), + }, + Capability { + name: Cap::Messaging, + import: Some("nexum:host/messaging@0.1.0"), + packages: &[], + adapter: Some("messaging"), + }, + Capability { + name: Cap::Logging, + import: Some("nexum:host/logging@0.1.0"), + packages: &[], + adapter: Some("logging"), + }, + Capability { + name: Cap::Http, + import: None, + packages: &[], + adapter: None, + }, +]; + +/// Number of import-bearing [`CORE`] rows. +const fn core_iface_count() -> usize { + let mut n = 0; + let mut i = 0; + while i < CORE.len() { + if CORE[i].import.is_some() { + n += 1; + } + i += 1; + } + n +} + +/// Names of the import-bearing [`CORE`] rows, in emission order; the +/// `nexum:host` interface set the runtime enforces. `http` is absent. +pub const CORE_IFACES: [&str; core_iface_count()] = { + let mut out = [""; core_iface_count()]; + let mut n = 0; + let mut i = 0; + while i < CORE.len() { + if CORE[i].import.is_some() { + out[n] = CORE[i].name.as_str(); + n += 1; + } + i += 1; + } + out +}; + +/// One registered extension row: a per-namespace capability declared in +/// a composition root's `extensions.toml`. Always has a WIT import, +/// never an adapter ident (adapter seams are core-only). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionRow { + /// The name modules declare under `[capabilities]`. + pub name: String, + /// The WIT import the declaration turns into. + pub import: String, + /// WIT package directories the import needs on the resolve path, + /// beyond `nexum-host`, in dependency order. + pub packages: Vec, +} + +/// The synthesized world plus what the `generate!` call and the host +/// adapter need to go with it. +#[derive(Debug)] +pub struct ModuleWorld { + /// Inline WIT text defining `nexum:module-world/module`. + pub wit: String, + /// WIT package directories the resolve path must carry, in + /// dependency order (a package precedes its dependants). + pub packages: Vec, + /// Capability idents to pass to `bind_host_via_wit_bindgen!`. + pub adapters: Vec<&'static str>, +} + +/// The declared capability names (`required` then `optional`) from the +/// manifest text. A missing or malformed `[capabilities]` section is an +/// error. +pub fn manifest_capabilities(text: &str) -> Result, String> { + let value: toml::Table = text + .parse() + .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; + let caps = value.get("capabilities").ok_or_else(|| { + "module.toml has no [capabilities] section; the module/adapter macro derives the \ + component's WIT world from [capabilities].required/optional, so declare it (an empty \ + `required = []` is valid)" + .to_string() + })?; + let list = |key: &str| -> Result, String> { + match caps.get(key) { + None => Ok(Vec::new()), + Some(v) => v + .as_array() + .ok_or_else(|| format!("[capabilities].{key} must be an array of strings"))? + .iter() + .map(|item| { + item.as_str() + .map(str::to_owned) + .ok_or_else(|| format!("[capabilities].{key} must contain only strings")) + }) + .collect(), + } + }; + let mut names = list("required")?; + names.extend(list("optional")?); + Ok(names) +} + +/// The declared `[module] name` from the manifest text, the id the +/// module registers under. Absent or non-string is an error. +pub fn manifest_name(text: &str) -> Result { + let value: toml::Table = text + .parse() + .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; + value + .get("module") + .and_then(|module| module.get("name")) + .ok_or_else(|| "[module].name is missing".to_string())? + .as_str() + .map(str::to_owned) + .ok_or_else(|| "[module].name must be a string".to_string()) +} + +/// The declared `[module] kind` from the manifest text; `None` when +/// absent (the runtime defaults it to the worker). +pub fn manifest_kind(text: &str) -> Result, String> { + let value: toml::Table = text + .parse() + .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; + match value.get("module").and_then(|module| module.get("kind")) { + None => Ok(None), + Some(kind) => kind + .as_str() + .map(|kind| Some(kind.to_owned())) + .ok_or_else(|| "[module].kind must be a string".to_string()), + } +} + +/// The registered extension rows from an `extensions.toml`. Each +/// `[extensions.]` table carries a WIT `import` and the extra +/// `packages` its resolve path needs. No `[extensions]` section +/// registers nothing. +pub fn manifest_extensions(text: &str) -> Result, String> { + let value: toml::Table = text + .parse() + .map_err(|e| format!("extensions.toml is not valid TOML: {e}"))?; + let Some(extensions) = value.get("extensions") else { + return Ok(Vec::new()); + }; + let extensions = extensions + .as_table() + .ok_or_else(|| "[extensions] must be a table of `[extensions.]` rows".to_string())?; + extensions + .iter() + .map(|(name, row)| { + let row = row + .as_table() + .ok_or_else(|| format!("[extensions.{name}] must be a table"))?; + let import = row + .get("import") + .and_then(toml::Value::as_str) + .ok_or_else(|| format!("[extensions.{name}] must carry a string `import`"))? + .to_owned(); + let packages = match row.get("packages") { + None => Vec::new(), + Some(value) => value + .as_array() + .ok_or_else(|| { + format!("[extensions.{name}].packages must be an array of strings") + })? + .iter() + .map(|item| { + item.as_str().map(str::to_owned).ok_or_else(|| { + format!("[extensions.{name}].packages must contain only strings") + }) + }) + .collect::>()?, + }; + Ok(ExtensionRow { + name: name.clone(), + import, + packages, + }) + }) + .collect() +} + +/// The extension registry for a build rooted at `start`: the nearest +/// ancestor `extensions.toml`, or `None`. +pub fn find_extensions_manifest(start: &Path) -> Option { + let mut dir = Some(start); + while let Some(cur) = dir { + let candidate = cur.join("extensions.toml"); + if candidate.is_file() { + return Some(candidate); + } + dir = cur.parent(); + } + None +} + +/// The per-module world from the declared capability names (required +/// and optional alike; optional imports still resolve, the host stubs +/// them at load time). `extensions` rows emit after the core rows. An +/// unknown name is an error; an extension name that shadows a core row +/// or another registration is an error. +pub fn synthesize(declared: &[String], extensions: &[ExtensionRow]) -> Result { + for (idx, ext) in extensions.iter().enumerate() { + if CORE.iter().any(|c| c.name.as_str() == ext.name) + || extensions[..idx].iter().any(|prior| prior.name == ext.name) + { + return Err(format!( + "extension capability `{}` collides with an already-registered capability; \ + names must be unique across the core table and the registered extensions", + ext.name + )); + } + } + + let known = || { + CORE.iter() + .map(|c| c.name.as_str()) + .chain(extensions.iter().map(|e| e.name.as_str())) + }; + for name in declared { + if !known().any(|k| k == name.as_str()) { + let names = known().collect::>().join(", "); + return Err(format!( + "unknown capability `{name}` in module.toml [capabilities]; expected one of: \ + {names}" + )); + } + } + + let mut imports = String::new(); + // `nexum:host` is a leaf package (the `event` variant carries status + // transitions as opaque bytes), so the base resolve set + // is the host package alone; capability declarations append their + // own packages. Dependency order: each directory is parsed against + // the packages before it, so a package precedes its dependants. + let mut packages = vec!["nexum-host".to_owned()]; + let mut adapters = Vec::new(); + for cap in CORE { + if !declared.iter().any(|d| d == cap.name.as_str()) { + continue; + } + if let Some(import) = cap.import { + imports.push_str(&format!(" import {import};\n")); + } + for package in cap.packages { + if !packages.iter().any(|p| p == package) { + packages.push((*package).to_owned()); + } + } + if let Some(adapter) = cap.adapter { + adapters.push(adapter); + } + } + for ext in extensions { + if !declared.contains(&ext.name) { + continue; + } + imports.push_str(&format!(" import {};\n", ext.import)); + for package in &ext.packages { + if !packages.contains(package) { + packages.push(package.clone()); + } + } + } + + let mut wit = String::from( + "package nexum:module-world;\n\nworld module {\n \ + use nexum:host/types@0.1.0.{config, event, fault};\n\n", + ); + wit.push_str(&imports); + wit.push_str( + "\n export init: func(config: config) -> result<_, fault>;\n \ + export on-event: func(event: event) -> result<_, fault>;\n}\n", + ); + + Ok(ModuleWorld { + wit, + packages, + adapters, + }) +} + +/// Resolve each WIT package directory for a build rooted at `start`. +/// Crate-local `wit/deps/` before own `wit/`, else the +/// nearest ancestor `wit/` that carries it. +pub fn resolve_wit_packages>( + start: &Path, + packages: &[S], +) -> Result, String> { + packages + .iter() + .map(|package| { + let package = package.as_ref(); + resolve_wit_package(start, package).ok_or_else(|| { + format!( + "declared capabilities need the `{package}` WIT package, but neither \ + `wit/deps/{package}` nor `wit/{package}` exists under {} or any ancestor", + start.display() + ) + }) + }) + .collect() +} + +/// The consuming crate's manifest directory, the root every crate-local +/// lookup starts from. +pub fn manifest_dir() -> Result { + std::env::var("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string()) +} + +/// [`resolve_wit_packages`] rooted at [`manifest_dir`], as the strings +/// `wit_bindgen::generate!` takes for its `path`. +pub fn manifest_wit_packages>(packages: &[S]) -> Result, String> { + Ok(resolve_wit_packages(&manifest_dir()?, packages)? + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect()) +} + +/// Whether a type is a plain named path (`Foo`), the only shape a module +/// export type may take. +#[cfg(feature = "macros")] +pub fn is_plain_type(ty: &syn::Type) -> bool { + matches!(ty, syn::Type::Path(tp) if tp.qself.is_none()) +} + +/// Find one package directory: crate-local `wit/deps/` then +/// `wit/`, walking up on a miss. +fn resolve_wit_package(start: &Path, package: &str) -> Option { + let mut dir = Some(start); + while let Some(cur) = dir { + let wit = cur.join("wit"); + for candidate in [wit.join("deps").join(package), wit.join(package)] { + if candidate.is_dir() { + return Some(candidate); + } + } + dir = cur.parent(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The base package set every module world resolves against. + const MODULE_PACKAGES: [&str; 1] = ["nexum-host"]; + + /// A stand-in extension row, as a registered extension would pass. + fn ext() -> Vec { + vec![ExtensionRow { + name: "acme".to_owned(), + import: "acme:ext/api@0.1.0".to_owned(), + packages: vec!["acme-ext".to_owned()], + }] + } + + #[test] + fn logging_only_world_imports_logging_alone() { + let world = synthesize(&["logging".to_string()], &[]).unwrap(); + assert!(world.wit.contains("import nexum:host/logging@0.1.0;")); + assert!(!world.wit.contains("import nexum:host/chain")); + assert_eq!(world.packages, MODULE_PACKAGES); + assert_eq!(world.adapters, vec!["logging"]); + } + + #[test] + fn extension_row_emits_its_import_and_packages() { + let world = synthesize(&["logging".to_string(), "acme".to_string()], &ext()).unwrap(); + assert!(world.wit.contains("import acme:ext/api@0.1.0;")); + assert_eq!(world.packages, vec!["nexum-host", "acme-ext"]); + } + + #[test] + fn undeclared_extension_row_stays_out_of_the_world() { + let world = synthesize(&["logging".to_string()], &ext()).unwrap(); + assert!(!world.wit.contains("acme")); + assert_eq!(world.packages, MODULE_PACKAGES); + } + + #[test] + fn extension_shadowing_a_core_name_is_rejected() { + let rows = vec![ExtensionRow { + name: "chain".to_owned(), + import: "acme:ext/chain@0.1.0".to_owned(), + packages: Vec::new(), + }]; + let err = synthesize(&["chain".to_string()], &rows).unwrap_err(); + assert!(err.contains("extension capability `chain` collides")); + } + + #[test] + fn duplicate_extension_registration_is_rejected() { + let mut rows = ext(); + rows.extend(ext()); + let err = synthesize(&[], &rows).unwrap_err(); + assert!(err.contains("extension capability `acme` collides")); + } + + #[test] + fn core_ifaces_are_the_import_bearing_rows() { + assert_eq!( + CORE_IFACES, + [ + Cap::Chain.as_str(), + Cap::Identity.as_str(), + Cap::LocalStore.as_str(), + Cap::RemoteStore.as_str(), + Cap::Messaging.as_str(), + Cap::Logging.as_str(), + ], + ); + assert!(!CORE_IFACES.contains(&Cap::Http.as_str())); + } + + /// Pin the hand-written const accessor to the derived vocabulary. + #[test] + fn cap_accessor_agrees_with_the_derived_vocabulary() { + let names: Vec<&str> = CORE.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, Cap::VARIANTS); + for name in Cap::VARIANTS { + assert_eq!(name.parse::().unwrap().as_str(), *name); + } + } + + #[test] + fn fault_labels_are_snake_case_and_distinct() { + for label in FaultLabel::VARIANTS { + assert!(label.chars().all(|c| c.is_ascii_lowercase() || c == '_')); + } + let mut labels = FaultLabel::VARIANTS.to_vec(); + labels.sort_unstable(); + labels.dedup(); + assert_eq!(labels.len(), FaultLabel::VARIANTS.len()); + } + + #[test] + fn fault_label_parses_back_from_its_label() { + for label in FaultLabel::VARIANTS { + let parsed: FaultLabel = label.parse().unwrap(); + assert_eq!(<&'static str>::from(parsed), *label); + } + assert!("nonesuch".parse::().is_err()); + } + + #[test] + fn core_table_carries_no_extension_row() { + assert!( + CORE.iter() + .all(|c| c.import.is_none_or(|i| i.starts_with("nexum:host/"))) + ); + assert!(CORE.iter().all(|c| c.packages.is_empty())); + } + + #[test] + fn every_import_bearing_core_row_carries_an_adapter() { + // `http` has no world import (SDK wasi:http client) and no + // adapter; every other core row has both. + for cap in CORE { + assert_eq!( + cap.import.is_some(), + cap.adapter.is_some(), + "{}", + cap.name.as_str() + ); + } + } + + #[test] + fn full_declaration_emits_the_six_adapters_in_core_order() { + let declared: Vec = CORE.iter().map(|c| c.name.as_str().to_owned()).collect(); + let world = synthesize(&declared, &[]).unwrap(); + assert_eq!( + world.adapters, + vec![ + "chain", + "identity", + "local_store", + "remote_store", + "messaging", + "logging", + ], + ); + } + + #[test] + fn http_declares_no_world_import() { + let world = synthesize(&["logging".to_string(), "http".to_string()], &[]).unwrap(); + assert!(!world.wit.contains("wasi:http")); + assert_eq!(world.packages, MODULE_PACKAGES); + } + + #[test] + fn duplicate_declarations_emit_one_import() { + let world = synthesize(&["chain".to_string(), "chain".to_string()], &[]).unwrap(); + assert_eq!(world.wit.matches("import nexum:host/chain").count(), 1); + assert_eq!(world.adapters, vec!["chain"]); + } + + #[test] + fn unknown_capability_is_rejected_with_the_known_list() { + let err = synthesize(&["telepathy".to_string()], &ext()).unwrap_err(); + assert!(err.contains("unknown capability `telepathy`")); + assert!(err.contains("logging")); + assert!(err.contains("acme")); + } + + #[test] + fn manifest_extensions_reads_rows() { + let rows = manifest_extensions( + r#" +[extensions.acme] +import = "acme:ext/api@0.1.0" +packages = ["acme-base", "acme-ext"] + +[extensions.beta] +import = "beta:ext/api@0.1.0" +"#, + ) + .unwrap(); + assert_eq!(rows, { + let mut expected = ext(); + expected[0].packages = vec!["acme-base".to_owned(), "acme-ext".to_owned()]; + expected.push(ExtensionRow { + name: "beta".to_owned(), + import: "beta:ext/api@0.1.0".to_owned(), + packages: Vec::new(), + }); + expected + }); + } + + #[test] + fn manifest_without_extensions_section_registers_nothing() { + assert_eq!(manifest_extensions("").unwrap(), Vec::new()); + } + + #[test] + fn extension_row_without_an_import_is_an_error() { + let err = manifest_extensions("[extensions.acme]\npackages = []\n").unwrap_err(); + assert!(err.contains("[extensions.acme] must carry a string `import`")); + } + + #[test] + fn extension_row_with_non_string_package_is_an_error() { + let err = + manifest_extensions("[extensions.acme]\nimport = \"a:b/c@0.1.0\"\npackages = [1]\n") + .unwrap_err(); + assert!(err.contains("only strings")); + } + + #[test] + fn manifest_capabilities_reads_required_and_optional() { + let caps = manifest_capabilities( + r#" +[capabilities] +required = ["logging", "chain"] +optional = ["remote-store"] + +[capabilities.http] +allow = [] +"#, + ) + .unwrap(); + assert_eq!(caps, vec!["logging", "chain", "remote-store"]); + } + + #[test] + fn manifest_kind_reads_the_module_kind() { + let kind = manifest_kind("[module]\nname = \"x\"\nkind = \"venue-adapter\"\n").unwrap(); + assert_eq!(kind.as_deref(), Some("venue-adapter")); + } + + #[test] + fn manifest_without_a_kind_is_none() { + assert_eq!(manifest_kind("[module]\nname = \"x\"\n").unwrap(), None); + assert_eq!(manifest_kind("").unwrap(), None); + } + + #[test] + fn manifest_with_a_non_string_kind_is_an_error() { + let err = manifest_kind("[module]\nkind = 3\n").unwrap_err(); + assert!(err.contains("[module].kind must be a string")); + } + + #[test] + fn manifest_without_capabilities_section_is_an_error() { + let err = manifest_capabilities("[module]\nname = \"x\"\n").unwrap_err(); + assert!(err.contains("[capabilities]")); + } + + #[test] + fn manifest_with_non_string_capability_is_an_error() { + let err = manifest_capabilities("[capabilities]\nrequired = [1]\n").unwrap_err(); + assert!(err.contains("only strings")); + } + + #[test] + fn world_is_valid_wit_shape() { + // Not a full WIT parse (that is the module build's job); pin the + // structural pieces the runtime contract depends on. + let world = synthesize(&["logging".to_string()], &[]).unwrap(); + assert!(world.wit.starts_with("package nexum:module-world;")); + assert!(world.wit.contains("world module {")); + assert!( + world + .wit + .contains("export init: func(config: config) -> result<_, fault>;") + ); + assert!( + world + .wit + .contains("export on-event: func(event: event) -> result<_, fault>;") + ); + } + + #[test] + fn resolution_prefers_vendored_deps_over_own_wit() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("wit/deps/pkg")).unwrap(); + std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); + let paths = resolve_wit_packages(root, &["pkg"]).unwrap(); + assert_eq!(paths, vec![root.join("wit/deps/pkg")]); + } + + #[test] + fn resolution_falls_back_to_the_nearest_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); + let leaf = root.join("crates/leaf"); + std::fs::create_dir_all(&leaf).unwrap(); + let paths = resolve_wit_packages(&leaf, &["pkg"]).unwrap(); + assert_eq!(paths, vec![root.join("wit/pkg")]); + } + + #[test] + fn crate_local_package_shadows_the_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); + let leaf = root.join("crates/leaf"); + std::fs::create_dir_all(leaf.join("wit/deps/pkg")).unwrap(); + let paths = resolve_wit_packages(&leaf, &["pkg"]).unwrap(); + assert_eq!(paths, vec![leaf.join("wit/deps/pkg")]); + } + + #[test] + fn extension_registry_resolves_from_the_nearest_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("extensions.toml"), "").unwrap(); + let leaf = root.join("crates/leaf"); + std::fs::create_dir_all(&leaf).unwrap(); + assert_eq!( + find_extensions_manifest(&leaf), + Some(root.join("extensions.toml")) + ); + } + + #[test] + fn absent_extension_registry_is_none() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(find_extensions_manifest(dir.path()), None); + } + + #[test] + fn missing_package_names_the_paths_tried() { + let dir = tempfile::tempdir().unwrap(); + let err = resolve_wit_packages(dir.path(), &["pkg"]).unwrap_err(); + assert!(err.contains("`pkg` WIT package")); + assert!(err.contains("wit/deps/pkg")); + } + + #[test] + fn read_surface_methods_parse() { + for m in [ + "eth_call", + "eth_blockNumber", + "eth_getBalance", + "eth_getLogs", + "eth_getTransactionReceipt", + "net_version", + ] { + assert!(ChainMethod::try_from(m).is_ok(), "{m} should parse"); + } + } + + #[test] + fn signing_and_mutating_methods_have_no_variant() { + for m in [ + "eth_sign", + "eth_signTransaction", + "eth_sendTransaction", + "eth_sendRawTransaction", + "eth_accounts", + "personal_sign", + "personal_unlockAccount", + "admin_peers", + "debug_traceCall", + "miner_start", + "eth_notAMethod", + "", + ] { + assert!(ChainMethod::try_from(m).is_err(), "{m} must be rejected"); + } + } + + #[test] + fn as_str_round_trips_the_wire_name() { + assert_eq!(ChainMethod::EthCall.as_str(), "eth_call"); + assert_eq!( + ChainMethod::try_from(ChainMethod::EthGetBalance.as_str()), + Ok(ChainMethod::EthGetBalance), + ); + } +} diff --git a/modules/example/Cargo.toml b/nexum/modules/example/Cargo.toml similarity index 85% rename from modules/example/Cargo.toml rename to nexum/modules/example/Cargo.toml index c9a0ff70..19311814 100644 --- a/modules/example/Cargo.toml +++ b/nexum/modules/example/Cargo.toml @@ -12,4 +12,5 @@ workspace = true crate-type = ["cdylib"] [dependencies] +nexum-sdk = { path = "../../crates/nexum-sdk" } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/example/module.toml b/nexum/modules/example/module.toml similarity index 100% rename from modules/example/module.toml rename to nexum/modules/example/module.toml diff --git a/nexum/modules/example/src/lib.rs b/nexum/modules/example/src/lib.rs new file mode 100644 index 00000000..cc16f55d --- /dev/null +++ b/nexum/modules/example/src/lib.rs @@ -0,0 +1,78 @@ +//! # example (reference Shepherd module) +//! +//! Minimal reference module: one handler per event, each logging a +//! one-line summary. The smallest demonstration of +//! `#[nexum_sdk::module]`, which supplies the wit-bindgen call, host +//! adapter, dispatch, and `export!`. + +// wit_bindgen::generate! expands to host-import shims whose arity matches +// the WIT signatures, which can exceed clippy's too-many-arguments threshold. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +use nexum::host::{logging, types}; + +struct ExampleModule; + +#[nexum_sdk::module] +impl ExampleModule { + fn init(config: Vec<(String, String)>) -> Result<(), Fault> { + let name = config + .iter() + .find(|(k, _)| k == "name") + .map(|(_, v)| v.as_str()) + .unwrap_or("unknown"); + logging::log( + logging::Level::Info, + &format!("example module init (name={name})"), + ); + Ok(()) + } + + fn on_block(block: types::Block) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!( + "block {} on chain {} (ts={}ms)", + block.number, block.chain_id, block.timestamp + ), + ); + Ok(()) + } + + fn on_chain_logs(batch: types::ChainLogs) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!("received {} chain-log entries", batch.logs.len()), + ); + Ok(()) + } + + fn on_tick(tick: types::Tick) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!("tick fired at {}ms", tick.fired_at), + ); + Ok(()) + } + + fn on_message(msg: types::Message) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!("message on topic {}", msg.content_topic), + ); + Ok(()) + } + + fn on_custom(event: types::CustomEvent) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!( + "custom event kind {} ({} payload bytes)", + event.kind, + event.payload.len(), + ), + ); + Ok(()) + } +} diff --git a/modules/examples/balance-tracker/Cargo.toml b/nexum/modules/examples/balance-tracker/Cargo.toml similarity index 100% rename from modules/examples/balance-tracker/Cargo.toml rename to nexum/modules/examples/balance-tracker/Cargo.toml diff --git a/modules/examples/balance-tracker/module.toml b/nexum/modules/examples/balance-tracker/module.toml similarity index 100% rename from modules/examples/balance-tracker/module.toml rename to nexum/modules/examples/balance-tracker/module.toml diff --git a/nexum/modules/examples/balance-tracker/src/lib.rs b/nexum/modules/examples/balance-tracker/src/lib.rs new file mode 100644 index 00000000..766d52fa --- /dev/null +++ b/nexum/modules/examples/balance-tracker/src/lib.rs @@ -0,0 +1,45 @@ +//! # balance-tracker (example Shepherd module) +//! +//! On each block reads `eth_getBalance` for every `[config].addresses` +//! entry, persists the last value under `balance:{addr}`, and warns +//! when a balance moves more than `[config].change_threshold` wei. +//! Pure logic lives in `logic`; `lib.rs` is the +//! `#[nexum_sdk::module]` glue. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +mod logic; + +use std::sync::OnceLock; + +use nexum::host::types; + +static SETTINGS: OnceLock = OnceLock::new(); + +// `WitBindgenHost`, the fault `From` impls, and `install_tracing` (used +// by the handlers) are generated by the attribute alongside the +// wit-bindgen call and the `Guest`/`export!` glue. +struct BalanceTracker; + +#[nexum_sdk::module] +impl BalanceTracker { + fn init(config: Vec<(String, String)>) -> Result<(), Fault> { + install_tracing(); + let cfg = logic::parse_config(&config)?; + tracing::info!( + "balance-tracker init: {} addresses, threshold={} wei", + cfg.addresses.len(), + cfg.change_threshold, + ); + let _ = SETTINGS.set(cfg); + Ok(()) + } + + fn on_block(block: types::Block) -> Result<(), Fault> { + let Some(cfg) = SETTINGS.get() else { + return Ok(()); + }; + logic::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(Into::into) + } +} diff --git a/modules/examples/balance-tracker/src/strategy.rs b/nexum/modules/examples/balance-tracker/src/logic.rs similarity index 84% rename from modules/examples/balance-tracker/src/strategy.rs rename to nexum/modules/examples/balance-tracker/src/logic.rs index 91e25193..c475eec2 100644 --- a/modules/examples/balance-tracker/src/strategy.rs +++ b/nexum/modules/examples/balance-tracker/src/logic.rs @@ -1,41 +1,31 @@ -//! Pure strategy logic for the balance-tracker module. +//! Pure logic for the balance-tracker module. //! -//! Every interaction with the world flows through the [`Host`] trait -//! seam exposed by `nexum-sdk` - no direct calls to wit-bindgen- -//! generated free functions live here. The `lib.rs` glue wraps a -//! `WitBindgenHost` adapter around the module's per-cdylib wit-bindgen -//! imports and hands it to [`on_block`]; tests under `#[cfg(test)]` -//! hand the same function a `nexum_sdk_test::MockHost`. -//! -//! Aligns balance-tracker with the M3 "host trait + adapter" recipe -//! the other four modules already follow (PR #55 review). Previously -//! `on_event` here dispatched against wit-bindgen free functions -//! directly, which made `check_one` / `fetch_balance` only reachable -//! from a real WASM build and excluded MockHost coverage. +//! World access flows through the [`ChainHost`] + [`LocalStoreHost`] +//! trait seam; `lib.rs` hands [`on_block`] a `WitBindgenHost`, tests a +//! `nexum_sdk_test::MockHost`. use nexum_sdk::address::parse_address_list; use nexum_sdk::config::{self, ConfigError}; -use nexum_sdk::host::{Fault, Host}; +use nexum_sdk::host::{ChainHost, Fault, LocalStoreHost}; use nexum_sdk::prelude::{Address, U256}; -/// Resolved settings parsed from `[config]` at `init` and read on -/// every event. +/// Settings parsed from `[config]` at `init`. #[derive(Clone, Debug)] pub struct Settings { - /// 0x-prefixed addresses to track. + /// Addresses to track. pub addresses: Vec
, - /// Change threshold in wei; an alert fires when the delta exceeds - /// it. + /// Alert fires when a balance moves more than this many wei. pub change_threshold: U256, } -/// Entry point: poll every tracked address on a new block, log on -/// threshold-crossing diffs, persist the latest reading. -/// -/// Each address is independent; a single flaky `eth_getBalance` does -/// not abort the loop - the failure is logged and the next address is -/// still polled. -pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Result<(), Fault> { +/// Poll every tracked address on a new block, warn on threshold-crossing +/// diffs, persist the latest reading. A single flaky `eth_getBalance` is +/// logged and skipped, not fatal to the loop. +pub fn on_block( + host: &H, + chain_id: u64, + settings: &Settings, +) -> Result<(), Fault> { for addr in &settings.addresses { if let Err(err) = check_one(host, chain_id, *addr, settings.change_threshold) { tracing::warn!("balance-tracker {addr:#x}: {err}"); @@ -44,10 +34,9 @@ pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Result Ok(()) } -/// Poll one address: fetch latest balance, diff against the last -/// stored value, emit a log if the delta crosses `threshold`, then -/// persist the new value under `balance:{addr}`. -fn check_one( +/// Fetch one address's balance, warn if the diff against the stored +/// value crosses `threshold`, then persist it under `balance:{addr}`. +fn check_one( host: &H, chain_id: u64, addr: Address, @@ -74,7 +63,7 @@ fn check_one( } /// `chain::request("eth_getBalance", [addr, "latest"])` -> `U256`. -fn fetch_balance(host: &H, chain_id: u64, addr: Address) -> Result { +fn fetch_balance(host: &H, chain_id: u64, addr: Address) -> Result { let params = format!("[\"{addr:#x}\",\"latest\"]"); let result_json = host.request(chain_id, "eth_getBalance", ¶ms)?; parse_balance_hex(&result_json).ok_or_else(|| { @@ -84,10 +73,8 @@ fn fetch_balance(host: &H, chain_id: u64, addr: Address) -> Result Option { let trimmed = result_json.trim(); let body = trimmed @@ -149,14 +136,12 @@ fn config_err(e: ConfigError) -> Fault { mod tests { use super::*; use nexum_sdk::Level; - use nexum_sdk::host::{ChainError, Fault, LocalStoreHost as _}; + use nexum_sdk::host::{ChainError, Fault}; use nexum_sdk::prelude::address; use nexum_sdk_test::{MockHost, capture_tracing}; const SEPOLIA: u64 = 11_155_111; - // ---- pure helpers ---- - #[test] fn parse_balance_hex_decodes_canonical_response() { // 0x16345785d8a0000 = 100_000_000_000_000_000 = 0.1 ETH. @@ -240,8 +225,6 @@ mod tests { assert!(message.contains("change_threshold")); } - // ---- MockHost-driven coverage of check_one / fetch_balance ---- - fn one_addr_settings(threshold_wei: u128) -> Settings { Settings { addresses: vec![address!("70997970C51812dc3A010C7d01b50e0d17dc79C8")], diff --git a/modules/examples/http-probe/Cargo.toml b/nexum/modules/examples/http-probe/Cargo.toml similarity index 100% rename from modules/examples/http-probe/Cargo.toml rename to nexum/modules/examples/http-probe/Cargo.toml diff --git a/modules/examples/http-probe/module.toml b/nexum/modules/examples/http-probe/module.toml similarity index 100% rename from modules/examples/http-probe/module.toml rename to nexum/modules/examples/http-probe/module.toml diff --git a/nexum/modules/examples/http-probe/src/lib.rs b/nexum/modules/examples/http-probe/src/lib.rs new file mode 100644 index 00000000..7290114b --- /dev/null +++ b/nexum/modules/examples/http-probe/src/lib.rs @@ -0,0 +1,49 @@ +//! # http-probe (example Shepherd module) +//! +//! On each matching block fetches the allowlisted `[config].probe_url` +//! over wasi:http and logs its status, then fetches `[config].denied_url` +//! and asserts the `[capabilities.http].allow` gate denies it. +//! Demonstrates the SDK `http::Fetch` seam and the allowlist denial +//! path. Pure logic lives in `logic`; `lib.rs` is the +//! `#[nexum_sdk::module]` glue. + +// wit_bindgen::generate! expands to host-import shims whose arity matches +// the WIT signatures, which can exceed clippy's too-many-arguments threshold. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +mod logic; + +use std::sync::OnceLock; + +use nexum::host::types; + +static SETTINGS: OnceLock = OnceLock::new(); + +// The fault `From` impls and `install_tracing` (used by the handlers) are +// generated by the attribute alongside the wit-bindgen call and the +// `Guest`/`export!` glue. +struct HttpProbe; + +#[nexum_sdk::module] +impl HttpProbe { + fn init(config: Vec<(String, String)>) -> Result<(), Fault> { + install_tracing(); + let cfg = logic::parse_config(&config)?; + tracing::info!( + "http-probe init: probe_url={} denied_url={} every_n_blocks={}", + cfg.probe_url, + cfg.denied_url, + cfg.every_n_blocks, + ); + let _ = SETTINGS.set(cfg); + Ok(()) + } + + fn on_block(block: types::Block) -> Result<(), Fault> { + let Some(cfg) = SETTINGS.get() else { + return Ok(()); + }; + logic::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number).map_err(Into::into) + } +} diff --git a/modules/examples/http-probe/src/strategy.rs b/nexum/modules/examples/http-probe/src/logic.rs similarity index 85% rename from modules/examples/http-probe/src/strategy.rs rename to nexum/modules/examples/http-probe/src/logic.rs index 09eb6980..374c0178 100644 --- a/modules/examples/http-probe/src/strategy.rs +++ b/nexum/modules/examples/http-probe/src/logic.rs @@ -1,31 +1,25 @@ -//! Pure strategy logic for the http-probe module. +//! Pure logic for the http-probe module. //! -//! All HTTP flows through the [`Fetch`] seam and logging goes through -//! the `tracing` facade, so the whole strategy is unit-testable -//! host-free: tests hand [`on_block`] a stub fetcher and capture the -//! `tracing` output; the `lib.rs` glue hands it `nexum_sdk::http::WasiFetch`. +//! HTTP flows through the [`Fetch`] seam; `lib.rs` hands [`on_block`] +//! `nexum_sdk::http::WasiFetch`, tests hand it a stub fetcher. use nexum_sdk::config::{self, ConfigError}; use nexum_sdk::host::Fault; use nexum_sdk::http::{Fetch, FetchError}; -/// Resolved settings parsed from `[config]` at `init` and read on -/// every event. +/// Settings parsed from `[config]` at `init`. #[derive(Clone, Debug)] pub struct Settings { - /// URL fetched on every matching block; its host must be on the - /// module's allowlist. + /// Allowlisted URL fetched on every matching block. pub probe_url: String, - /// URL whose host is deliberately off-list; anything other than a - /// denial is a failure. + /// Off-list URL; anything other than a denial is a failure. pub denied_url: String, /// Only probe every Nth block. pub every_n_blocks: u64, } -/// Entry point: probe the allowlisted URL, then verify the off-list -/// URL is denied. Returns `Err` when either leg misbehaves so the -/// runtime records a fault for the dispatch. +/// Probe the allowlisted URL, then verify the off-list URL is denied. +/// `Err` when either leg misbehaves. pub fn on_block( fetcher: &F, settings: &Settings, @@ -38,8 +32,8 @@ pub fn on_block( probe_denied(fetcher, &settings.denied_url) } -/// Fetch the allowlisted URL and log its status; any fetch error is -/// surfaced as a fault for this dispatch. +/// Fetch the allowlisted URL and log its status; a fetch error is a +/// fault. fn probe_allowlisted(fetcher: &F, url: &str) -> Result<(), Fault> { let response = fetcher .fetch(get_request(url)?) @@ -52,8 +46,8 @@ fn probe_allowlisted(fetcher: &F, url: &str) -> Result<(), Fault> { Ok(()) } -/// Fetch the off-list URL and demand [`FetchError::Denied`]; a -/// response or any other error means the allowlist gate did not hold. +/// Fetch the off-list URL and demand [`FetchError::Denied`]; any other +/// outcome means the allowlist gate did not hold. fn probe_denied(fetcher: &F, url: &str) -> Result<(), Fault> { match fetcher.fetch(get_request(url)?) { Err(FetchError::Denied) => { @@ -70,23 +64,23 @@ fn probe_denied(fetcher: &F, url: &str) -> Result<(), Fault> { } } -/// Build a body-less GET for `url`; a malformed URL is a config error -/// surfaced as an invalid-input fault. +/// Body-less GET for `url`; a malformed URL is an invalid-input fault. fn get_request(url: &str) -> Result>, Fault> { http::Request::get(url) .body(Vec::new()) .map_err(|e| invalid_input(format!("probe url {url}: {e}"))) } -/// Lift a [`FetchError`] into a [`Fault`], preserving the -/// policy/timeout/input/transport distinction in the case. +/// Lift a [`FetchError`] into a [`Fault`], preserving the case. fn fetch_err(url: &str, error: &FetchError) -> Fault { let detail = format!("fetch {url}: {error}"); match error { FetchError::Denied => Fault::Denied(detail), FetchError::InvalidRequest(_) => Fault::InvalidInput(detail), FetchError::Timeout(_) => Fault::Timeout, - FetchError::Transport(_) => Fault::Unavailable(detail), + // `FetchError` is `#[non_exhaustive]`: a future case folds to + // retryable `unavailable` with its detail. + _ => Fault::Unavailable(detail), } } @@ -133,8 +127,7 @@ mod tests { use super::*; - /// Stub fetcher: replays canned outcomes in call order and records - /// the requested URLs. + /// Stub fetcher: replays canned outcomes in order, records URLs. struct StubFetch { outcomes: RefCell>, FetchError>>>, urls: RefCell>, diff --git a/modules/examples/price-alert/Cargo.toml b/nexum/modules/examples/price-alert/Cargo.toml similarity index 92% rename from modules/examples/price-alert/Cargo.toml rename to nexum/modules/examples/price-alert/Cargo.toml index 30bb5280..c31f9385 100644 --- a/modules/examples/price-alert/Cargo.toml +++ b/nexum/modules/examples/price-alert/Cargo.toml @@ -17,6 +17,6 @@ wit-bindgen = { version = "0.59", default-features = false, features = ["macros" [dev-dependencies] nexum-sdk-test = { path = "../../../crates/nexum-sdk-test" } -# Only used by tests in `strategy.rs` to encode a synthetic oracle +# Only used by tests in `logic.rs` to encode a synthetic oracle # return body; the production code uses `nexum_sdk::chain::chainlink`. alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } diff --git a/modules/examples/price-alert/module.toml b/nexum/modules/examples/price-alert/module.toml similarity index 100% rename from modules/examples/price-alert/module.toml rename to nexum/modules/examples/price-alert/module.toml diff --git a/nexum/modules/examples/price-alert/src/lib.rs b/nexum/modules/examples/price-alert/src/lib.rs new file mode 100644 index 00000000..a9d16b35 --- /dev/null +++ b/nexum/modules/examples/price-alert/src/lib.rs @@ -0,0 +1,49 @@ +//! # price-alert (example Shepherd module) +//! +//! Polls a Chainlink oracle on each block and warns when the price +//! crosses a `[config]`-supplied threshold on the configured side. +//! Demonstrates `chain::request` with an `alloy_sol_types` ABI decode +//! and the `nexum_sdk::chain` helpers. Pure logic lives in `logic`; +//! `lib.rs` is the `#[nexum_sdk::module]` glue. + +// wit_bindgen::generate! expands to host-import shims whose arity matches +// the WIT signatures, which can exceed clippy's too-many-arguments threshold. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +mod logic; + +use std::sync::OnceLock; + +use nexum::host::types; + +static SETTINGS: OnceLock = OnceLock::new(); + +// `WitBindgenHost`, the fault `From` impls, and `install_tracing` (used +// by the handlers) are generated by the attribute alongside the +// wit-bindgen call and the `Guest`/`export!` glue. +struct PriceAlert; + +#[nexum_sdk::module] +impl PriceAlert { + fn init(config: Vec<(String, String)>) -> Result<(), Fault> { + install_tracing(); + let cfg = logic::parse_config(&config)?; + tracing::info!( + "price-alert init: oracle={:#x} threshold={} direction={:?} every_n_blocks={}", + cfg.oracle_address, + cfg.threshold_scaled, + cfg.direction, + cfg.every_n_blocks, + ); + let _ = SETTINGS.set(cfg); + Ok(()) + } + + fn on_block(block: types::Block) -> Result<(), Fault> { + let Some(cfg) = SETTINGS.get() else { + return Ok(()); + }; + logic::on_block(&WitBindgenHost, block.chain_id, cfg, block.number).map_err(Into::into) + } +} diff --git a/modules/examples/price-alert/src/strategy.rs b/nexum/modules/examples/price-alert/src/logic.rs similarity index 86% rename from modules/examples/price-alert/src/strategy.rs rename to nexum/modules/examples/price-alert/src/logic.rs index 0dcccd95..3c5c0bcb 100644 --- a/modules/examples/price-alert/src/strategy.rs +++ b/nexum/modules/examples/price-alert/src/logic.rs @@ -1,20 +1,16 @@ -//! Pure strategy logic for the price-alert module. +//! Pure logic for the price-alert module. //! -//! Every interaction with the world flows through the [`Host`] trait -//! seam exposed by `nexum-sdk` - no direct calls to wit-bindgen- -//! generated free functions live here. The `lib.rs` glue wraps a -//! `WitBindgenHost` adapter around the module's per-cdylib wit-bindgen -//! imports and hands it to [`on_block`]; tests under `#[cfg(test)]` -//! hand the same function a `nexum_sdk_test::MockHost`. +//! World access flows through the [`ChainHost`] + [`LoggingHost`] trait +//! seam, the module's two declared capabilities; `lib.rs` hands +//! [`on_block`] a `WitBindgenHost`, tests a `nexum_sdk_test::MockHost`. use alloy_primitives::I256; use nexum_sdk::chain::chainlink::read_latest_answer; use nexum_sdk::config::{self, ConfigError}; -use nexum_sdk::host::{Fault, Host}; +use nexum_sdk::host::{ChainHost, Fault, LoggingHost}; use nexum_sdk::prelude::Address; -/// Resolved configuration, parsed from `module.toml::[config]` at -/// `init` and read on every `on_event`. +/// Configuration parsed from `[config]` at `init`. #[derive(Debug)] pub struct Settings { /// Chainlink AggregatorV3Interface address. @@ -37,14 +33,10 @@ pub enum Direction { Below, } -/// React to a new block. -/// -/// Returns `Ok(())` on success and on recoverable upstream failures -/// (oracle RPC error, decode failure) - the strategy logs a Warn and -/// lets the next block re-poll rather than propagating into the -/// supervisor. Only host-level I/O on the persistence side would -/// bubble up via `?`, and this module does not touch the store. -pub fn on_block( +/// Poll the oracle and warn on a threshold crossing. Recoverable +/// upstream failures (oracle RPC error, decode failure) log a Warn and +/// return `Ok` so the next block re-polls. +pub fn on_block( host: &H, chain_id: u64, settings: &Settings, @@ -77,7 +69,7 @@ pub fn on_block( } /// `true` when `answer` is on the firing side of `threshold` per -/// `direction`. Pure - exercised by the unit tests. +/// `direction`. pub fn classify(answer: I256, threshold: I256, direction: Direction) -> bool { match direction { Direction::Above => answer >= threshold, @@ -85,11 +77,7 @@ pub fn classify(answer: I256, threshold: I256, direction: Direction) -> bool { } } -/// Parse `module.toml::[config]` into a typed [`Settings`]. -/// -/// One-shot config-parser style: returns `Result` so the -/// `Guest::init` adapter can lower the failure into the wit-bindgen -/// `fault` with no extra plumbing. +/// Parse `[config]` into a typed [`Settings`]. pub fn parse_config(entries: &[(String, String)]) -> Result { let oracle_address = config::get_required(entries, "oracle_address") .map_err(config_err)? @@ -136,16 +124,12 @@ pub fn parse_config(entries: &[(String, String)]) -> Result { }) } -/// Lift a free-text invalid-config detail into a [`Fault::InvalidInput`]. -/// Used when the SDK helper does not own the error (e.g. an -/// `Address::from_str` failure). +/// Lift a free-text detail into a [`Fault::InvalidInput`]. fn invalid(message: impl Into) -> Fault { Fault::InvalidInput(message.into()) } -/// Project a `nexum_sdk::config::ConfigError` into a -/// [`Fault::InvalidInput`] via `Display`, preserving the detail at the -/// WIT boundary. +/// Project a [`ConfigError`] into a [`Fault::InvalidInput`]. fn config_err(e: ConfigError) -> Fault { invalid(e.to_string()) } @@ -172,8 +156,8 @@ mod tests { } } - /// Encode a `latestRoundData` return into the `"0x..."` JSON string - /// the host's `chain::request` would yield. + /// Encode a `latestRoundData` return as the `"0x..."` JSON string + /// `chain::request` yields. fn oracle_response_json(answer_scaled: i128) -> String { use alloy_primitives::aliases::U80; let returns = AggregatorV3::latestRoundDataReturn { @@ -194,8 +178,6 @@ mod tests { host.chain.respond_to("eth_call", ¶ms, response); } - // ---- pure helpers ---- - #[test] fn classify_below_fires_at_or_under_threshold() { let t = I256::try_from(100_i32).unwrap(); @@ -292,8 +274,6 @@ mod tests { assert!(message.contains("oracle_address")); } - // ---- strategy behaviour against MockHost ---- - #[test] fn on_block_idle_when_price_above_below_trigger() { let host = MockHost::new(); @@ -362,13 +342,13 @@ mod tests { Err(ChainError::Fault(Fault::Timeout)), ); - // Strategy returns Ok so the supervisor moves on. + // The module returns Ok so the supervisor moves on. let (result, logs) = capture_tracing(|| on_block(&host, 11_155_111, &settings, 100)); result.unwrap(); // The oracle-read failure is logged by the SDK chainlink helper // through the host logging call, so it lands on `host.logging`. assert!(host.logging.contains("eth_call failed")); - // No facade event at all: the strategy returns before emitting + // No facade event at all: the module returns before emitting // either the ok or TRIGGERED line. assert!(logs.is_empty()); } diff --git a/modules/fixtures/clock-reader/Cargo.toml b/nexum/modules/fixtures/clock-reader/Cargo.toml similarity index 100% rename from modules/fixtures/clock-reader/Cargo.toml rename to nexum/modules/fixtures/clock-reader/Cargo.toml diff --git a/modules/fixtures/clock-reader/module.toml b/nexum/modules/fixtures/clock-reader/module.toml similarity index 100% rename from modules/fixtures/clock-reader/module.toml rename to nexum/modules/fixtures/clock-reader/module.toml diff --git a/modules/fixtures/clock-reader/src/lib.rs b/nexum/modules/fixtures/clock-reader/src/lib.rs similarity index 65% rename from modules/fixtures/clock-reader/src/lib.rs rename to nexum/modules/fixtures/clock-reader/src/lib.rs index d2d7a54b..a79b3e1a 100644 --- a/modules/fixtures/clock-reader/src/lib.rs +++ b/nexum/modules/fixtures/clock-reader/src/lib.rs @@ -1,14 +1,10 @@ //! # clock-reader (test fixture) //! -//! On every event reads `std::time::SystemTime::now()` and logs the wall -//! time as whole seconds since the Unix epoch. Under `wasm32-wasip2` that -//! read routes to `wasi:clocks/wall-clock`, which the supervisor -//! virtualizes per store, so a test that boots this fixture under a pinned -//! clock override can assert from the log line that the guest observed the -//! overridden time rather than the ambient host clock. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only and never gets loaded by the testnet configs. +//! Logs `SystemTime::now()` as whole seconds since the epoch. Under +//! `wasm32-wasip2` the read routes to `wasi:clocks/wall-clock`, which +//! the supervisor virtualizes per store, so a test under a pinned clock +//! override can assert the guest observed the overridden time. +//! Test-only. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] @@ -16,8 +12,11 @@ use std::time::{SystemTime, UNIX_EPOCH}; wit_bindgen::generate!({ - path: "../../../wit/nexum-host", + path: [ + "../../../../wit/nexum-host", + ], world: "nexum:host/event-module", + generate_all, }); use nexum::host::{logging, types}; diff --git a/modules/fixtures/flaky-bomb/Cargo.toml b/nexum/modules/fixtures/flaky-bomb/Cargo.toml similarity index 100% rename from modules/fixtures/flaky-bomb/Cargo.toml rename to nexum/modules/fixtures/flaky-bomb/Cargo.toml diff --git a/modules/fixtures/flaky-bomb/module.toml b/nexum/modules/fixtures/flaky-bomb/module.toml similarity index 100% rename from modules/fixtures/flaky-bomb/module.toml rename to nexum/modules/fixtures/flaky-bomb/module.toml diff --git a/modules/fixtures/flaky-bomb/src/lib.rs b/nexum/modules/fixtures/flaky-bomb/src/lib.rs similarity index 74% rename from modules/fixtures/flaky-bomb/src/lib.rs rename to nexum/modules/fixtures/flaky-bomb/src/lib.rs index 63157a8b..91a82bbb 100644 --- a/modules/fixtures/flaky-bomb/src/lib.rs +++ b/nexum/modules/fixtures/flaky-bomb/src/lib.rs @@ -1,36 +1,26 @@ //! # flaky-bomb (test fixture) //! -//! Traps deterministically on the first N events and succeeds on -//! every subsequent event. Drives the supervisor's exponential- -//! backoff restart policy through its full lifecycle: -//! -//! 1. Dispatch 1: trap (failure_count = 1, next_attempt = +1s). -//! 2. (engine waits the backoff window) -//! 3. Dispatch 2 (eligible after 1s): trap again, failure_count = 2. -//! 4. ... -//! 5. Dispatch N+1: succeeds, failure_count resets to 0. -//! -//! N is config-supplied via `[config].fail_first_n`. The fixture -//! reads the value once during `init` into a `OnceLock` and keeps -//! a static `AtomicU32` counter across calls. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only. +//! Traps on the first `[config].fail_first_n` events and succeeds +//! after, driving the supervisor's exponential-backoff restart policy. +//! The attempt counter rides local-store so it survives restarts. +//! Test-only. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: "../../../wit/nexum-host", + path: [ + "../../../../wit/nexum-host", + ], world: "nexum:host/event-module", + generate_all, }); use std::sync::OnceLock; use nexum::host::{local_store, logging, types}; -/// Number of consecutive events to trap on. Set from `[config].fail_first_n` -/// at init; defaults to `1` (trap once, recover on second event). +/// Consecutive events to trap on, from `[config].fail_first_n`; default 1. static FAIL_FIRST_N: OnceLock = OnceLock::new(); const ATTEMPTS_KEY: &str = "attempts"; diff --git a/modules/fixtures/fuel-bomb/Cargo.toml b/nexum/modules/fixtures/fuel-bomb/Cargo.toml similarity index 100% rename from modules/fixtures/fuel-bomb/Cargo.toml rename to nexum/modules/fixtures/fuel-bomb/Cargo.toml diff --git a/modules/fixtures/fuel-bomb/module.toml b/nexum/modules/fixtures/fuel-bomb/module.toml similarity index 100% rename from modules/fixtures/fuel-bomb/module.toml rename to nexum/modules/fixtures/fuel-bomb/module.toml diff --git a/modules/fixtures/fuel-bomb/src/lib.rs b/nexum/modules/fixtures/fuel-bomb/src/lib.rs similarity index 71% rename from modules/fixtures/fuel-bomb/src/lib.rs rename to nexum/modules/fixtures/fuel-bomb/src/lib.rs index eb158a26..3fd1bfd8 100644 --- a/modules/fixtures/fuel-bomb/src/lib.rs +++ b/nexum/modules/fixtures/fuel-bomb/src/lib.rs @@ -1,20 +1,18 @@ //! # fuel-bomb (test fixture) //! -//! Deliberately exhausts the wasmtime fuel budget on every `on_event` -//! by running an unbounded counter loop. The wasmtime engine must -//! trap with `OutOfFuel`; the supervisor must catch the trap, mark -//! the module dead, and continue dispatching to other modules. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only and never gets loaded by the M2 / M3 testnet -//! configs. +//! Exhausts the fuel budget on every `on_event` via an unbounded loop. +//! The engine traps with `OutOfFuel`; the supervisor must catch it, +//! mark the module dead, and keep dispatching others. Test-only. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: "../../../wit/nexum-host", + path: [ + "../../../../wit/nexum-host", + ], world: "nexum:host/event-module", + generate_all, }); use nexum::host::{logging, types}; diff --git a/modules/fixtures/memory-bomb/Cargo.toml b/nexum/modules/fixtures/memory-bomb/Cargo.toml similarity index 100% rename from modules/fixtures/memory-bomb/Cargo.toml rename to nexum/modules/fixtures/memory-bomb/Cargo.toml diff --git a/modules/fixtures/memory-bomb/module.toml b/nexum/modules/fixtures/memory-bomb/module.toml similarity index 100% rename from modules/fixtures/memory-bomb/module.toml rename to nexum/modules/fixtures/memory-bomb/module.toml diff --git a/modules/fixtures/memory-bomb/src/lib.rs b/nexum/modules/fixtures/memory-bomb/src/lib.rs similarity index 75% rename from modules/fixtures/memory-bomb/src/lib.rs rename to nexum/modules/fixtures/memory-bomb/src/lib.rs index 5feeadc3..b09f54f0 100644 --- a/modules/fixtures/memory-bomb/src/lib.rs +++ b/nexum/modules/fixtures/memory-bomb/src/lib.rs @@ -1,19 +1,19 @@ //! # memory-bomb (test fixture) //! -//! Deliberately allocates past the default 64 MiB per-module memory -//! cap on every `on_event`. The wasmtime `StoreLimits` reject the -//! linear-memory grow, the host traps the module, the supervisor -//! marks it dead, and other modules keep dispatching. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only. +//! Allocates past the default 64 MiB per-module memory cap on every +//! `on_event`. The `StoreLimits` reject the grow, the module traps, the +//! supervisor marks it dead, and other modules keep dispatching. +//! Test-only. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: "../../../wit/nexum-host", + path: [ + "../../../../wit/nexum-host", + ], world: "nexum:host/event-module", + generate_all, }); use nexum::host::{logging, types}; diff --git a/modules/fixtures/panic-bomb/Cargo.toml b/nexum/modules/fixtures/panic-bomb/Cargo.toml similarity index 100% rename from modules/fixtures/panic-bomb/Cargo.toml rename to nexum/modules/fixtures/panic-bomb/Cargo.toml diff --git a/modules/fixtures/panic-bomb/module.toml b/nexum/modules/fixtures/panic-bomb/module.toml similarity index 100% rename from modules/fixtures/panic-bomb/module.toml rename to nexum/modules/fixtures/panic-bomb/module.toml diff --git a/modules/fixtures/panic-bomb/src/lib.rs b/nexum/modules/fixtures/panic-bomb/src/lib.rs similarity index 66% rename from modules/fixtures/panic-bomb/src/lib.rs rename to nexum/modules/fixtures/panic-bomb/src/lib.rs index 086738f3..e0e711ac 100644 --- a/modules/fixtures/panic-bomb/src/lib.rs +++ b/nexum/modules/fixtures/panic-bomb/src/lib.rs @@ -1,31 +1,25 @@ //! # panic-bomb (test fixture) //! //! Installs the nexum-sdk tracing facade (subscriber + panic hook) in -//! `init` and panics on every `on_event`. The hook writes the panic to -//! stderr and forwards it over the host logging call before the trap -//! reaches the supervisor, so one death leaves Stderr, HostInterface, -//! and Panic records on the run. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only. +//! `init` and panics on every `on_event`. The hook forwards the panic +//! to stderr and the host logging call before the trap reaches the +//! supervisor, so one death leaves Stderr, HostInterface, and Panic +//! records. Test-only. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: "../../../wit/nexum-host", + path: [ + "../../../../wit/nexum-host", + ], world: "nexum:host/event-module", + generate_all, }); use nexum::host::{logging, types}; /// Routes facade lines to the bound host logging import. -/// -/// Hand-rolled rather than generated by `bind_host_via_wit_bindgen!`: -/// this fixture binds only the minimal nexum world, so the generic -/// adapter would pull in unused chain/local-store impls and an unused -/// error converter (dead code under the `-D warnings` wasm build) for -/// the sole benefit of one log line. The sink below is the whole cost. struct HostLogSink; impl nexum_sdk::tracing::LogSink for HostLogSink { diff --git a/modules/fixtures/slow-host/Cargo.toml b/nexum/modules/fixtures/slow-host/Cargo.toml similarity index 100% rename from modules/fixtures/slow-host/Cargo.toml rename to nexum/modules/fixtures/slow-host/Cargo.toml diff --git a/modules/fixtures/slow-host/module.toml b/nexum/modules/fixtures/slow-host/module.toml similarity index 100% rename from modules/fixtures/slow-host/module.toml rename to nexum/modules/fixtures/slow-host/module.toml diff --git a/nexum/modules/fixtures/slow-host/src/lib.rs b/nexum/modules/fixtures/slow-host/src/lib.rs new file mode 100644 index 00000000..f5ce7b09 --- /dev/null +++ b/nexum/modules/fixtures/slow-host/src/lib.rs @@ -0,0 +1,45 @@ +//! # slow-host (test fixture) +//! +//! Issues one `chain::request` per event and returns `Ok` regardless of +//! its result. Fuel and epoch interruption only meter wasm instructions, +//! not time suspended inside a host call, so the test parks the first +//! `request` past a short `event_deadline_secs`: the wall-clock deadline +//! must fire, the supervisor drop the suspended call, mark the module +//! dead, and reinstantiate it. The next dispatch answers promptly and +//! recovers. Test-only. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +wit_bindgen::generate!({ + path: [ + "../../../../wit/nexum-host", + ], + world: "nexum:host/event-module", + generate_all, +}); + +use nexum::host::{chain, logging, types}; + +struct SlowHost; + +impl Guest for SlowHost { + fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { + // Minimal SDK-free fixture: no tracing subscriber is installed, + // so log through the raw host binding directly. + logging::log(logging::Level::Info, "slow-host init"); + Ok(()) + } + + fn on_event(_event: types::Event) -> Result<(), Fault> { + // A single read-only RPC. The test's mock provider decides how long + // it takes to answer; the guest just awaits it. `eth_blockNumber` + // with empty params is the cheapest well-formed request in the + // permitted read surface. + let _ = chain::request(1, "eth_blockNumber", "[]"); + logging::log(logging::Level::Info, "slow-host on_event returned"); + Ok(()) + } +} + +export!(SlowHost); diff --git a/tools/load-gen/Cargo.toml b/nexum/tools/load-gen/Cargo.toml similarity index 90% rename from tools/load-gen/Cargo.toml rename to nexum/tools/load-gen/Cargo.toml index 9652bdc8..4c62cd8a 100644 --- a/tools/load-gen/Cargo.toml +++ b/nexum/tools/load-gen/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true alloy-primitives.workspace = true -alloy-provider.workspace = true +alloy-provider = { workspace = true, features = ["ws"] } alloy-rpc-types-eth.workspace = true alloy-sol-types.workspace = true alloy-transport-ws.workspace = true diff --git a/tools/load-gen/src/main.rs b/nexum/tools/load-gen/src/main.rs similarity index 88% rename from tools/load-gen/src/main.rs rename to nexum/tools/load-gen/src/main.rs index 1546ac42..da58399d 100644 --- a/tools/load-gen/src/main.rs +++ b/nexum/tools/load-gen/src/main.rs @@ -1,21 +1,11 @@ -//! Anvil-side load generator for shepherd's M4 load test. +//! Anvil-side load generator for the runtime load test. //! //! Connects to an Anvil fork of Sepolia, impersonates the pinned test -//! EOA (no signer required - `anvil_impersonateAccount` skips -//! signature verification), and submits N `ComposableCoW.create(...)` -//! plus M `CoWSwapEthFlow.createOrder(...)` calls per new block. The -//! resulting `ConditionalOrderCreated` and `OrderPlacement` events are -//! what shepherd's twap-monitor and ethflow-watcher dispatch on. -//! -//! Knobs (`--help` for the full list): -//! - `--anvil ` WebSocket URL of the Anvil fork -//! - `--twap-per-block N` calls to ComposableCoW.create per block -//! - `--ethflow-per-block M` calls to CoWSwapEthFlow.createOrder per block -//! - `--duration ` wall-clock window the loop runs for -//! -//! Pinned identities mirror `docs/operations/e2e-prep.md`: -//! EOA, ComposableCoW, TWAP handler, CoWSwapEthFlow, WETH9, COW token, -//! Safe. These are constant across the Sepolia fork. +//! EOA, and submits N `ComposableCoW.create` plus M +//! `CoWSwapEthFlow.createOrder` calls per block; the resulting +//! `ConditionalOrderCreated` and `OrderPlacement` events are what +//! twap-monitor and ethflow-watcher dispatch on. `--help` lists the +//! knobs. #![cfg_attr(not(test), warn(unused_crate_dependencies))] @@ -98,20 +88,14 @@ struct Cli { #[arg(long, default_value_t = 5)] duration_min: u64, - /// Address whose state Anvil should impersonate when sending the - /// load-gen transactions. Defaults to the pinned Sepolia test EOA. - /// Ignored when `--parallel > 1` - synthetic per-worker EOAs are - /// used instead so the per-EOA nonce serialisation does not gate - /// throughput (the bottleneck the saturation 50x50 report - /// surfaced). + /// Address Anvil impersonates. Defaults to the pinned Sepolia test + /// EOA; ignored when `--parallel > 1`, which uses synthetic + /// per-worker EOAs so nonce serialisation does not gate throughput. #[arg(long, default_value_t = EOA)] eoa: Address, - /// Number of parallel workers. Each worker impersonates its own - /// synthetic EOA (`Address::from([i; 20])` where `i` is the - /// 1-based worker index), gets its own WS connection, runs its - /// own per-block submission loop. Total events per block = - /// `parallel * (twap_per_block + ethflow_per_block)`. + /// Parallel workers, each with its own synthetic EOA and WS + /// connection. Events per block = `parallel * (twap + ethflow)`. #[arg(long, default_value_t = 1)] parallel: u32, } @@ -364,10 +348,8 @@ fn salt_from_counter(n: u128) -> B256 { B256::from(bytes) } -/// Encode `ComposableCoW.create((handler, salt, staticInput), true)`. -/// The static input is the TWAP tuple from -/// `docs/operations/e2e-prep.md` §4.2 with `t0 = block_ts - 60` -/// so part 0 is Ready immediately. +/// Encode `ComposableCoW.create((handler, salt, staticInput), true)` +/// with `t0 = block_ts - 60` so part 0 is Ready immediately. fn encode_twap_create(salt: B256, block_ts: u64) -> Bytes { let static_input = ( WETH, @@ -394,12 +376,8 @@ fn encode_twap_create(salt: B256, block_ts: u64) -> Bytes { } /// Encode `CoWSwapEthFlow.createOrder(EthFlowOrder.Data)` with a sell -/// amount matched to the tx `value`. `appData` is the empty hash - a -/// digest every orderbook already knows, so hash-only submission -/// needs no registration step. `validTo` is `u32::MAX` per the -/// canonical EthFlow shape (the mock orderbook is -/// permissive here, and shepherd's strategy will drop with the -/// expected Info-level log per PR #49). +/// amount matched to the tx `value`. `appData` is the empty hash; +/// `validTo` is `u32::MAX` per the canonical EthFlow shape. fn encode_ethflow_create_order(eoa: Address, sell_amount: u128, quote_id: i64) -> Bytes { let order = EthFlowOrderData { buyToken: COW_TOKEN, diff --git a/scripts/README.md b/scripts/README.md index 4d4fad15..261158b5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,10 +1,6 @@ -# scripts/ — E2E automation +# scripts/: E2E automation -Three-step automation for the E2E run on Sepolia. Wraps -the runbook (`docs/operations/e2e-testnet-runbook.md`) + the prep -punch list (`docs/operations/e2e-prep.md`) into shell -scripts so the operator only has to (a) fill in `.env` and -(b) decide when to stop. +Three-step automation for the E2E run on Sepolia. Wraps the runbook (`docs/operations/e2e-testnet-runbook.md`) into shell scripts so the operator only has to (a) fill in `.env` and (b) decide when to stop. ## One-time setup @@ -13,13 +9,12 @@ cp scripts/env-template scripts/.env $EDITOR scripts/.env # fill in RPC URLs + EOA private key ``` -`.env` is gitignored — secrets stay on disk, never enter chat, -never get committed. +`.env` is gitignored: secrets stay on disk, never enter chat, never get committed. Required external tools: - `cargo` + the `wasm32-wasip2` target (already there if you've - built the workspace before). +built the workspace before). - `cast` from foundry (`curl -L https://foundry.paradigm.xyz | bash && foundryup`). - `jq`, `curl`, `python3` with `pip3 install eth-utils eth-abi pycryptodome`. @@ -41,79 +36,62 @@ Three artefacts land in `docs/operations/e2e-reports/`: | `metrics-end-.txt` | `/metrics` snapshot at SIGINT. | | `e2e-report-.md` | Auto-filled E2E report. Operator reviews + signs off + commits. | -The first three are gitignored; the report is committed manually -once you've reviewed it. +The first three are gitignored; the report is committed manually once you've reviewed it. ## Script details ### `e2e-run.sh` - Renders `engine.e2e.toml` → `engine.e2e.local.toml` - (gitignored via `*.local.toml`) with `RPC_URL_SEPOLIA` - substituted in. Embedded URL key never reaches git. +(gitignored via `*.local.toml`) with `RPC_URL_SEPOLIA` substituted in. Embedded URL key never reaches git. - Cleans `data/e2e/` for a fresh local-store. - Builds 5 modules + engine in `--release`. - Launches via `nohup`; engine survives the parent shell exiting. - Waits ≤ 60 s for `supervisor ready modules=5 chains=1`. - Persists `ENGINE_PID`, `LOG_FILE`, `METRICS_START`, `START_TS`, - `START_ISO` into `scripts/.state` (gitignored). +`START_ISO` into `scripts/.state` (gitignored). ### `e2e-onchain.sh` Pre-flight: - Derives the EOA address from `OPERATOR_PRIVATE_KEY` and asserts - it matches the pinned `0x7bF140727D27ea64b607E042f1225680B40ECa6A`. +it matches the pinned `0x7bF140727D27ea64b607E042f1225680B40ECa6A`. - Asserts EOA balance ≥ 0.02 ETH. Required actions: -1. **TWAP** — `cast send ComposableCoW.create((handler,salt,staticInput),true)` - with calldata derived freshly per invocation by - `scripts/_twap_calldata.py` (sets `t0 = now - 60` so part 0 is - Ready immediately; hardcoding `t0 = 0` is the prior bug). Fires - `ConditionalOrderCreated` → twap-monitor logs `watch:`. -2. **EthFlow** — calls `scripts/_ethflow_quote.py` to hit cow.fi - `/api/v1/quote`, encodes the returned `EthFlowOrder.Data`, - then `cast send EthFlow.createOrder` with the right msg.value. - Fires `OrderPlacement` → ethflow-watcher logs `submitted:`. +1. **TWAP**: `cast send ComposableCoW.create((handler,salt,staticInput),true)` +with calldata derived freshly per invocation by `scripts/_twap_calldata.py` (sets `t0 = now - 60` so part 0 is Ready immediately; hardcoding `t0 = 0` is the prior bug). Fires `ConditionalOrderCreated` → twap-monitor logs `watch:`. +2. **EthFlow**: calls `scripts/_ethflow_quote.py` to hit cow.fi +`/api/v1/quote`, encodes the returned `EthFlowOrder.Data`, then `cast send EthFlow.createOrder` with the right msg.value. Fires `OrderPlacement` → ethflow-watcher logs `submitted:`. Optional (gated on `RUN_OPTIONAL_PRESIGN=1` in `.env`): 3. `WETH9.deposit()` payable 0.01 ETH. 4. `GPv2Settlement.setPreSignature(uid, true)` with the pinned UID. 5. `WETH9.approve(GPv2VaultRelayer, 0.005 ETH)`. -Each tx hash appended to `scripts/.state` so the report generator -can link them. - -> stop-loss already produces `submitted:{uid}` on the very first -> block (verified in run-prep smoke — the CoW orderbook accepts -> PreSign orders upfront). The optional path is only needed if you -> want the order to actually **settle** on-chain. +Each tx hash appended to `scripts/.state` so the report generator can link them. The optional presign path is only needed if you want the order to actually **settle** on-chain. ### `e2e-finish.sh` - Captures `metrics-end-.txt`. - Sends `SIGINT` to the engine PID. - Waits ≤ 30 s for `graceful shutdown complete` in the log - (graceful-shutdown path). +(graceful-shutdown path). - Escalates to `SIGKILL` if the engine is still alive after 30 s. - Invokes `e2e-report-gen.sh` to write the filled-in report. ### `e2e-report-gen.sh` -Reads `LOG_FILE`, `METRICS_START`, `METRICS_END`, `START_ISO`, -`END_ISO`, and the `TX_*` hashes from `scripts/.state`; computes: +Reads `LOG_FILE`, `METRICS_START`, `METRICS_END`, `START_ISO`, `END_ISO`, and the `TX_*` hashes from `scripts/.state`; computes: - Chain coverage (first/last block from `block_number` log fields). - Per-module first terminal marker timestamp + sample line. - Delta of every `shepherd_*` Prometheus counter / histogram. - ERROR + trapped + poisoned tallies. - Per-row acceptance checklist (auto-checks block delta ≥ 1500, - marker per module, zero traps, zero poisons, zero ERRORs, - TWAP+EthFlow tx hashes present). +marker per module, zero traps, zero poisons, zero ERRORs, TWAP+EthFlow tx hashes present). -Writes `e2e-report-.md` in `docs/operations/e2e-reports/`. -Operator: review + add anomalies (section 6) + sign off -(section 8) + commit with `git add -f`. +Writes `e2e-report-.md` in `docs/operations/e2e-reports/`. Operator: review + add anomalies (section 6) + sign off (section 8) + commit with `git add -f`. ## Troubleshooting @@ -129,7 +107,7 @@ Operator: review + add anomalies (section 6) + sign off ## Re-running cleanly ```bash -scripts/e2e-finish.sh # safe even if it's the only command — graceful exit +scripts/e2e-finish.sh # safe even if it's the only command: graceful exit rm -rf data/e2e # wipe local-store rm scripts/.state # wipe run state scripts/e2e-run.sh # fresh start diff --git a/scripts/check-carve-groups.sh b/scripts/check-carve-groups.sh new file mode 100755 index 00000000..b5c35af7 --- /dev/null +++ b/scripts/check-carve-groups.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Dep-sync gate for the transitional three-grouping workspace (M5, #403). +# +# The physical layout is the source of truth: every workspace crate lives under +# exactly one group dir (nexum/ = L1, videre/ = L2, shepherd/ = L3). A crate may +# depend only within its own tier or a lower one (nexum <- videre <- shepherd). +# An upward edge (e.g. nexum depending on videre) would become a circular repo +# dependency the moment the groups are carved into separate repos, so it is +# rejected here rather than discovered at the cut. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +meta="$(cargo metadata --format-version 1 --no-deps)" + +# Emit "|" lines for every violation, then gate on the count. +violations="$(printf '%s' "$meta" | jq -r ' + .workspace_root as $root + | (["nexum","videre","shepherd"]) as $tiers + | def grp($p): ($p | ltrimstr($root + "/") | split("/")[0]); + def tier($p): ($tiers | index(grp($p))); + .packages[] + | select(.manifest_path | startswith($root + "/")) + | .name as $n + | (.manifest_path | rtrimstr("/Cargo.toml")) as $self + | if (tier($self) == null) + then "\($n)|not under a group dir (nexum/videre/shepherd): \(grp($self))" + else + (tier($self)) as $st + | .dependencies[] + | select(.path != null and (.path | startswith($root + "/"))) + | select((tier(.path)) != null and (tier(.path)) > $st) + | "\($n)|upward dep on \(.name) (\(grp($self)) -> \(grp(.path)))" + end +')" + +if [ -n "$violations" ]; then + echo "carve-groups: FAIL — grouping invariant violated:" >&2 + printf '%s\n' "$violations" | sed 's/^/ /; s/|/: /' >&2 + exit 1 +fi + +echo "carve-groups: OK — every workspace crate is grouped and depends only within or below its tier" diff --git a/scripts/check-cow-orderbook-only.sh b/scripts/check-cow-orderbook-only.sh new file mode 100755 index 00000000..e6fee9cb --- /dev/null +++ b/scripts/check-cow-orderbook-only.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Orderbook-only check for the CoW venue crate: shepherd/crates/cow-venue carries +# no composable symbol (Composable*, getTradeableOrder*, the +# IConditionalOrder revert selectors, LegacyRevertAdapter) and no +# dependency edge to the composable-cow keeper crate - the Cargo.toml +# scan covers the edge, since the dep line names the crate. Blocking in +# CI; run locally via `just check-cow-orderbook-only`. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." || exit 2 + +pass() { printf '\033[1;32m[cow PASS]\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31m[cow FAIL]\033[0m %s\n' "$*" >&2; status=1; } + +command -v rg >/dev/null || { echo "ripgrep (rg) is required" >&2; exit 2; } + +status=0 + +symbols='composable|getTradeableOrder|IConditionalOrder|LegacyRevertAdapter|\bVerdict\b|OrderNotValid|PollTryNextBlock|PollTryAtBlock|PollTryAtEpoch|PollNever' +rg -in --no-heading -e "$symbols" shepherd/crates/cow-venue +case $? in + 0) fail "composable symbols leak into shepherd/crates/cow-venue" ;; + 1) pass "cow-venue symbol scan empty" ;; + *) fail "symbol scan errored (shepherd/crates/cow-venue missing?)" ;; +esac + +exit "$status" diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh new file mode 100755 index 00000000..4b317a8d --- /dev/null +++ b/scripts/check-venue-agnostic.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Zero-leak check for the host layer, scoped precisely: no host-layer +# crate graph (runtime, launcher, bare engine) reaches a +# videre/intent/venue/cow crate; the runtime Rust sources carry no +# charter symbol +# (videre:|videre_host|Venue[A-Z]|EgressGuard|synthesize_venue|value-flow) +# and no privileged router field; and nexum:host names no foreign WIT +# package, carries no venue-domain vocabulary (venue|receipt|intent-status), +# and resolves as a leaf. Blocking in CI; run locally via +# `just check-venue-agnostic`. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." || exit 2 + +pass() { printf '\033[1;32m[l1 PASS]\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31m[l1 FAIL]\033[0m %s\n' "$*" >&2; status=1; } + +command -v rg >/dev/null || { echo "ripgrep (rg) is required" >&2; exit 2; } + +status=0 + +# 1. Crate graph: nothing venue-shaped reachable from the host-layer +# crates - the runtime, the generic launcher, and the bare engine +# binary (normal + build edges; dev-deps stay local to the crate). +for crate in nexum-runtime nexum-launch nexum-cli; do + if tree="$(cargo tree -p "$crate" -e normal,build --all-features --prefix none --locked)"; then + reached="$(printf '%s\n' "$tree" | + awk '{print $1}' | sort -u | rg -i 'videre|intent|venue|cow' || true)" + if [[ -n $reached ]]; then + fail "$crate crate graph reaches: $(tr '\n' ' ' <<<"$reached")" + else + pass "$crate crate graph clean" + fi + else + fail "cargo tree failed for $crate" + fi +done + +# 1b. Guest SDK: the generic guest SDK stays venue-free too. Its crate +# graph must reach no videre/venue crate (normal + build edges), so +# the venue status-codec never re-enters the domain-free SDK through a +# dependency; and its sources must name none of the intent-status +# codec's own vocabulary. The symbol scan is curated (not a bare +# `venue|receipt` sweep): the keeper stores legitimately speak of +# receipt-keyed journals and generic venue prose, so only the codec's +# distinctive names flag. +if tree="$(cargo tree -p nexum-sdk -e normal,build --all-features --prefix none --locked)"; then + reached="$(printf '%s\n' "$tree" | + awk '{print $1}' | sort -u | rg -i 'videre|intent|venue|cow' || true)" + if [[ -n $reached ]]; then + fail "nexum-sdk crate graph reaches: $(tr '\n' ' ' <<<"$reached")" + else + pass "nexum-sdk crate graph clean" + fi +else + fail "cargo tree failed for nexum-sdk" +fi +sdk_charter='intent-status|IntentStatusUpdate|INTENT_STATUS_KIND|[Ss]tatus[Bb]ody|status[-_]body' +rg -n --no-heading -e "$sdk_charter" nexum/crates/nexum-sdk/src +case $? in + 0) fail "venue status-codec vocabulary leaks into nexum-sdk" ;; + 1) pass "nexum-sdk carries no venue status-codec vocabulary" ;; + *) fail "nexum-sdk scan errored (nexum/crates/nexum-sdk/src missing?)" ;; +esac + +# 2. Symbol scan: the charter set (the current venue vocabulary that would +# signal a leak - videre WIT/crate refs, the Venue* types, the egress +# guard). Section 1 guards dependency edges; this scan stays curated to +# the live post-rename names so opaque extension payloads never false-flag. +charter='videre:|videre_host|Venue[A-Z]|EgressGuard|synthesize_venue|value-flow' +rg -n --no-heading -e "$charter" nexum/crates/nexum-runtime/src +case $? in + 0) fail "charter symbols leak into nexum-runtime" ;; + 1) pass "symbol scan empty" ;; + *) fail "symbol scan errored (nexum/crates/nexum-runtime/src missing?)" ;; +esac + +# 3. Privileged-field scan: the venue registry rides the extension +# service map; no `VenueRegistry` router field may return to the +# runtime. (The charter scan above also catches the type; this stays +# as the named guard for that specific invariant.) +rg -n --no-heading -e 'VenueRegistry' nexum/crates/nexum-runtime/src +case $? in + 0) fail "a privileged router field returned to nexum-runtime" ;; + 1) pass "no privileged router field" ;; + *) fail "field scan errored (nexum/crates/nexum-runtime/src missing?)" ;; +esac + +# 4. WIT surface: nexum:host is a leaf. No foreign package named +# anywhere in its sources, no cross-package use/import, no venue-domain +# vocabulary, and the package resolves standalone. +wit_charter='nexum:intent|nexum:adapter|value-flow|videre:|shepherd:cow' +rg -n --no-heading -e "$wit_charter" wit/nexum-host +case $? in + 0) fail "a foreign WIT namespace leaks into wit/nexum-host" ;; + 1) pass "no foreign WIT namespace named" ;; + *) fail "WIT namespace scan errored (wit/nexum-host missing?)" ;; +esac +rg -n --no-heading -e '^\s*(use|import)\s+[a-z0-9-]+:' wit/nexum-host +case $? in + 0) fail "nexum:host references another WIT package" ;; + 1) pass "nexum:host has no cross-package reference" ;; + *) fail "WIT scan errored (wit/nexum-host missing?)" ;; +esac +# Venue-domain vocabulary must not appear in the core event surface: the +# intent-status envelope is a videre-side borsh struct crossing `custom` +# as opaque bytes, so a term leaking back into nexum:host is a regression +# of the extraction. +rg -n --no-heading -i -e 'venue|receipt|intent-status' wit/nexum-host +case $? in + 0) fail "venue-domain vocabulary leaks into wit/nexum-host" ;; + 1) pass "nexum:host carries no venue-domain vocabulary" ;; + *) fail "WIT vocabulary scan errored (wit/nexum-host missing?)" ;; +esac +if command -v wasm-tools >/dev/null; then + if wasm-tools component wit wit/nexum-host >/dev/null; then + pass "nexum:host resolves standalone" + else + fail "nexum:host does not resolve standalone" + fi +else + printf '\033[1;33m[l1 WARN]\033[0m wasm-tools not found; WIT resolve skipped\n' >&2 +fi + +exit "$status" diff --git a/scripts/e2e-report-gen.sh b/scripts/e2e-report-gen.sh index 219a1a98..3d9161d9 100755 --- a/scripts/e2e-report-gen.sh +++ b/scripts/e2e-report-gen.sh @@ -40,7 +40,7 @@ LOG, M_START, M_END, START_ISO, END_ISO, TEMPLATE, OUT, STATE = sys.argv[1:9] # ── Parse engine log ───────────────────────────────────────────────── blocks = [] # list of dispatched block_numbers (per module, but we just want range) -markers = {m: [] for m in ("twap-monitor","ethflow-watcher","price-alert","balance-tracker","stop-loss")} +markers = {m: [] for m in ("twap-monitor","ethflow-watcher","price-alert","balance-tracker")} errors = [] trapped = [] poisoned = [] @@ -56,8 +56,6 @@ MARKER_PATTERNS = { # balance-tracker logs each per-block diff as # "0x changed +N wei (prior=..., current=...)". "balance-tracker": ["changed +", "changed -"], - "stop-loss": ["TRIGGERED", "retry on next block", "stop-loss submitted", - "stop-loss dropped", "already submitted", "submitted:"], } def event_field(ev, key, default=None): @@ -155,7 +153,9 @@ shepherd_keys = [ "shepherd_module_restarts_total", "shepherd_module_poisoned", "shepherd_chain_request_total", - "shepherd_cow_api_submit_total", + "shepherd_adapter_errors_total", + "shepherd_adapter_restarts_total", + "shepherd_adapter_poisoned", "shepherd_stream_reconnects_total", ] @@ -232,7 +232,7 @@ lines.append("## 4. Per-module terminal-state markers") lines.append("") lines.append("| Module | First marker | Sample line |") lines.append("|---|---|---|") -for m in ("twap-monitor","ethflow-watcher","price-alert","balance-tracker","stop-loss"): +for m in ("twap-monitor","ethflow-watcher","price-alert","balance-tracker"): if markers[m]: first = markers[m][0] # Truncate the marker line for the table diff --git a/scripts/e2e-run.sh b/scripts/e2e-run.sh index ce24b343..b3ec7aac 100755 --- a/scripts/e2e-run.sh +++ b/scripts/e2e-run.sh @@ -6,8 +6,8 @@ # operator's RPC URL (with key) substituted in. Local file is # gitignored. # 3. Cleans data/e2e for a fresh local-store. -# 4. Builds all 5 modules + the engine. -# 5. Launches nexum via nohup, redirecting stdout/stderr to +# 4. Builds all 4 modules + the engine. +# 5. Launches shepherd via nohup, redirecting stdout/stderr to # docs/operations/e2e-reports/engine-.log. JSON logs # (no --pretty-logs) so e2e-report-gen.sh can mine them with jq. # 6. Waits up to 60 s for the `supervisor ready modules=5 chains=1` @@ -44,15 +44,14 @@ render_engine_config log "cleaning local-store at $REPO_ROOT/data/e2e" rm -rf "$REPO_ROOT/data/e2e" -log "building 5 modules + engine (this can take a minute on first run)" +log "building 4 modules + engine (this can take a minute on first run)" ( cd "$REPO_ROOT" cargo build -p twap-monitor --target wasm32-wasip2 --release >/dev/null cargo build -p ethflow-watcher --target wasm32-wasip2 --release >/dev/null cargo build -p price-alert --target wasm32-wasip2 --release >/dev/null cargo build -p balance-tracker --target wasm32-wasip2 --release >/dev/null - cargo build -p stop-loss --target wasm32-wasip2 --release >/dev/null - cargo build -p nexum-cli --release >/dev/null + cargo build -p shepherd --release >/dev/null ) ts="$(date -u +%Y%m%dT%H%M%SZ)" @@ -63,7 +62,7 @@ start_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)" log "launching engine — log: $log_file" ( cd "$REPO_ROOT" - nohup "$REPO_ROOT/target/release/nexum" \ + nohup "$REPO_ROOT/target/release/shepherd" \ --engine-config "$REPO_ROOT/engine.e2e.local.toml" \ >"$log_file" 2>&1 & echo $! > "$STATE_FILE.pid.tmp" diff --git a/scripts/lib.sh b/scripts/lib.sh index 229a83df..0e773ecb 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -12,8 +12,7 @@ STATE_FILE="$SCRIPT_DIR/.state" REPORTS_DIR="$REPO_ROOT/docs/operations/e2e-reports" # Pinned identities — match docs/operations/e2e-prep.md -# section 0. If you change one, change them in lock-step and re-run -# `cargo test -p stop-loss --lib e2e_settings_yield_expected_uid`. +# section 0. If you change one, change them in lock-step. TEST_EOA="0x7bF140727D27ea64b607E042f1225680B40ECa6A" TEST_SAFE="0x14995a1118Caf95833e923faf8Dd155721cd53c2" COMPOSABLE_COW="0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" diff --git a/scripts/load-run.sh b/scripts/load-run.sh index 91cef9b0..af636aef 100755 --- a/scripts/load-run.sh +++ b/scripts/load-run.sh @@ -80,10 +80,10 @@ log "building modules + engine + load-gen (release)" ( cd "$REPO_ROOT" && cargo build --release --quiet \ --target wasm32-wasip2 \ -p twap-monitor -p ethflow-watcher ) -( cd "$REPO_ROOT" && cargo build --release --quiet -p nexum-cli -p load-gen ) +( cd "$REPO_ROOT" && cargo build --release --quiet -p shepherd -p load-gen ) -log "starting nexum (engine.load.toml)" -( cd "$REPO_ROOT" && ./target/release/nexum --engine-config engine.load.toml ) \ +log "starting shepherd (engine.load.toml)" +( cd "$REPO_ROOT" && ./target/release/shepherd --engine-config engine.load.toml ) \ >"$LOG_DIR/engine.log" 2>&1 & ENGINE_PID=$! echo "ENGINE_PID=$ENGINE_PID" >>"$PID_FILE" diff --git a/shepherd/crates/composable-cow/Cargo.toml b/shepherd/crates/composable-cow/Cargo.toml new file mode 100644 index 00000000..e692c1a7 --- /dev/null +++ b/shepherd/crates/composable-cow/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "composable-cow" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "ComposableCoW keeper machinery: the conditional-order body, the structured poll Verdict with the deployed 1.x revert decoding quarantined behind LegacyRevertAdapter, and the run composition over the venue client." + +[lib] +# Plain library, keeper-side only. The CoW venue crate is orderbook-only +# and never links this. + +[lints] +workspace = true + +[dependencies] +# `borsh` supplies the `Address`/`B256`/`Bytes` borsh impls the body +# derives against, byte-identical to the fields they replace. +alloy-primitives = { workspace = true, features = ["borsh"] } +alloy-sol-types.workspace = true +borsh.workspace = true +cowprotocol = { version = "0.2.0", default-features = false } +nexum-sdk = { path = "../../../nexum/crates/nexum-sdk" } +# `run` slice: the keeper run over the typed CoW client on the +# `videre:venue/client` seam. +cow-venue = { path = "../cow-venue", features = ["client", "assembly"], optional = true } +videre-sdk = { path = "../../../videre/crates/videre-sdk", optional = true } +tracing = { workspace = true, optional = true } + +[features] +# The poll-loop composition conditional-commitment keepers share: +# gate/journal discipline, pool submission, and retry dispatch. +run = ["dep:cow-venue", "dep:videre-sdk", "dep:tracing"] + +[dev-dependencies] +proptest.workspace = true +nexum-sdk-test = { path = "../../../nexum/crates/nexum-sdk-test" } + +[[test]] +name = "run" +required-features = ["run"] diff --git a/shepherd/crates/composable-cow/src/body.rs b/shepherd/crates/composable-cow/src/body.rs new file mode 100644 index 00000000..d05d1727 --- /dev/null +++ b/shepherd/crates/composable-cow/src/body.rs @@ -0,0 +1,74 @@ +//! The composable (conditional) order body. +//! +//! ComposableCoW's `ConditionalOrderParams` tuple in wire form: the +//! handler that mints the tradeable order, a salt, and the opaque +//! handler-specific static input. `static_input` is opaque; only the +//! named handler parses it. + +use alloy_primitives::{Address, B256, Bytes}; +use borsh::{BorshDeserialize, BorshSerialize}; + +/// The conditional order body: `ConditionalOrderParams` in wire form. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub struct ComposableBody { + /// The `IConditionalOrder` handler that mints the tradeable order. + pub handler: Address, + /// Salt distinguishing otherwise-identical conditional orders. + pub salt: B256, + /// Handler-specific static input; opaque to everything but the + /// named handler. + pub static_input: Bytes, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> ComposableBody { + ComposableBody { + handler: Address::repeat_byte(0xab), + salt: B256::repeat_byte(0xcd), + static_input: Bytes::from_static(&[1, 2, 3, 4, 5]), + } + } + + #[test] + fn composable_body_borsh_round_trips() { + let body = sample(); + let bytes = borsh::to_vec(&body).expect("encode"); + assert_eq!( + ComposableBody::try_from_slice(&bytes).expect("decode"), + body + ); + } + + #[test] + fn empty_static_input_round_trips() { + let mut body = sample(); + body.static_input = Bytes::new(); + let bytes = borsh::to_vec(&body).expect("encode"); + assert_eq!( + ComposableBody::try_from_slice(&bytes).expect("decode"), + body + ); + } + + /// Wire layout: 20 bare handler bytes, 32 bare salt bytes, then a + /// `u32`-length-prefixed static input. + #[test] + fn wire_matches_the_raw_array_layout() { + let mut expected = Vec::new(); + expected.extend_from_slice(&[0xab; 20]); + expected.extend_from_slice(&[0xcd; 32]); + expected.extend_from_slice(&5_u32.to_le_bytes()); + expected.extend_from_slice(&[1, 2, 3, 4, 5]); + assert_eq!(borsh::to_vec(&sample()).expect("encode"), expected); + } + + /// A truncated handler is a decode error, not a silent short read. + #[test] + fn truncated_input_fails_to_decode() { + let bytes = borsh::to_vec(&sample()).expect("encode"); + assert!(ComposableBody::try_from_slice(&bytes[..10]).is_err()); + } +} diff --git a/shepherd/crates/composable-cow/src/lib.rs b/shepherd/crates/composable-cow/src/lib.rs new file mode 100644 index 00000000..1eb93fc5 --- /dev/null +++ b/shepherd/crates/composable-cow/src/lib.rs @@ -0,0 +1,20 @@ +//! # composable-cow +//! +//! ComposableCoW keeper machinery, kept out of the CoW venue: the +//! conditional-order body ([`ComposableBody`]) and the structured poll +//! seam ([`Verdict`]), with the deployed 1.x reverting wire quarantined +//! behind [`LegacyRevertAdapter`]. The `run` slice adds the shared +//! poll-loop composition (`run`) over the typed CoW venue client. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] + +pub mod body; +pub mod poll; +#[cfg(feature = "run")] +pub mod run; + +pub use body::ComposableBody; +pub use poll::{IConditionalOrder, LegacyRevertAdapter, Verdict}; +#[cfg(feature = "run")] +pub use run::run; diff --git a/shepherd/crates/composable-cow/src/poll.rs b/shepherd/crates/composable-cow/src/poll.rs new file mode 100644 index 00000000..68343772 --- /dev/null +++ b/shepherd/crates/composable-cow/src/poll.rs @@ -0,0 +1,326 @@ +//! ComposableCoW poll seam: the structured [`Verdict`] and the +//! quarantined [`LegacyRevertAdapter`]. +//! +//! Every module poll resolves to a [`Verdict`]; the keeper run and +//! modules dispatch on its variants alone. The deployed +//! ComposableCoW 1.x contract instead reverts with one of five custom +//! errors; [`LegacyRevertAdapter`] decodes that wire onto a [`Verdict`] +//! and is the single seam that retires when the structured generator +//! ships. + +use alloy_primitives::{Bytes, U256}; +use alloy_sol_types::{SolError, sol}; +use cowprotocol::GPv2OrderData; +use nexum_sdk::host::ChainError; + +sol! { + /// Deployed ComposableCoW 1.x custom error surface; selector source + /// for [`LegacyRevertAdapter::decode`]. + #[derive(Debug)] + interface IConditionalOrder { + /// Order condition permanently unmet; drop. + error OrderNotValid(string reason); + /// Retry on the next block. + error PollTryNextBlock(string reason); + /// Retry at or after `blockNumber`. + error PollTryAtBlock(uint256 blockNumber, string reason); + /// Retry at or after `timestamp` (Unix seconds). + error PollTryAtEpoch(uint256 timestamp, string reason); + /// Conditional order is dead. + error PollNever(string reason); + } +} + +/// Structured outcome of a single watch poll. +/// +/// Every variant but `Post` carries `reason`, the source 4-byte +/// selector for logging only; `[0; 4]` when synthetic. `Post` is the +/// only variant [`LegacyRevertAdapter`] never produces. +#[derive(Debug)] +pub enum Verdict { + /// Tradeable now; submit `order` with its EIP-1271 `signature`. + Post { + /// Order ready to submit. + order: Box, + /// EIP-1271 signature blob (raw verifier bytes; the orderbook + /// prepends `from` before settlement). + signature: Bytes, + /// Advisory next-poll hint (Unix seconds); `None` when synthetic. + next_poll_timestamp: Option, + }, + /// Retry once the wall clock (Unix seconds) reaches `wait_until`. + WaitTimestamp { + /// Re-poll at or after this Unix timestamp (seconds). + wait_until: u64, + /// Source selector, log only. + reason: [u8; 4], + }, + /// Retry once the block number reaches `wait_until`. + WaitBlock { + /// Re-poll at or after this block number. + wait_until: u64, + /// Source selector, log only. + reason: [u8; 4], + }, + /// Retry on the next block. + TryNextBlock { + /// Source selector, log only. + reason: [u8; 4], + }, + /// Order is dead; drop the watch. + Invalid { + /// Source selector, log only. + reason: [u8; 4], + }, + /// Generator needs off-chain input; the keeper parks the watch. + /// Never produced by [`LegacyRevertAdapter`]. + NeedsInput { + /// Source selector, log only. + reason: [u8; 4], + }, +} + +/// Quarantined decoder for the deployed ComposableCoW 1.x reverting +/// wire; maps each revert onto a [`Verdict`]. +#[derive(Debug, Clone, Copy)] +pub struct LegacyRevertAdapter; + +impl LegacyRevertAdapter { + /// Decode a revert payload into a [`Verdict`], or `None` when the + /// selector is not one of the five [`IConditionalOrder`] errors. + /// [`classify`](Self::classify) is the lifecycle policy on top. + #[must_use] + pub fn decode(data: &[u8]) -> Option { + if data.len() < 4 { + return None; + } + let reason: [u8; 4] = data[..4].try_into().ok()?; + let body = &data[4..]; + match reason { + s if s == IConditionalOrder::OrderNotValid::SELECTOR => { + Some(Verdict::Invalid { reason }) + } + s if s == IConditionalOrder::PollTryNextBlock::SELECTOR => { + Some(Verdict::TryNextBlock { reason }) + } + s if s == IConditionalOrder::PollTryAtBlock::SELECTOR => { + let decoded = IConditionalOrder::PollTryAtBlock::abi_decode_raw(body).ok()?; + Some(Verdict::WaitBlock { + wait_until: u256_to_u64_saturating(decoded.blockNumber), + reason, + }) + } + s if s == IConditionalOrder::PollTryAtEpoch::SELECTOR => { + let decoded = IConditionalOrder::PollTryAtEpoch::abi_decode_raw(body).ok()?; + Some(Verdict::WaitTimestamp { + wait_until: u256_to_u64_saturating(decoded.timestamp), + reason, + }) + } + s if s == IConditionalOrder::PollNever::SELECTOR => Some(Verdict::Invalid { reason }), + _ => None, + } + } + + /// Classify a failed poll `eth_call` into a [`Verdict`]: the one + /// policy for what a poll failure means to the watch lifecycle. + /// + /// A recognised revert decodes; an unrecognised selector maps to + /// `Invalid` (a permanent contract-level rejection that would + /// otherwise loop every block); payload-free failures (transport + /// faults, sub-selector data) stay `TryNextBlock`. + #[must_use] + pub fn classify(err: &ChainError) -> Verdict { + match err { + ChainError::Rpc(rpc) => match rpc.data.as_deref() { + Some(data) if data.len() >= 4 => { + let reason: [u8; 4] = data[..4].try_into().unwrap_or([0; 4]); + Self::decode(data).unwrap_or(Verdict::Invalid { reason }) + } + _ => Verdict::TryNextBlock { reason: [0; 4] }, + }, + // `ChainError` is `#[non_exhaustive]`: transport faults and + // any future case are payload-free, so they stay retryable. + _ => Verdict::TryNextBlock { reason: [0; 4] }, + } + } +} + +fn u256_to_u64_saturating(v: U256) -> u64 { + u64::try_from(v).unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn order_not_valid_maps_to_invalid() { + let err = IConditionalOrder::OrderNotValid { + reason: "expired".to_string(), + }; + assert!(matches!( + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::Invalid { .. }) + )); + } + + #[test] + fn poll_never_maps_to_invalid() { + let err = IConditionalOrder::PollNever { + reason: "cancelled".to_string(), + }; + assert!(matches!( + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::Invalid { .. }) + )); + } + + #[test] + fn try_next_block() { + let err = IConditionalOrder::PollTryNextBlock { + reason: "noop".to_string(), + }; + assert!(matches!( + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::TryNextBlock { .. }) + )); + } + + #[test] + fn try_at_block_carries_number() { + let err = IConditionalOrder::PollTryAtBlock { + blockNumber: U256::from(12_345_678_u64), + reason: "wait".to_string(), + }; + assert!(matches!( + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::WaitBlock { + wait_until: 12_345_678, + .. + }) + )); + } + + #[test] + fn try_at_epoch_carries_timestamp() { + let err = IConditionalOrder::PollTryAtEpoch { + timestamp: U256::from(1_700_000_000_u64), + reason: "soon".to_string(), + }; + assert!(matches!( + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::WaitTimestamp { + wait_until: 1_700_000_000, + .. + }) + )); + } + + #[test] + fn decoded_reason_carries_the_selector() { + let err = IConditionalOrder::PollTryNextBlock { + reason: "noop".to_string(), + }; + let Some(Verdict::TryNextBlock { reason }) = LegacyRevertAdapter::decode(&err.abi_encode()) + else { + panic!("expected TryNextBlock"); + }; + assert_eq!(reason, IConditionalOrder::PollTryNextBlock::SELECTOR); + } + + #[test] + fn unknown_selector_returns_none() { + let mut data = vec![0xde, 0xad, 0xbe, 0xef]; + data.extend_from_slice(&[0u8; 32]); + assert!(LegacyRevertAdapter::decode(&data).is_none()); + } + + #[test] + fn truncated_returns_none() { + assert!(LegacyRevertAdapter::decode(&[0x01, 0x02]).is_none()); + } + + #[test] + fn u256_saturates_at_max() { + assert_eq!(u256_to_u64_saturating(U256::MAX), u64::MAX); + assert_eq!(u256_to_u64_saturating(U256::from(42_u64)), 42); + } + + use nexum_sdk::host::{Fault, RpcError}; + + fn rpc(data: Option>) -> ChainError { + ChainError::Rpc(RpcError { + code: -32000, + message: "execution reverted".into(), + data: data.map(Into::into), + }) + } + + #[test] + fn classify_dispatches_a_recognised_selector() { + let revert = IConditionalOrder::PollTryAtBlock { + blockNumber: U256::from(777_u64), + reason: "wait".to_string(), + } + .abi_encode(); + assert!(matches!( + LegacyRevertAdapter::classify(&rpc(Some(revert))), + Verdict::WaitBlock { + wait_until: 777, + .. + } + )); + } + + /// A selector outside the `IConditionalOrder` vocabulary maps to + /// `Invalid`, not re-poll forever. + #[test] + fn classify_unrecognised_selector_is_invalid() { + let mut data = vec![0x7a, 0x93, 0x32, 0x34]; + data.extend_from_slice(&[0u8; 32]); + assert!(matches!( + LegacyRevertAdapter::classify(&rpc(Some(data))), + Verdict::Invalid { .. } + )); + // A bare 4-byte selector with no body classifies the same way. + assert!(matches!( + LegacyRevertAdapter::classify(&rpc(Some(vec![0x2c, 0x7c, 0xa6, 0xd7]))), + Verdict::Invalid { .. } + )); + } + + #[test] + fn classify_payload_free_failures_stay_try_next_block() { + assert!(matches!( + LegacyRevertAdapter::classify(&rpc(None)), + Verdict::TryNextBlock { .. } + )); + assert!(matches!( + LegacyRevertAdapter::classify(&rpc(Some(Vec::new()))), + Verdict::TryNextBlock { .. } + )); + // Sub-selector payloads cannot name a contract error. + assert!(matches!( + LegacyRevertAdapter::classify(&rpc(Some(vec![0x01, 0x02]))), + Verdict::TryNextBlock { .. } + )); + assert!(matches!( + LegacyRevertAdapter::classify(&ChainError::Fault(Fault::Timeout)), + Verdict::TryNextBlock { .. } + )); + } + + use proptest::prelude::*; + + proptest! { + /// `decode` never panics; `None` below the 4-byte selector length. + #[test] + fn decode_never_panics(bytes in proptest::collection::vec(any::(), 0..64)) { + let outcome = LegacyRevertAdapter::decode(&bytes); + if bytes.len() < 4 { + prop_assert!(outcome.is_none()); + } + } + } +} diff --git a/shepherd/crates/composable-cow/src/run.rs b/shepherd/crates/composable-cow/src/run.rs new file mode 100644 index 00000000..591759e5 --- /dev/null +++ b/shepherd/crates/composable-cow/src/run.rs @@ -0,0 +1,219 @@ +//! Keeper run: the poll-loop composition conditional-commitment modules +//! share. +//! +//! [`run`] first drives the shared [`reconcile`](videre_sdk::reconcile) +//! pass over the `submitted:` reserve/commit journal, then polls each +//! gate-ready watch through a [`Poller`] and applies its [`Verdict`]: +//! lifecycle outcomes update the gate and watch stores; `Post` reserves +//! the encoded body, submits once through the typed `CowClient`, and +//! commits on acceptance. A reservation whose outcome is lost is +//! resubmitted by the next tick's reconcile pass, never dropped. +//! +//! Store faults abort the run (the next tick replays it); a submission +//! failure folds into a [`RetryAction`], a `denied` refusal re-entering +//! the CoW classification by its errorType prefix ([`classify_denied`]). +//! Diagnostics go through the guest `tracing` facade. + +use alloy_primitives::{Address, Bytes, hex}; +use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; +use cow_venue::{CowClient, CowIntent, CowIntentBody, CowVenue, SignedOrder, classify_denied}; +use cowprotocol::GPv2OrderData; +use nexum_sdk::host::{Fault, LocalStoreHost}; +use nexum_sdk::keeper::{ + Disposition, Gates, Guarded, Journal, Mark, Poller, Retrier, RetryAction, Tick, WatchRef, + WatchSet, +}; +use std::task::Poll; + +use videre_sdk::client::poll_once; +use videre_sdk::keeper::{retry_action, submission_key}; +use videre_sdk::{ + ClientError, IntentBody as _, SubmitOutcome, Venue as _, VenueFault, VenueTransport, +}; + +use crate::Verdict; + +/// Poll every gate-ready watch once at `tick` and apply each outcome. +/// The top-of-sweep [`reconcile`](videre_sdk::reconcile) pass resolves +/// stranded reservations first; a `Post` makes at most one submit. +pub fn run(host: &H, venue: &CowClient, source: &S, tick: &Tick) -> Result<(), Fault> +where + H: LocalStoreHost, + S: Poller, + T: VenueTransport, +{ + // Resolve any stranded reservation before polling fresh watches, so a + // submit whose outcome was lost is resubmitted, never dropped (#572). + // The helper is async and the guest boundary synchronous, so drive it + // with `poll_once`. + let journal = Journal::submitted(host); + match poll_once(videre_sdk::reconcile( + &CowVenue::ID, + venue.transport(), + &journal, + tick, + videre_sdk::DEFAULT_RECONCILE_BUDGET, + )) { + Poll::Ready(res) => { + res?; + } + Poll::Pending => { + // A misbehaving guest transport suspended; leave the RESERVED + // markers for the next tick rather than dropping them. + tracing::error!("cow reconcile suspended; skipping this tick"); + } + } + + let watches = WatchSet::new(host); + let gates = Gates::new(host); + for key in watches.list()? { + let Some(watch) = WatchRef::parse(&key) else { + continue; + }; + if !gates.is_ready(watch, tick.block, tick.epoch_s)? { + continue; + } + let Some(params) = watches.get(watch)? else { + continue; + }; + match source.poll(host, watch, ¶ms, tick) { + Verdict::Post { + order, signature, .. + } => { + submit_ready(host, venue, watch, &order, signature, tick, source.label())?; + } + Verdict::TryNextBlock { .. } => {} + Verdict::WaitBlock { wait_until, .. } => gates.set_next_block(watch, wait_until)?, + Verdict::WaitTimestamp { wait_until, .. } => gates.set_next_epoch(watch, wait_until)?, + Verdict::Invalid { .. } => { + // The removal is permanent; leave a trace of it even + // for sources that do not log their own outcomes. + tracing::info!("{} dropped watch {}", source.label(), watch.key()); + watches.remove(watch)?; + } + Verdict::NeedsInput { .. } => { + tracing::info!("watch {} parked awaiting input", watch.key()); + } + } + } + Ok(()) +} + +/// Submit one polled `Post` order through the guard, folding a refusal +/// into the retry ledger. +fn submit_ready( + host: &H, + venue: &CowClient, + watch: WatchRef<'_>, + order: &GPv2OrderData, + signature: Bytes, + tick: &Tick, + label: &str, +) -> Result<(), Fault> +where + H: LocalStoreHost, + T: VenueTransport, +{ + let Ok(owner) = watch.owner_hex().parse::
() else { + tracing::warn!( + "watch {} carries an unparseable owner; skipping submit", + watch.key(), + ); + return Ok(()); + }; + + let Some(order_data) = gpv2_to_order_data(order) else { + // Unknown enum marker: skip, not drop, so an SDK upgrade picks it up. + tracing::warn!( + "{label} submit skipped for {owner:#x}: GPv2OrderData carried an unknown enum marker" + ); + return Ok(()); + }; + + let intent = CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner, + signature: signature.to_vec(), + })); + // Key on the exact bytes the submit and reconcile both carry. + let encoded = match intent.to_bytes() { + Ok(bytes) => bytes, + Err(err) => { + tracing::error!("intent body encode failed: {err}"); + return Ok(()); + } + }; + let intent_id = submission_key(&CowVenue::ID, &encoded); + let journal = Journal::submitted(host); + // The guard owns the reserve/commit/reconcile ordering (#572). + let guarded = poll_once(journal.guard(&intent_id, &encoded, || async { + let outcome = venue.submit(&intent).await; + let disposition = match &outcome { + Ok(SubmitOutcome::Accepted(_)) => Disposition::Commit, + Ok(SubmitOutcome::RequiresSigning(_)) + | Err(ClientError::Body(_)) + | Err(ClientError::Venue(_)) => Disposition::Release, + // Unknown outcome: hold for reconcile. + Err(_) => Disposition::Retain, + }; + (disposition, outcome) + })); + let outcome = match guarded { + Poll::Ready(res) => match res? { + Guarded::Skipped(Mark::Committed) => { + tracing::info!("{label} {intent_id} already committed; skipping re-submit"); + return Ok(()); + } + Guarded::Skipped(Mark::Reserved) => { + tracing::info!("{label} {intent_id} reserved; reconcile owns it"); + return Ok(()); + } + Guarded::Ran(outcome) => outcome, + }, + Poll::Pending => { + // Guest transports never suspend; retry next block, reconcile owns the marker. + tracing::error!("{label} submit future suspended; retrying next block"); + return Retrier::new(host).apply(watch, RetryAction::TryNextBlock, tick); + } + }; + match outcome { + Ok(SubmitOutcome::Accepted(receipt)) => { + // Acceptance ends the refusal episode: clear the first-refusal marker. + if let Err(fault) = Retrier::new(host).clear_refusal(watch) { + tracing::error!("submitted {intent_id} but refusal-marker clear failed: {fault}"); + } + tracing::info!( + "submitted {intent_id} (receipt {})", + hex::encode_prefixed(&receipt), + ); + } + Ok(SubmitOutcome::RequiresSigning(_)) => { + tracing::warn!("{label} submit for {owner:#x} requires signing; not journalled"); + } + Err(ClientError::Body(err)) => { + tracing::error!("intent body encode failed: {err}"); + } + Err(ClientError::Venue(fault)) => { + let action = match &fault { + VenueFault::Denied(detail) => classify_denied(detail), + other => retry_action(other), + }; + Retrier::new(host).apply(watch, action, tick)?; + match action { + RetryAction::TryNextBlock => tracing::warn!("submit retry-next-block: {fault}"), + RetryAction::Backoff { seconds } => { + tracing::warn!("submit backoff {seconds}s: {fault}"); + } + RetryAction::DropOnRepeat => tracing::warn!("submit drop-on-repeat: {fault}"), + RetryAction::Drop => tracing::warn!("submit dropped watch: {fault}"), + // Non-exhaustive fallthrough: log the action name. + other => { + let action_label: &'static str = other.into(); + tracing::warn!("submit retry action {action_label}: {fault}"); + } + } + } + Err(err) => tracing::error!("submit failed: {err}"), + } + Ok(()) +} diff --git a/shepherd/crates/composable-cow/tests/run.rs b/shepherd/crates/composable-cow/tests/run.rs new file mode 100644 index 00000000..ad2df22d --- /dev/null +++ b/shepherd/crates/composable-cow/tests/run.rs @@ -0,0 +1,995 @@ +//! Run acceptance tests: `run` over the generic store mocks with a +//! scripted venue transport on the `videre:venue/client` seam. + +use std::cell::{Cell, RefCell}; +use std::collections::{HashSet, VecDeque}; + +use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; +use composable_cow::{Verdict, run}; +use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; +use cow_venue::{CowClient, CowIntent, CowIntentBody, CowVenue, SignedOrder}; +use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; +use nexum_sdk::host::{Fault, LocalStoreHost}; +use nexum_sdk::keeper::{Gates, Journal, Mark, Poller, Tick, WatchRef, WatchSet}; +use nexum_sdk_test::{MockHost, MockLocalStore, capture_tracing}; +use videre_sdk::client::sealed::SealedTransport; +use videre_sdk::keeper::submission_key; +use videre_sdk::{ + IntentBody as _, IntentStatus, Quotation, SubmitOutcome, UnsignedTx, Venue as _, VenueFault, + VenueId, VenueTransport, +}; + +const SEPOLIA: u64 = 11_155_111; + +/// Scripted venue transport: one submit outcome per queued entry, +/// every submit recorded. Quote, status, and cancel are off the run +/// path. +#[derive(Default)] +struct MockVenue { + outcomes: RefCell>>, + submits: RefCell)>>, +} + +impl MockVenue { + fn enqueue_submit(&self, outcome: Result) { + self.outcomes.borrow_mut().push_back(outcome); + } + + fn submits(&self) -> Vec<(String, Vec)> { + self.submits.borrow().clone() + } + + fn submit_count(&self) -> usize { + self.submits.borrow().len() + } +} + +impl SealedTransport for &MockVenue {} + +impl VenueTransport for &MockVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit(&self, venue: &VenueId, body: Vec) -> Result { + self.submits.borrow_mut().push((venue.to_string(), body)); + self.outcomes.borrow_mut().pop_front().unwrap_or_else(|| { + Err(VenueFault::Unavailable( + "MockVenue: unscripted submit".into(), + )) + }) + } + + async fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } +} + +fn client(venue: &MockVenue) -> CowClient<&MockVenue> { + CowClient::with_transport(venue) +} + +/// Closure-backed source so each test scripts its own outcome and +/// observes its own poll calls. +struct FnSource(F); + +impl Poller for FnSource +where + F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> Verdict, +{ + type Outcome = Verdict; + + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { + (self.0)(host, watch, params, tick) + } +} + +/// Pin the closure to the source signature so inference keeps the +/// higher-ranked lifetime. +fn src(f: F) -> FnSource +where + F: Fn(&MockHost, WatchRef<'_>, &[u8], &Tick) -> Verdict, +{ + FnSource(f) +} + +fn sample_owner() -> Address { + address!("00112233445566778899aabbccddeeff00112233") +} + +fn sample_hash() -> B256 { + keccak256(b"conditional order params") +} + +fn sample_tick() -> Tick { + Tick { + chain_id: SEPOLIA, + block: 1_000, + epoch_s: 1_700_000_000, + } +} + +fn submittable_order() -> GPv2OrderData { + GPv2OrderData { + sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), + buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), + receiver: Address::ZERO, + sellAmount: U256::from(1_000_000_u64), + buyAmount: U256::from(999_u64), + validTo: u32::MAX, + appData: cowprotocol::EMPTY_APP_DATA_HASH, + feeAmount: U256::ZERO, + kind: OrderKind::SELL, + partiallyFillable: false, + sellTokenBalance: SellTokenSource::ERC20, + buyTokenBalance: BuyTokenDestination::ERC20, + } +} + +fn ready_outcome(order: &GPv2OrderData) -> Verdict { + Verdict::Post { + order: Box::new(order.clone()), + signature: hex!("c0ffeec0ffeec0ffee").to_vec().into(), + next_poll_timestamp: None, + } +} + +fn seed_watch(host: &MockHost) -> String { + WatchSet::new(host) + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap() +} + +/// The encoded intent body the run submits for `order`. +fn intent_bytes(order: &GPv2OrderData) -> Vec { + let order_data = gpv2_to_order_data(order).expect("known markers"); + CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: sample_owner(), + signature: hex!("c0ffeec0ffeec0ffee").to_vec(), + })) + .to_bytes() + .expect("body encodes") +} + +/// The intent-id the run journals for `order`. +fn intent_id(order: &GPv2OrderData) -> String { + submission_key(&CowVenue::ID, &intent_bytes(order)) +} + +fn accepted() -> Result { + Ok(SubmitOutcome::Accepted(vec![0xAA])) +} + +#[test] +fn try_next_block_leaves_the_store_untouched() { + let host = MockHost::new(); + seed_watch(&host); + let before = host.store.snapshot(); + let venue = MockVenue::default(); + + run( + &host, + &client(&venue), + &src(|_, _, _, _| Verdict::TryNextBlock { reason: [0; 4] }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(host.store.snapshot(), before); + assert_eq!(venue.submit_count(), 0); +} + +#[test] +fn wait_block_sets_the_block_gate() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let venue = MockVenue::default(); + + run( + &host, + &client(&venue), + &src(|_, _, _, _| Verdict::WaitBlock { + wait_until: 2_000, + reason: [0; 4], + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!( + host.store.snapshot().get(&watch.next_block_key()).unwrap(), + &2_000_u64.to_le_bytes().to_vec(), + ); +} + +#[test] +fn wait_timestamp_sets_the_epoch_gate() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let venue = MockVenue::default(); + + run( + &host, + &client(&venue), + &src(|_, _, _, _| Verdict::WaitTimestamp { + wait_until: 1_800_000_000, + reason: [0; 4], + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!( + host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), + &1_800_000_000_u64.to_le_bytes().to_vec(), + ); +} + +#[test] +fn invalid_removes_the_watch_and_its_gates() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + Gates::new(&host).set_next_block(watch, 1).unwrap(); + let venue = MockVenue::default(); + + run( + &host, + &client(&venue), + &src(|_, _, _, _| Verdict::Invalid { reason: [0; 4] }), + &sample_tick(), + ) + .unwrap(); + + assert!(host.store.is_empty(), "watch and gates must go"); +} + +#[test] +fn gated_watch_is_not_polled() { + let host = MockHost::new(); + let key = seed_watch(&host); + Gates::new(&host) + .set_next_block(WatchRef::parse(&key).unwrap(), 5_000) + .unwrap(); + let polls = Cell::new(0_u32); + let venue = MockVenue::default(); + + run( + &host, + &client(&venue), + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + Verdict::TryNextBlock { reason: [0; 4] } + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 0, "a gated watch must not reach the source"); +} + +#[test] +fn malformed_watch_rows_are_skipped() { + let host = MockHost::new(); + host.store.set("watch:no-separator", b"junk").unwrap(); + let polls = Cell::new(0_u32); + let venue = MockVenue::default(); + + run( + &host, + &client(&venue), + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + Verdict::TryNextBlock { reason: [0; 4] } + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 0); +} + +#[test] +fn ready_submits_once_and_journals_the_intent_id() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(accepted()); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); + + assert_eq!(venue.submit_count(), 1); + assert!( + Journal::submitted(&host) + .contains(&intent_id(&order)) + .unwrap(), + "submitted:{{intent_id}} marker must be recorded", + ); + + // The next tick short-circuits on the journal: no second submit. + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); + assert_eq!(venue.submit_count(), 1); +} + +#[test] +fn ready_marker_keys_on_the_intent_id_never_the_server_receipt() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(vec![0xFE, 0xED, 0xFA, 0xCE]))); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); + + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&format!("submitted:{}", intent_id(&order)))); + assert_eq!( + snapshot + .keys() + .filter(|k| k.starts_with("submitted:")) + .count(), + 1, + "marker must key on the pre-submit intent-id, not the server receipt", + ); +} + +#[test] +fn ready_skips_the_venue_when_the_intent_id_is_journalled() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + Journal::submitted(&host) + .record(&intent_id(&order)) + .unwrap(); + let polls = Cell::new(0_u32); + let venue = MockVenue::default(); + + run( + &host, + &client(&venue), + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + ready_outcome(&order) + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 1, "the source is still consulted"); + assert_eq!( + venue.submit_count(), + 0, + "the journal guard must short-circuit before any network work", + ); +} + +#[test] +fn ready_with_unknown_marker_skips_submit_and_keeps_the_watch() { + let host = MockHost::new(); + let key = seed_watch(&host); + let mut order = submittable_order(); + order.kind = B256::repeat_byte(0x42); + let venue = MockVenue::default(); + + run( + &host, + &client(&venue), + &src(move |_, _, _, _| ready_outcome(&order)), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(venue.submit_count(), 0); + assert!(host.store.snapshot().contains_key(&key)); +} + +/// A run cannot sign: `requires-signing` is surfaced, not journalled. +#[test] +fn requires_signing_is_surfaced_and_not_journalled() { + let host = MockHost::new(); + let key = seed_watch(&host); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(Ok(SubmitOutcome::RequiresSigning(UnsignedTx { + chain: SEPOLIA, + to: vec![0x11; 20], + value: Vec::new(), + data: vec![0x22], + }))); + + let source = src(move |_, _, _, _| ready_outcome(&order)); + let (result, logs) = capture_tracing(|| run(&host, &client(&venue), &source, &sample_tick())); + result.unwrap(); + + assert_eq!(venue.submit_count(), 1); + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key), "the watch survives"); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); + assert!(logs.any(|e| e.message.contains("requires signing"))); +} + +#[test] +fn transient_fault_keeps_the_watch_ungated() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch_key = WatchRef::parse(&key).unwrap(); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(Err(VenueFault::Unavailable("orderbook http 502".into()))); + + run( + &host, + &client(&venue), + &src(move |_, _, _, _| ready_outcome(&order)), + &sample_tick(), + ) + .unwrap(); + + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key)); + assert!(!snapshot.contains_key(&watch_key.next_block_key())); + assert!(!snapshot.contains_key(&watch_key.next_epoch_key())); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); +} + +#[test] +fn denied_fault_drops_the_watch_through_the_ledger() { + let host = MockHost::new(); + let key = seed_watch(&host); + Gates::new(&host) + .set_next_block(WatchRef::parse(&key).unwrap(), 1) + .unwrap(); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(Err(VenueFault::Denied("InvalidSignature: bad sig".into()))); + + let source = src(move |_, _, _, _| ready_outcome(&order)); + let (result, logs) = capture_tracing(|| run(&host, &client(&venue), &source, &sample_tick())); + result.unwrap(); + + assert!( + host.store.is_empty(), + "a permanent refusal must drop the watch and its gates", + ); + assert!(logs.any(|e| e.message.contains("submit dropped watch"))); +} + +/// A rate-limit fault backs the watch off on the epoch clock. +#[test] +fn rate_limited_submit_backs_off_through_the_epoch_gate() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(Err(VenueFault::RateLimited { + retry_after_ms: Some(2_500), + })); + + let tick = sample_tick(); + run( + &host, + &client(&venue), + &src(move |_, _, _, _| ready_outcome(&order)), + &tick, + ) + .unwrap(); + + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key), "backoff must keep the watch"); + assert_eq!( + snapshot.get(&watch.next_epoch_key()).unwrap(), + &(tick.epoch_s + 3).to_le_bytes().to_vec(), + "2500ms rounds up to a 3s backoff from the tick clock", + ); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); +} + +/// The same-block wiring+create race: the orderbook rejects the first +/// submission against its own head. +fn eip1271_rejection() -> Result { + Err(VenueFault::Denied( + "InvalidEip1271Signature: signature for computed order hash 0x7ee5 is not valid".into(), + )) +} + +/// First EIP-1271 rejection gates to the next block; the retry one +/// block later lands. +#[test] +fn first_eip1271_rejection_retries_on_the_next_block() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(eip1271_rejection()); + venue.enqueue_submit(accepted()); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + let tick = sample_tick(); + let (result, logs) = capture_tracing(|| run(&host, &client(&venue), &source, &tick)); + result.unwrap(); + + let snapshot = host.store.snapshot(); + assert!( + snapshot.contains_key(&key), + "first rejection keeps the watch" + ); + assert_eq!( + snapshot.get(&watch.next_block_key()).unwrap(), + &(tick.block + 1).to_le_bytes().to_vec(), + "the watch gates to the next block", + ); + assert!(logs.any(|e| e.message.contains("drop-on-repeat"))); + + // Sub-block re-polls stay gated: the race is not hammered. + run(&host, &client(&venue), &source, &tick).unwrap(); + assert_eq!(venue.submit_count(), 1); + + // One block later the wiring is visible and the retry lands. + let next = Tick { + block: tick.block + 1, + ..tick + }; + run(&host, &client(&venue), &source, &next).unwrap(); + assert_eq!(venue.submit_count(), 2); + assert!( + Journal::submitted(&host) + .contains(&intent_id(&order)) + .unwrap(), + ); +} + +/// A rejection repeating on a later block drops the watch and its keys. +#[test] +fn repeated_eip1271_rejection_on_a_later_block_drops_the_watch() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(eip1271_rejection()); + venue.enqueue_submit(eip1271_rejection()); + + let source = src(move |_, _, _, _| ready_outcome(&order)); + let tick = sample_tick(); + run(&host, &client(&venue), &source, &tick).unwrap(); + + let next = Tick { + block: tick.block + 1, + ..tick + }; + run(&host, &client(&venue), &source, &next).unwrap(); + + assert_eq!(venue.submit_count(), 2); + assert!( + host.store.is_empty(), + "a repeated rejection must drop the watch, its gates, and the marker", + ); +} + +/// An acceptance ends the refusal episode; a later tranche's first +/// rejection earns a fresh one-block grace. +#[test] +fn acceptance_resets_the_one_block_grace_for_later_tranches() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let tranche_one = submittable_order(); + let mut tranche_two = submittable_order(); + tranche_two.buyAmount = U256::from(1_001_u64); + + let venue = MockVenue::default(); + venue.enqueue_submit(eip1271_rejection()); + venue.enqueue_submit(accepted()); + venue.enqueue_submit(eip1271_rejection()); + + let tick = sample_tick(); + let boundary = tick.block + 5; + let source = src(move |_, _, _, t: &Tick| { + if t.block < boundary { + ready_outcome(&tranche_one) + } else { + ready_outcome(&tranche_two) + } + }); + + // Tranche one: refused at the tick block, accepted one block later. + run(&host, &client(&venue), &source, &tick).unwrap(); + let next = Tick { + block: tick.block + 1, + ..tick + }; + run(&host, &client(&venue), &source, &next).unwrap(); + assert_eq!(venue.submit_count(), 2); + assert!( + !host.store.snapshot().contains_key(&watch.refused_key()), + "acceptance must clear the first-refusal marker", + ); + + // Tranche two: its own first rejection at a later block keeps the + // watch and gates it to the next block. + let later = Tick { + block: boundary, + ..tick + }; + run(&host, &client(&venue), &source, &later).unwrap(); + let snapshot = host.store.snapshot(); + assert!( + snapshot.contains_key(&key), + "a fresh refusal after an acceptance must keep the watch", + ); + assert_eq!( + snapshot.get(&watch.refused_key()).unwrap(), + &later.block.to_le_bytes().to_vec(), + ); + assert_eq!( + snapshot.get(&watch.next_block_key()).unwrap(), + &(later.block + 1).to_le_bytes().to_vec(), + ); +} + +/// Restart regression: a journalled intent is not re-posted after +/// restart, one venue submit across both lives. +#[test] +fn restart_with_a_journalled_intent_does_not_repost() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(accepted()); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); + assert_eq!(venue.submit_count(), 1); + + // A restarted keeper: fresh instance, the local store carried over. + let restarted = MockHost::new(); + for (key, value) in host.store.snapshot() { + restarted.store.set(&key, &value).unwrap(); + } + let venue_after = MockVenue::default(); + venue_after.enqueue_submit(accepted()); + + run(&restarted, &client(&venue_after), &source, &sample_tick()).unwrap(); + + assert_eq!( + venue.submit_count() + venue_after.submit_count(), + 1, + "resubmit after restart must make no second venue submit", + ); + assert!( + Journal::submitted(&restarted) + .contains(&intent_id(&order)) + .unwrap(), + ); +} + +/// The seam proof: a `Post` reaches the transport as the encoded +/// `CowIntentBody`, keyed on the generic submission key. +#[test] +fn ready_submits_the_encoded_intent_body_through_the_venue_seam() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(accepted()); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); + + let expected = intent_bytes(&order); + let submits = venue.submits(); + assert_eq!(submits.len(), 1); + assert_eq!(submits[0].0, CowVenue::ID.as_str()); + assert_eq!(submits[0].1, expected, "the wire carries the intent body"); + assert!( + Journal::submitted(&host) + .contains(&submission_key(&CowVenue::ID, &expected)) + .unwrap(), + "the journal keys on the generic submission key", + ); +} + +/// Models the CoW re-POST floor: a held body re-accepts, so a reconcile +/// resubmit is always safe. A fresh body gets the programmed outcome, an +/// accepted body joins the held set. Every POST is recorded. +struct HoldingVenue { + outcome: RefCell>, + posts: RefCell>>, + held: RefCell>>, +} + +impl HoldingVenue { + fn new(outcome: Result) -> Self { + Self { + outcome: RefCell::new(outcome), + posts: RefCell::new(Vec::new()), + held: RefCell::new(HashSet::new()), + } + } + + fn accepting() -> Self { + Self::new(Ok(SubmitOutcome::Accepted(vec![0xAB]))) + } + + fn posts(&self) -> Vec> { + self.posts.borrow().clone() + } + + fn post_count(&self) -> usize { + self.posts.borrow().len() + } + + fn held_count(&self) -> usize { + self.held.borrow().len() + } + + /// Pre-seed a held body: a POST the venue received before the caller + /// lost its outcome. + fn preload(&self, body: &[u8]) { + self.held.borrow_mut().insert(body.to_vec()); + } +} + +impl SealedTransport for &HoldingVenue {} + +impl VenueTransport for &HoldingVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit(&self, _venue: &VenueId, body: Vec) -> Result { + self.posts.borrow_mut().push(body.clone()); + if self.held.borrow().contains(&body) { + return Ok(SubmitOutcome::Accepted(vec![0xAB])); + } + let outcome = self.outcome.borrow().clone(); + if let Ok(SubmitOutcome::Accepted(_)) = &outcome { + self.held.borrow_mut().insert(body); + } + outcome + } + + async fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } +} + +fn holding_client(venue: &HoldingVenue) -> CowClient<&HoldingVenue> { + CowClient::with_transport(venue) +} + +/// Faults the first `COMMITTED` write to `submitted:` once: models a +/// commit write that faults, leaving the marker RESERVED with no release. +struct FlakyCommit { + inner: MockLocalStore, + arm: Cell, +} + +impl FlakyCommit { + fn new() -> Self { + Self { + inner: MockLocalStore::default(), + arm: Cell::new(true), + } + } +} + +impl LocalStoreHost for FlakyCommit { + fn get(&self, key: &str) -> Result>, Fault> { + self.inner.get(key) + } + + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { + // 0x02 is the journal COMMITTED tag. + if self.arm.get() && key.starts_with("submitted:") && value.first() == Some(&0x02) { + self.arm.set(false); + return Err(Fault::Unavailable("commit write faulted".into())); + } + self.inner.set(key, value) + } + + fn delete(&self, key: &str) -> Result<(), Fault> { + self.inner.delete(key) + } + + fn list_keys(&self, prefix: &str) -> Result, Fault> { + self.inner.list_keys(prefix) + } + + fn contains(&self, key: &str) -> Result { + self.inner.contains(key) + } + + fn len(&self, key: &str) -> Result, Fault> { + LocalStoreHost::len(&self.inner, key) + } + + fn count(&self, prefix: &str) -> Result { + self.inner.count(prefix) + } +} + +/// A source that never submits: exercises the reconcile pass alone. +struct Idle; + +impl Poller for Idle { + type Outcome = Verdict; + + fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Verdict { + Verdict::TryNextBlock { reason: [0; 4] } + } +} + +/// A source that posts one fixed order on every poll. +struct PostOnce(GPv2OrderData); + +impl Poller for PostOnce { + type Outcome = Verdict; + + fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Verdict { + ready_outcome(&self.0) + } +} + +/// Seed a stranded `RESERVED` marker, as a prior tick's reserve whose +/// outcome never landed. +fn seed_reserved(host: &impl LocalStoreHost, order: &GPv2OrderData) { + Journal::submitted(host) + .reserve(&intent_id(order), &intent_bytes(order)) + .unwrap(); +} + +fn cow_mark(host: &impl LocalStoreHost, order: &GPv2OrderData) -> Option { + Journal::submitted(host).mark(&intent_id(order)).unwrap() +} + +/// W1: reserved, but the venue never saw the POST. The next tick's +/// reconcile resubmits to exactly one held order. +#[test] +fn w1_reserved_but_venue_never_saw_the_post_reconciles() { + let host = MockLocalStore::default(); + let order = submittable_order(); + seed_reserved(&host, &order); + let venue = HoldingVenue::accepting(); + + run(&host, &holding_client(&venue), &Idle, &sample_tick()).unwrap(); + + assert_eq!( + venue.post_count(), + 1, + "reconcile resubmits the stranded body" + ); + assert_eq!(venue.held_count(), 1, "exactly one held order"); + assert_eq!( + venue.posts()[0], + intent_bytes(&order), + "the reserved body round-trips", + ); + assert_eq!(cow_mark(&host, &order), Some(Mark::Committed)); +} + +/// W2: accepted, then the commit faults, leaving the marker RESERVED. +/// The next tick's reconcile resubmits, the venue dedups +/// (AlreadyHeld -> Accepted), the commit lands: two POSTs, one held. +#[test] +fn w2_accepted_then_commit_faults_reconciles_without_double_holding() { + let host = FlakyCommit::new(); + WatchSet::new(&host) + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap(); + let order = submittable_order(); + let venue = HoldingVenue::accepting(); + + // Tick A: reserve, venue accepts (POST #1), the commit write faults; + // the RESERVED marker persists, no release runs. + run( + &host, + &holding_client(&venue), + &PostOnce(order.clone()), + &sample_tick(), + ) + .unwrap(); + assert_eq!(venue.post_count(), 1); + assert_eq!( + cow_mark(&host, &order), + Some(Mark::Reserved), + "a commit fault leaves the marker RESERVED", + ); + + // Tick B: reconcile re-POSTs (POST #2), the venue dedups, the commit + // lands. The fresh loop then sees COMMITTED and never re-posts. + run( + &host, + &holding_client(&venue), + &PostOnce(order.clone()), + &sample_tick(), + ) + .unwrap(); + assert_eq!( + venue.post_count(), + 2, + "reconcile re-POSTs the reserved body" + ); + assert_eq!(venue.held_count(), 1, "one held order despite two POSTs"); + assert_eq!(cow_mark(&host, &order), Some(Mark::Committed)); +} + +/// W3: the run was abandoned after the venue received the POST. The next +/// tick's reconcile re-POSTs, the AlreadyHeld backstop accepts, one held. +#[test] +fn w3_abandoned_after_the_post_reconciles_to_one_held() { + let host = MockLocalStore::default(); + let order = submittable_order(); + seed_reserved(&host, &order); + let venue = HoldingVenue::accepting(); + venue.preload(&intent_bytes(&order)); + + run(&host, &holding_client(&venue), &Idle, &sample_tick()).unwrap(); + + assert_eq!(venue.post_count(), 1); + assert_eq!(venue.held_count(), 1, "the already-held order stays single"); + assert_eq!(cow_mark(&host, &order), Some(Mark::Committed)); + // The venue-never-saw-it sub-case is W1 above. +} + +/// Anti-#572: a RESERVED marker drives a reconcile POST through +/// `venue.submit`, where the AlreadyHeld backstop catches the duplicate. +#[test] +fn anti_572_reserved_marker_drives_a_reconcile_post_through_the_venue() { + let host = MockLocalStore::default(); + let order = submittable_order(); + seed_reserved(&host, &order); + let venue = HoldingVenue::accepting(); + // The venue already holds it: the reconcile POST hits the + // AlreadyHeld -> Accepted path, not a fresh accept. + venue.preload(&intent_bytes(&order)); + + run(&host, &holding_client(&venue), &Idle, &sample_tick()).unwrap(); + + assert_eq!( + venue.post_count(), + 1, + "the reserved marker POSTs, never a silent skip", + ); + assert_eq!( + venue.posts()[0], + intent_bytes(&order), + "the exact reserved bytes re-POST", + ); + assert_eq!(venue.held_count(), 1, "no duplicate order"); + assert_eq!( + cow_mark(&host, &order), + Some(Mark::Committed), + "the backstop-accepted resubmit commits", + ); +} diff --git a/shepherd/crates/cow-venue/Cargo.toml b/shepherd/crates/cow-venue/Cargo.toml new file mode 100644 index 00000000..9c1bb291 --- /dev/null +++ b/shepherd/crates/cow-venue/Cargo.toml @@ -0,0 +1,95 @@ +[package] +name = "cow-venue" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "CoW venue slices, orderbook-only. The default `body` slice carries the venue-neutral order intent body types and their borsh IntentBody codec, linkable by adapters and modules." + +[lib] +# The default `body` slice is dependency-light so a venue adapter +# component or a keeper module can link the intent body types and +# codec without the host-side CoW machinery. The cdylib is the +# `adapter` slice's component build (wasm32-wasip2). +crate-type = ["lib", "cdylib"] + +[lints] +workspace = true + +[dependencies] +# Payload structs derive the borsh traits directly, so the crate carries +# its own borsh declaration (the IntentBody derive reaches borsh through +# the venue SDK re-export, but the payload derives need it by name). +borsh = { workspace = true, optional = true } +# Source of the `IntentBody` derive and trait the version enum implements, +# and the typed intent client the `client` slice binds to the CoW venue. +videre-sdk = { path = "../../../videre/crates/videre-sdk", optional = true } +# `client` slice only: the keeper `RetryAction` the generated +# classification table maps each errorType to. The TOML parse happens in +# `build.rs`, so serde/toml/thiserror are build- and dev-only and never +# reach a guest that links this slice. +nexum-sdk = { path = "../../../nexum/crates/nexum-sdk", optional = true } +# `assembly` slice: the chain-edge order projections and orderbook +# submission bodies. The `client` slice also enables it for the typed +# `OrderbookApiErrorType` classifier boundary. Express-declared (not +# workspace-inherited) so the guest build never inherits the native +# `http-client` feature. +cowprotocol = { version = "0.2.0", default-features = false, optional = true } +# `body` slice: the order body fields are alloy `Address`/`U256`, and +# `borsh` supplies their borsh impls (ruint for `U256`). Optional only +# so the empty `--no-default-features` build stays dependency-free. +alloy-primitives = { workspace = true, features = ["borsh"], optional = true } +alloy-sol-types = { workspace = true, optional = true } +# `adapter` slice: the orderbook REST speaker over the scoped +# wasi:http transport. +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +http = { workspace = true, optional = true } +url = { workspace = true, optional = true } +wit-bindgen = { workspace = true, optional = true } + +# `build.rs` parses `data/classification.toml` and emits the static +# lookup table; the same parse is shared with the parity tests below. +[build-dependencies] +serde = { workspace = true } +toml = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +alloy-primitives = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } +thiserror = { workspace = true } +# The conformance kit: holds the body codec to its published vector set. +videre-test = { path = "../../../videre/crates/videre-test" } +# Parity tests: the upstream `retry_hint()` the shipped table is +# reconciled against. +cowprotocol = { version = "0.2.0", default-features = false } + +[features] +# The body-type + codec slice ships by default; the `client` slice layers +# the typed client and the table-driven retry classification on top. +# `--no-default-features` drops everything so downstream can depend on a +# single slice without pulling the codec or the keeper transitively. +default = ["body"] +body = ["dep:borsh", "dep:videre-sdk", "dep:alloy-primitives"] +client = ["body", "dep:nexum-sdk", "dep:cowprotocol"] +# Chain-edge order assembly, shared by the adapter's submit and the +# keeper's legacy submit path. Carries no component glue, so a keeper +# module can link it without exporting the adapter face. +assembly = ["body", "dep:cowprotocol", "dep:alloy-sol-types"] +# The venue-adapter component slice: the `#[videre_sdk::venue]` export +# and the orderbook transport. Only the cdylib wasm build enables it. +adapter = [ + "assembly", + "client", + "dep:serde", + "dep:serde_json", + "dep:http", + "dep:url", + "dep:wit-bindgen", +] + +[[test]] +name = "conformance" +required-features = ["adapter"] diff --git a/shepherd/crates/cow-venue/build.rs b/shepherd/crates/cow-venue/build.rs new file mode 100644 index 00000000..f619f788 --- /dev/null +++ b/shepherd/crates/cow-venue/build.rs @@ -0,0 +1,44 @@ +//! Turn the shipped `data/classification.toml` into a generated lookup +//! table at build time, so the runtime `client` slice (and any wasm +//! guest that links it) carries no TOML parser. The parse and the table +//! invariants live in `src/classification_data.rs`, shared verbatim with +//! the crate's parity tests. + +#[path = "src/classification_data.rs"] +mod classification_data; + +use std::{env, fs, path::Path}; + +use classification_data::{Action, parse_and_validate}; + +fn main() { + let manifest = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); + let data = Path::new(&manifest).join("data/classification.toml"); + println!("cargo:rerun-if-changed={}", data.display()); + println!("cargo:rerun-if-changed=src/classification_data.rs"); + + let toml = fs::read_to_string(&data).expect("read data/classification.toml"); + let entries = + parse_and_validate(&toml).expect("shipped cow classification.toml is well formed"); + + let mut out = String::new(); + out.push_str("// @generated from data/classification.toml by build.rs; do not edit.\n"); + out.push_str("static GENERATED_ROWS: &[GeneratedRow] = &[\n"); + for e in &entries { + let action = match e.action { + Action::TryNextBlock => "GenAction::TryNextBlock", + Action::Backoff => "GenAction::Backoff", + Action::DropOnRepeat => "GenAction::DropOnRepeat", + Action::Drop => "GenAction::Drop", + }; + out.push_str(&format!( + " GeneratedRow {{ error_type: {:?}, action: {action}, \ + backoff_seconds: {}, already_submitted: {} }},\n", + e.error_type, e.backoff_seconds, e.already_submitted, + )); + } + out.push_str("];\n"); + + let dest = Path::new(&env::var("OUT_DIR").expect("OUT_DIR")).join("classification_table.rs"); + fs::write(&dest, out).expect("write generated classification table"); +} diff --git a/shepherd/crates/cow-venue/data/classification.toml b/shepherd/crates/cow-venue/data/classification.toml new file mode 100644 index 00000000..2d236248 --- /dev/null +++ b/shepherd/crates/cow-venue/data/classification.toml @@ -0,0 +1,145 @@ +# CoW orderbook order-submission retry classification. +# +# This file is the source of truth for how a submitted order's rejection +# `errorType` maps to a retry action. It is plain TOML: a non-Rust author +# (or any TOML reader in any language) can add, remove, or re-target an +# entry without touching Rust. The `cow-venue` `client` slice embeds and +# parses this exact file, and a parity test guards the Rust contract +# against it. +# +# Each entry names one orderbook `errorType` and the action the retry +# ledger takes when the orderbook returns it: +# +# action = "try-next-block" Transient: a fresh submission on a later +# block may succeed. Leave the watch in +# place. +# action = "backoff" Server- or account-throttle: retrying next +# block cannot clear the condition. Gate the +# watch for `backoff-seconds` before the next +# attempt. `backoff-seconds` is required and +# must be at least 1. +# action = "drop-on-repeat" Permanent unless first-seen: the first +# rejection retries on the next block (a +# same-block state race can make a valid +# order verify late); a repeat on a later +# block removes the watch. +# +# action = "drop" Permanent: no retry can succeed. Remove the +# watch and its gates. +# +# `already-submitted = true` marks a rejection that means the orderbook +# already holds this exact order. It is success wearing an error status, +# so the action is "try-next-block" (never drop, which would kill every +# future tranche of a TWAP) and the submit path records the submitted +# receipt so the next tick short-circuits instead of re-posting. +# +# Any `errorType` absent from this table classifies as "drop": an +# unrecognised, structured contract-level rejection is treated as +# permanent rather than retried every block forever. +# +# Relationship to `cowprotocol::ApiError::retry_hint()`. The upstream +# `cowprotocol` crate also classifies +# orderbook `errorType`s, via `RetryHint`. Ratified: this table, not +# `RetryHint`, is shepherd's classification source of truth. It is +# shepherd's own, more conservative retry policy, kept as data of record +# here so a non-Rust author owns it and so the guest `client` slice +# stays free of the upstream error module. The ratified divergences +# (a permanent-looking contract rejection is dropped rather than +# retried, and the limit-order backoff is shorter) are exactly: +# +# InvalidEip1271Signature drop-on-repeat upstream: retry next block +# InsufficientBalance drop upstream: backoff 10 min +# InsufficientAllowance drop upstream: backoff 10 min +# InvalidAppData drop upstream: backoff 60 s +# TooManyLimitOrders backoff 30 s upstream: backoff 1 h +# +# Every `error-type` below must name a member of the upstream orderbook +# errorType enum (`cowprotocol::OrderbookApiErrorType`). Parity tests +# reject phantom types and pin the divergence set to the list above, so +# both a data edit and an upstream policy change force re-ratification. +# Revisit policy here, not by switching the source of truth to +# `RetryHint`. + +# --- Transient: retry on the next block ------------------------------ + +[[entry]] +error-type = "InsufficientFee" +action = "try-next-block" + +# --- Throttle: wait, then retry -------------------------------------- + +# The account already holds the maximum number of open limit orders. A +# next-block retry cannot help: the slot only frees when an existing +# order settles or expires, so back off and re-check rather than hammer +# the orderbook every block. +[[entry]] +error-type = "TooManyLimitOrders" +action = "backoff" +backoff-seconds = 30 + +# --- Already submitted: keep the watch, record the receipt ----------- + +# The orderbook's canonical spelling. +[[entry]] +error-type = "DuplicatedOrder" +action = "try-next-block" +already-submitted = true + +# The spelling older deployments emit; classify identically. +[[entry]] +error-type = "DuplicateOrder" +action = "try-next-block" +already-submitted = true + +# --- One-block grace: retry once, then drop -------------------------- + +# A first-time user's Safe wiring and conditional-order registration +# can land in the same block as the indexed event; an orderbook node +# verifying against its own head then rejects a signature that is valid +# one block later. The first rejection retries next block; a repeat on +# a later block is a genuinely broken signature and drops the watch. +[[entry]] +error-type = "InvalidEip1271Signature" +action = "drop-on-repeat" + +# --- Permanent: drop the watch --------------------------------------- +# +# Listed for documentation; each is also the default for any unlisted +# `errorType`. Flip one to "backoff" or "try-next-block" here to change +# the policy without touching Rust. + +[[entry]] +error-type = "InvalidSignature" +action = "drop" + +[[entry]] +error-type = "WrongOwner" +action = "drop" + +[[entry]] +error-type = "InsufficientBalance" +action = "drop" + +[[entry]] +error-type = "InsufficientAllowance" +action = "drop" + +[[entry]] +error-type = "UnsupportedToken" +action = "drop" + +[[entry]] +error-type = "InvalidAppData" +action = "drop" + +[[entry]] +error-type = "AppDataHashMismatch" +action = "drop" + +[[entry]] +error-type = "ZeroAmount" +action = "drop" + +[[entry]] +error-type = "SameBuyAndSellToken" +action = "drop" diff --git a/shepherd/crates/cow-venue/module.load.toml b/shepherd/crates/cow-venue/module.load.toml new file mode 100644 index 00000000..b28b4a4a --- /dev/null +++ b/shepherd/crates/cow-venue/module.load.toml @@ -0,0 +1,24 @@ +# Load-test variant of the cow adapter manifest: Sepolia chain id with +# the orderbook re-pointed at tools/orderbook-mock (no live cow.fi). + +[module] +name = "cow" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["http"] +optional = [] + +[capabilities.http] +allow = ["localhost"] + +[config] +chain = "11155111" +orderbook-url = "http://localhost:9999" + +# Body-schema versions this adapter decodes: the handshake authority. +[venue] +body_versions = [1] diff --git a/shepherd/crates/cow-venue/module.sepolia.toml b/shepherd/crates/cow-venue/module.sepolia.toml new file mode 100644 index 00000000..35635f09 --- /dev/null +++ b/shepherd/crates/cow-venue/module.sepolia.toml @@ -0,0 +1,24 @@ +# Sepolia variant of the cow adapter manifest: same component, chain +# 11155111, so submits land on the Sepolia orderbook. Wire this from +# any engine config whose watchers index Sepolia. + +[module] +name = "cow" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["http"] +optional = [] + +[capabilities.http] +allow = ["api.cow.fi"] + +[config] +chain = "11155111" + +# Body-schema versions this adapter decodes: the handshake authority. +[venue] +body_versions = [1] diff --git a/shepherd/crates/cow-venue/module.toml b/shepherd/crates/cow-venue/module.toml new file mode 100644 index 00000000..0e5b555c --- /dev/null +++ b/shepherd/crates/cow-venue/module.toml @@ -0,0 +1,30 @@ +# cow adapter manifest - the CoW venue's `#[videre_sdk::venue]` +# component. The manifest name is the venue id the registry installs +# the adapter under. Outbound orderbook HTTP is the only transport; +# the operator's `[[adapters]].http_allow` grant scopes it at install. + +[module] +name = "cow" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["http"] +optional = [] + +[capabilities.http] +allow = ["api.cow.fi"] + +# One adapter instance speaks one chain's orderbook. `orderbook-url`, +# `owner` (enables the pre-sign path), and `http-timeout-ms` are +# optional overrides. Sepolia and load-mock variants sit alongside +# (`module.sepolia.toml`, `module.load.toml`). +[config] +chain = "1" + +# Body-schema versions this adapter decodes: the handshake authority. +# Install asserts the adapter's body-versions export equals it. +[venue] +body_versions = [1] diff --git a/shepherd/crates/cow-venue/src/adapter.rs b/shepherd/crates/cow-venue/src/adapter.rs new file mode 100644 index 00000000..9133b70d --- /dev/null +++ b/shepherd/crates/cow-venue/src/adapter.rs @@ -0,0 +1,1003 @@ +//! The CoW venue adapter: the `venue-adapter` component slice. +//! +//! `CowAdapter` decodes [`CowIntentBody`], assembles the orderbook +//! wire bodies through [`crate::assembly`], and speaks the orderbook +//! REST API over the scoped wasi:http transport bounded by the +//! configured per-request timeout. Orderbook `errorType` rejections +//! project onto `venue-error` through the shipped classification table; +//! an unsigned order submits as pre-sign, success carrying the +//! `setPreSignature` call the host signs. +//! +//! `[config]` keys: `chain` (required, decimal chain id), optional +//! `orderbook-url`, `owner` (hex address enabling the pre-sign path), +//! `http-timeout-ms` (per-request bound, default the SDK per-phase +//! timeout). + +use core::time::Duration; +use std::sync::{PoisonError, RwLock}; + +use alloy_primitives::Address; +use cowprotocol::{ + ApiError, Chain, OrderCreation, OrderData, OrderKind, OrderStatus, OrderbookApiErrorType, + QuoteAppData, QuoteRequest, +}; +use nexum_sdk::keeper::RetryAction; +use serde::Deserialize; +use url::Url; +use videre_sdk::transport::http::Fetch; +use videre_sdk::value_flow::AssetAmount; +use videre_sdk::{ + AuthScheme, IntentBody as _, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, + SubmitOutcome, UnsignedTx, VenueError, +}; + +use crate::assembly; +use crate::body::{CowIntent, CowIntentBody}; +use crate::classification; +use crate::order::OrderUid; + +/// The CoW venue's `venue-adapter` export type; the component face +/// comes from `#[videre_sdk::venue]` on the +/// [`VenueAdapter`](videre_sdk::VenueAdapter) impl. +pub struct CowAdapter; + +// The reconcile floor: an already-held re-POST folds to the same accept +// outcome (`submit_with`, both auth paths) and status GETs the +// body-derived uid, so the adapter honours the contract. +impl videre_sdk::client::sealed::SealedReconcile for CowAdapter {} +impl videre_sdk::VenueReconcile for CowAdapter {} + +/// Default per-request timeout bound: the SDK's per-phase default. +const DEFAULT_TIMEOUT: Duration = videre_sdk::transport::http::DEFAULT_TIMEOUT; + +/// Parsed `[config]`: one adapter instance speaks one chain's orderbook. +#[derive(Clone, Debug)] +pub(crate) struct AdapterConfig { + pub(crate) chain: Chain, + pub(crate) base: Url, + pub(crate) owner: Option
, + pub(crate) timeout: Duration, +} + +impl AdapterConfig { + /// Parse the wire config table. Unknown keys are ignored; a + /// malformed value fails init typedly. + pub(crate) fn parse(config: &[(String, String)]) -> Result { + let invalid = |key: &str, value: &str| { + videre_sdk::Fault::InvalidInput(format!("config {key} is invalid: {value}")) + }; + let mut chain = None; + let mut base = None; + let mut owner = None; + let mut timeout = DEFAULT_TIMEOUT; + for (key, value) in config { + match key.as_str() { + "chain" => { + let id: u64 = value.parse().map_err(|_| invalid(key, value))?; + chain = Some(Chain::try_from(id).map_err(|_| invalid(key, value))?); + } + "orderbook-url" => { + let mut url: Url = value.parse().map_err(|_| invalid(key, value))?; + // Path joining relies on a trailing slash. + if !url.path().ends_with('/') { + let path = format!("{}/", url.path()); + url.set_path(&path); + } + base = Some(url); + } + "owner" => { + owner = Some(value.parse::
().map_err(|_| invalid(key, value))?); + } + "http-timeout-ms" => { + let ms: u64 = value.parse().map_err(|_| invalid(key, value))?; + timeout = Duration::from_millis(ms.max(1)); + } + _ => {} + } + } + let chain = chain.ok_or_else(|| { + videre_sdk::Fault::InvalidInput("config requires a chain id".to_owned()) + })?; + Ok(Self { + chain, + base: base.unwrap_or_else(|| chain.orderbook_base_url()), + owner, + timeout, + }) + } +} + +/// Configured adapter state; `init` replaces it whole. +static CONFIG: RwLock> = RwLock::new(None); + +pub(crate) fn store_config(config: AdapterConfig) { + *CONFIG.write().unwrap_or_else(PoisonError::into_inner) = Some(config); +} + +/// The stored config, or a typed refusal when `init` has not run. +pub(crate) fn config() -> Result { + CONFIG + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() + .ok_or_else(|| VenueError::Unavailable("adapter not initialised".to_owned())) +} + +// ── intent functions, transport-injected for host-free tests ───────── + +/// Decode the versioned wire body into its single published intent sum. +fn decode(body: &[u8]) -> Result { + let CowIntentBody::V1(intent) = CowIntentBody::from_bytes(body)?; + Ok(intent) +} + +/// Derive the intent header for `chain`: the sell side gives, the buy +/// side wants, authorisation by intent kind. +pub(crate) fn derive_header_with(chain: u64, body: &[u8]) -> Result { + let intent = decode(body)?; + let (order, authorisation) = match &intent { + CowIntent::Order(order) => (order, AuthScheme::Eip712), + CowIntent::Signed(signed) => (&signed.order, AuthScheme::Eip1271), + }; + Ok(IntentHeader { + gives: AssetAmount::erc20(order.sell_token, order.sell_amount), + wants: AssetAmount::erc20(order.buy_token, order.buy_amount), + settlement: Settlement { chain }, + authorisation, + }) +} + +/// Submit one intent. A signed order posts EIP-1271, its receipt the +/// canonical UID; an unsigned order posts pre-sign, success carrying +/// the `setPreSignature` call. An already-held rejection is success on +/// the client-derived UID. An accepted UID is reconciled against the +/// local derivation; a disagreement is a typed refusal. +pub(crate) fn submit_with( + fetch: &impl Fetch, + config: &AdapterConfig, + body: &[u8], +) -> Result { + match decode(body)? { + CowIntent::Signed(signed) => { + let order = assembly::body_to_order_data(&signed.order); + let owner = signed.owner; + let creation = assembly::build_order_creation(&order, &signed.signature, owner) + .map_err(|e| VenueError::InvalidBody(e.to_string()))?; + let uid = match post_order(fetch, config, &creation)? { + Posted::Accepted(uid) => reconciled_uid(uid, config, &order, owner)?, + // Locally derived and unverified: no UID in the reply. + Posted::AlreadyHeld => assembly::order_uid(config.chain, &order, owner), + }; + Ok(SubmitOutcome::Accepted(uid.as_slice().to_vec())) + } + CowIntent::Order(wire) => { + // Pre-sign needs an owner for `from` and the on-chain call; + // an unconfigured deployment refuses rather than guesses. + let owner = config.owner.ok_or(VenueError::Unsupported)?; + let order = assembly::body_to_order_data(&wire); + let creation = assembly::build_presign_creation(&order, owner) + .map_err(|e| VenueError::InvalidBody(e.to_string()))?; + let uid = match post_order(fetch, config, &creation)? { + Posted::Accepted(uid) => reconciled_uid(uid, config, &order, owner)?, + // Locally derived and unverified: no UID in the reply. + Posted::AlreadyHeld => assembly::order_uid(config.chain, &order, owner), + }; + Ok(SubmitOutcome::RequiresSigning(UnsignedTx { + chain: config.chain.id(), + to: config.chain.settlement().as_slice().to_vec(), + value: Vec::new(), + data: assembly::set_pre_signature_calldata(&uid), + })) + } + } +} + +/// Refuse an accepted receipt whose UID disagrees with the local +/// derivation. +fn reconciled_uid( + server: cowprotocol::OrderUid, + config: &AdapterConfig, + order: &OrderData, + owner: Address, +) -> Result { + let derived = assembly::order_uid(config.chain, order, owner); + if server != derived { + return Err(VenueError::ReceiptMismatch); + } + Ok(server) +} + +/// Poll one receipt's orderbook lifecycle state. +pub(crate) fn status_with( + fetch: &impl Fetch, + config: &AdapterConfig, + receipt: &[u8], +) -> Result { + let uid = OrderUid::try_from(receipt).map_err(|_| VenueError::InvalidReceipt)?; + let url = join(config, &format!("api/v1/orders/{uid}"))?; + let response = call(fetch, http::Method::GET, url, None)?; + if response.status() == http::StatusCode::NOT_FOUND { + // A just-accepted order can lag the read path; not-found stays + // retryable rather than killing the watch. + return Err(VenueError::Unavailable("order not found".to_owned())); + } + if !response.status().is_success() { + return Err(refusal_for_read(&response)); + } + /// The one server field the lifecycle projection reads. + #[derive(Deserialize)] + struct OrderStatusView { + status: OrderStatus, + } + let view: OrderStatusView = serde_json::from_slice(response.body()) + .map_err(|e| VenueError::Unavailable(format!("order decode failed: {e}")))?; + Ok(match view.status { + OrderStatus::PresignaturePending => IntentStatus::Pending, + OrderStatus::Open => IntentStatus::Open, + OrderStatus::Fulfilled => IntentStatus::Fulfilled, + OrderStatus::Cancelled => IntentStatus::Cancelled, + OrderStatus::Expired => IntentStatus::Expired, + }) +} + +/// Price one intent body: an indicative orderbook quote. +pub(crate) fn quote_with( + fetch: &impl Fetch, + config: &AdapterConfig, + body: &[u8], +) -> Result { + let intent = decode(body)?; + let (wire, from) = match &intent { + CowIntent::Order(order) => (order, config.owner.ok_or(VenueError::Unsupported)?), + CowIntent::Signed(signed) => (&signed.order, signed.owner), + }; + let order = assembly::body_to_order_data(wire); + let request = serde_json::to_vec("e_request(&order, from)) + .map_err(|e| VenueError::Unavailable(format!("quote encode failed: {e}")))?; + let response = call( + fetch, + http::Method::POST, + join(config, "api/v1/quote")?, + Some(request), + )?; + if !response.status().is_success() { + return Err(refusal_for_read(&response)); + } + let quoted: cowprotocol::OrderQuoteResponse = serde_json::from_slice(response.body()) + .map_err(|e| VenueError::Unavailable(format!("quote decode failed: {e}")))?; + Ok(Quotation { + gives: AssetAmount::erc20(order.sell_token, quoted.quote.sell_amount), + wants: AssetAmount::erc20(order.buy_token, quoted.quote.buy_amount), + fee: AssetAmount::erc20(order.sell_token, quoted.quote.fee_amount), + valid_until_ms: u64::from(quoted.quote.valid_to).saturating_mul(1000), + }) +} + +/// A quote request pinned to the body's own terms. +fn quote_request(order: &OrderData, from: Address) -> QuoteRequest { + let mut request = match order.kind { + OrderKind::Sell => QuoteRequest::sell_before_fee( + order.sell_token, + order.buy_token, + from, + order.sell_amount, + ), + OrderKind::Buy => { + QuoteRequest::buy_after_fee(order.sell_token, order.buy_token, from, order.buy_amount) + } + }; + request.receiver = order.receiver; + request.valid_to = Some(order.valid_to); + request.app_data = Some(QuoteAppData::Hash(order.app_data)); + request.partially_fillable = Some(order.partially_fillable); + request.sell_token_balance = Some(order.sell_token_balance); + request.buy_token_balance = Some(order.buy_token_balance); + request +} + +// ── orderbook wire plumbing ────────────────────────────────────────── + +/// What a `POST /api/v1/orders` produced. +enum Posted { + /// The orderbook accepted and assigned this UID. + Accepted(cowprotocol::OrderUid), + /// The orderbook already holds this exact order. + AlreadyHeld, +} + +fn post_order( + fetch: &impl Fetch, + config: &AdapterConfig, + creation: &OrderCreation, +) -> Result { + let body = serde_json::to_vec(creation) + .map_err(|e| VenueError::Unavailable(format!("order encode failed: {e}")))?; + let response = call( + fetch, + http::Method::POST, + join(config, "api/v1/orders")?, + Some(body), + )?; + if response.status().is_success() { + let uid: cowprotocol::OrderUid = serde_json::from_slice(response.body()) + .map_err(|e| VenueError::Unavailable(format!("uid decode failed: {e}")))?; + return Ok(Posted::Accepted(uid)); + } + match refusal_for_submit(&response) { + Refusal::AlreadyHeld => Ok(Posted::AlreadyHeld), + Refusal::Error(err) => Err(err), + } +} + +fn join(config: &AdapterConfig, path: &str) -> Result { + config + .base + .join(path) + .map_err(|e| VenueError::Unavailable(format!("orderbook url: {e}"))) +} + +/// One bounded request; transport failures arrive as a typed [`VenueError`]. +fn call( + fetch: &impl Fetch, + method: http::Method, + url: Url, + json: Option>, +) -> Result>, VenueError> { + let mut builder = http::Request::builder().method(method).uri(url.as_str()); + if json.is_some() { + builder = builder.header(http::header::CONTENT_TYPE, "application/json"); + } + let request = builder + .body(json.unwrap_or_default()) + .map_err(|e| VenueError::Unavailable(format!("request build failed: {e}")))?; + Ok(fetch.fetch(request)?) +} + +/// A non-2xx submit reply; already-held is a success shape here. Reads +/// use [`refusal_for_read`] instead. +enum Refusal { + /// Already-held: success wearing an error status. + AlreadyHeld, + /// Everything else, as a reported `venue-error`. + Error(VenueError), +} + +/// Project a non-2xx submit reply: throttles first, server failures +/// stay retryable, and only a structured 4xx envelope reaches the +/// classification table. +fn refusal_for_submit(response: &http::Response>) -> Refusal { + let status = response.status(); + if status == http::StatusCode::TOO_MANY_REQUESTS { + return Refusal::Error(VenueError::RateLimited(RateLimit { + retry_after_ms: retry_after_ms(response), + })); + } + if status.is_server_error() { + return Refusal::Error(VenueError::Unavailable(format!( + "orderbook status {status}" + ))); + } + match serde_json::from_slice::(response.body()) { + Ok(api) if classification::is_already_submitted(api.error_kind()) => Refusal::AlreadyHeld, + Ok(api) => Refusal::Error(classified(&api)), + Err(_) => Refusal::Error(VenueError::Unavailable(format!( + "orderbook status {status}" + ))), + } +} + +/// Project a non-2xx read reply; already-held has no read meaning and +/// collapses to an error. +fn refusal_for_read(response: &http::Response>) -> VenueError { + match refusal_for_submit(response) { + Refusal::AlreadyHeld => VenueError::Unavailable("order already held".to_owned()), + Refusal::Error(err) => err, + } +} + +/// `Retry-After` in milliseconds, when the reply carries the +/// delta-seconds form. +fn retry_after_ms(response: &http::Response>) -> Option { + response + .headers() + .get(http::header::RETRY_AFTER)? + .to_str() + .ok()? + .trim() + .parse::() + .ok() + .map(|seconds| seconds.saturating_mul(1000)) +} + +/// Fold a structured rejection through the shipped table: transient +/// rows retry as `unavailable`, throttle rows carry their backoff as +/// `rate-limited`, permanent rows (and any future action) are `denied`. +fn classified(api: &ApiError) -> VenueError { + let detail = format!("{}: {}", api.error_type, api.description); + let action = match api.error_kind() { + // A wire `errorType` the upstream enum does not know is by + // definition unlisted, hence permanent. + OrderbookApiErrorType::Unknown(_) => RetryAction::Drop, + kind => classification::classify(kind), + }; + match action { + RetryAction::TryNextBlock => VenueError::Unavailable(detail), + RetryAction::Backoff { seconds } => VenueError::RateLimited(RateLimit { + retry_after_ms: Some(seconds.saturating_mul(1000)), + }), + // The one-shot grace is client-side; the wire stays `denied`, + // and the errorType prefix in the detail carries it across. + RetryAction::DropOnRepeat => VenueError::Denied(detail), + RetryAction::Drop => VenueError::Denied(detail), + _ => VenueError::Denied(detail), + } +} + +// The component-ABI export glue only exists on the wasm build; the +// native build keeps the same trait impl (for conformance suites) +// without export symbols no native linker accepts. +#[cfg(not(target_arch = "wasm32"))] +use wit_bindgen as _; + +/// The component face: `#[videre_sdk::venue]` derives the world from +/// `module.toml`; the transport is wasi:http behind the configured +/// [`BoundedFetch`](videre_sdk::transport::BoundedFetch). +mod export { + use videre_sdk::VenueAdapter; + use videre_sdk::transport::BoundedFetch; + use videre_sdk::transport::http::WasiFetch; + #[cfg(not(target_arch = "wasm32"))] + use videre_sdk::{Config, Fault}; + use videre_sdk::{IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueError}; + + use super::{AdapterConfig, CowAdapter}; + + #[cfg_attr(target_arch = "wasm32", videre_sdk::venue)] + impl VenueAdapter for CowAdapter { + fn init(config: Config) -> Result<(), Fault> { + AdapterConfig::parse(&config).map(super::store_config) + } + + fn body_versions() -> Vec { + // Must equal the manifest `[venue] body_versions`; install + // asserts it. + vec![1] + } + + fn derive_header(body: Vec) -> Result { + super::derive_header_with(super::config()?.chain.id(), &body) + } + + fn quote(body: Vec) -> Result { + let config = super::config()?; + super::quote_with( + &BoundedFetch::new(WasiFetch, config.timeout), + &config, + &body, + ) + } + + fn submit(body: Vec) -> Result { + let config = super::config()?; + super::submit_with( + &BoundedFetch::new(WasiFetch, config.timeout), + &config, + &body, + ) + } + + fn status(receipt: Vec) -> Result { + let config = super::config()?; + super::status_with( + &BoundedFetch::new(WasiFetch, config.timeout), + &config, + &receipt, + ) + } + + fn cancel(_receipt: Vec) -> Result<(), VenueError> { + // Off-chain cancellation is an owner-signed request; the + // adapter structurally holds no keys. + Err(VenueError::Unsupported) + } + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::U256; + use videre_sdk::transport::BoundedFetch; + use videre_sdk::transport::http::FetchError; + use videre_sdk::value_flow::{Asset, Erc20}; + use videre_sdk::{IntentBody as _, VenueFault}; + use videre_test::MockFetch; + use videre_test::reconcile::ReconcileFixture; + + use super::*; + use crate::body::{CowIntent, CowIntentBody}; + use crate::order::{BuyToken, OrderBody, SellToken, SignedOrder}; + + const SEPOLIA: u64 = 11_155_111; + const ORDERS: &str = "https://orderbook.test/api/v1/orders"; + const QUOTE: &str = "https://orderbook.test/api/v1/quote"; + + fn config() -> AdapterConfig { + AdapterConfig { + chain: Chain::try_from(SEPOLIA).expect("sepolia is supported"), + base: Url::parse("https://orderbook.test/").expect("test url parses"), + owner: None, + timeout: Duration::from_secs(5), + } + } + + fn with_owner(owner: Address) -> AdapterConfig { + AdapterConfig { + owner: Some(owner), + ..config() + } + } + + fn owner() -> Address { + Address::repeat_byte(0x55) + } + + fn order_body() -> OrderBody { + OrderBody::sell( + SellToken(Address::repeat_byte(0x11)), + U256::from(42u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(41u64), + 1_700_000_000, + ) + .app_data([0x44; 32]) + .build() + } + + fn signed_bytes() -> Vec { + CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_body(), + owner: owner(), + signature: vec![0xC0, 0xFF, 0xEE], + })) + .to_bytes() + .expect("body encodes") + } + + fn order_bytes() -> Vec { + CowIntentBody::V1(CowIntent::Order(order_body())) + .to_bytes() + .expect("body encodes") + } + + fn expected_uid(config: &AdapterConfig) -> cowprotocol::OrderUid { + let order = assembly::body_to_order_data(&order_body()); + assembly::order_uid(config.chain, &order, owner()) + } + + fn reject(fetch: &MockFetch, error_type: &str) { + fetch.respond_to( + http::Method::POST, + ORDERS, + 400, + format!(r#"{{"errorType":"{error_type}","description":"d"}}"#), + ); + } + + // ── config ─────────────────────────────────────────────────────── + + #[test] + fn config_defaults_resolve_from_the_chain() { + let pairs = [("chain".to_owned(), "1".to_owned())]; + let parsed = AdapterConfig::parse(&pairs).expect("chain alone suffices"); + assert_eq!(parsed.chain.id(), 1); + assert_eq!(parsed.base.as_str(), "https://api.cow.fi/mainnet/"); + assert_eq!(parsed.owner, None); + assert_eq!(parsed.timeout, DEFAULT_TIMEOUT); + } + + #[test] + fn config_overrides_parse_and_the_base_gains_its_slash() { + let pairs = [ + ("chain".to_owned(), SEPOLIA.to_string()), + ( + "orderbook-url".to_owned(), + "https://barn.test/sepolia".to_owned(), + ), + ("owner".to_owned(), format!("{:#x}", owner())), + ("http-timeout-ms".to_owned(), "1500".to_owned()), + ("name".to_owned(), "cow".to_owned()), + ]; + let parsed = AdapterConfig::parse(&pairs).expect("overrides parse"); + assert_eq!(parsed.base.as_str(), "https://barn.test/sepolia/"); + assert_eq!(parsed.owner, Some(owner())); + assert_eq!(parsed.timeout, Duration::from_millis(1500)); + } + + #[test] + fn config_refuses_a_missing_or_malformed_chain() { + assert!(matches!( + AdapterConfig::parse(&[]), + Err(videre_sdk::Fault::InvalidInput(_)) + )); + for bad in ["x", "0"] { + let pairs = [("chain".to_owned(), bad.to_owned())]; + assert!(matches!( + AdapterConfig::parse(&pairs), + Err(videre_sdk::Fault::InvalidInput(_)) + )); + } + } + + // ── derive-header ──────────────────────────────────────────────── + + #[test] + fn header_projects_sides_minimally_and_auth_by_kind() { + let header = derive_header_with(SEPOLIA, &signed_bytes()).expect("valid body"); + assert_eq!( + header.gives.asset, + Asset::Erc20(Erc20 { + token: vec![0x11; 20] + }) + ); + assert_eq!(header.gives.amount, vec![42]); + assert_eq!( + header.wants.asset, + Asset::Erc20(Erc20 { + token: vec![0x22; 20] + }) + ); + assert_eq!(header.wants.amount, vec![41]); + assert_eq!(header.settlement.chain, SEPOLIA); + assert!(matches!(header.authorisation, AuthScheme::Eip1271)); + + let presign = derive_header_with(SEPOLIA, &order_bytes()).expect("valid body"); + assert!(matches!(presign.authorisation, AuthScheme::Eip712)); + } + + #[test] + fn header_refuses_a_malformed_body() { + assert!(matches!( + derive_header_with(SEPOLIA, &[9, 9, 9]), + Err(VenueError::InvalidBody(_)) + )); + } + + // ── submit ─────────────────────────────────────────────────────── + + #[test] + fn signed_submit_posts_eip1271_and_returns_the_uid_receipt() { + let config = config(); + let uid = expected_uid(&config); + let fetch = MockFetch::default(); + fetch.respond_to(http::Method::POST, ORDERS, 201, format!("\"{uid}\"")); + + let outcome = submit_with(&fetch, &config, &signed_bytes()).expect("accepted"); + let SubmitOutcome::Accepted(receipt) = outcome else { + panic!("signed submit must accept"); + }; + assert_eq!(receipt, uid.as_slice()); + + let request = fetch.last_request().expect("one request"); + assert_eq!(request.uri, ORDERS); + let posted: serde_json::Value = serde_json::from_slice(&request.body).expect("posted JSON"); + assert_eq!(posted["signingScheme"], "eip1271"); + assert_eq!( + posted["from"].as_str().map(str::to_lowercase), + Some(format!("{:#x}", owner())), + ); + assert_eq!(posted["appData"], format!("0x{}", "44".repeat(32))); + } + + #[test] + fn already_held_is_success_with_the_derived_uid() { + let config = config(); + let fetch = MockFetch::default(); + reject(&fetch, "DuplicatedOrder"); + + let outcome = submit_with(&fetch, &config, &signed_bytes()).expect("held is success"); + let SubmitOutcome::Accepted(receipt) = outcome else { + panic!("already-held must accept"); + }; + assert_eq!(receipt, expected_uid(&config).as_slice()); + } + + #[test] + fn an_accepted_uid_disagreeing_with_the_derivation_is_refused() { + let config = config(); + let mut drifted = expected_uid(&config); + drifted.0[0] ^= 0x01; + let fetch = MockFetch::default(); + fetch.respond_to(http::Method::POST, ORDERS, 201, format!("\"{drifted}\"")); + + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::ReceiptMismatch) + )); + } + + #[test] + fn unsigned_submit_requires_signing_the_presign_call() { + let config = with_owner(owner()); + let uid = expected_uid(&config); + let fetch = MockFetch::default(); + fetch.respond_to(http::Method::POST, ORDERS, 201, format!("\"{uid}\"")); + + let outcome = submit_with(&fetch, &config, &order_bytes()).expect("accepted"); + let SubmitOutcome::RequiresSigning(tx) = outcome else { + panic!("unsigned submit must require signing"); + }; + assert_eq!(tx.chain, SEPOLIA); + assert_eq!(tx.to, config.chain.settlement().as_slice()); + assert!(tx.value.is_empty()); + assert_eq!(tx.data, assembly::set_pre_signature_calldata(&uid)); + + let posted: serde_json::Value = + serde_json::from_slice(&fetch.last_request().expect("one request").body) + .expect("posted JSON"); + assert_eq!(posted["signingScheme"], "presign"); + } + + #[test] + fn unsigned_submit_without_an_owner_is_unsupported() { + let fetch = MockFetch::default(); + assert!(matches!( + submit_with(&fetch, &config(), &order_bytes()), + Err(VenueError::Unsupported) + )); + assert_eq!(fetch.request_count(), 0); + } + + #[test] + fn rejections_project_through_the_classification_table() { + let config = config(); + let fetch = MockFetch::default(); + + reject(&fetch, "InvalidSignature"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Denied(detail)) if detail.contains("InvalidSignature") + )); + + // A drop-on-repeat row stays `denied` on the wire; the + // errorType prefix carries the one-shot grace to the client. + reject(&fetch, "InvalidEip1271Signature"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Denied(detail)) + if detail.starts_with("InvalidEip1271Signature:") + )); + + reject(&fetch, "TooManyLimitOrders"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::RateLimited(rl)) if rl.retry_after_ms == Some(30_000) + )); + + reject(&fetch, "InsufficientFee"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Unavailable(detail)) if detail.contains("InsufficientFee") + )); + } + + #[test] + fn transport_shapes_stay_typed() { + let config = config(); + let fetch = MockFetch::default(); + + fetch.respond_to(http::Method::POST, ORDERS, 429, "slow down"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::RateLimited(rl)) if rl.retry_after_ms.is_none() + )); + + fetch.respond_to(http::Method::POST, ORDERS, 503, "maintenance"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Unavailable(_)) + )); + + fetch.fail_with( + http::Method::POST, + ORDERS, + FetchError::Timeout("first byte".to_owned()), + ); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Timeout) + )); + + fetch.fail_with(http::Method::POST, ORDERS, FetchError::Denied); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Denied(_)) + )); + } + + #[test] + fn requests_ride_the_configured_timeout_bound() { + let config = config(); + let uid = expected_uid(&config); + let fetch = MockFetch::default(); + fetch.respond_to(http::Method::POST, ORDERS, 201, format!("\"{uid}\"")); + + let timed = BoundedFetch::new(&fetch, config.timeout); + submit_with(&timed, &config, &signed_bytes()).expect("accepted"); + let options = fetch.last_request().expect("one request").options; + assert_eq!(options.connect_timeout, config.timeout); + assert_eq!(options.first_byte_timeout, config.timeout); + assert_eq!(options.between_bytes_timeout, config.timeout); + } + + #[test] + fn retry_after_header_survives_as_milliseconds() { + let response = http::Response::builder() + .status(429) + .header(http::header::RETRY_AFTER, "7") + .body(Vec::new()) + .expect("test response builds"); + let Refusal::Error(VenueError::RateLimited(rl)) = refusal_for_submit(&response) else { + panic!("429 must rate-limit"); + }; + assert_eq!(rl.retry_after_ms, Some(7_000)); + } + + // ── status ─────────────────────────────────────────────────────── + + fn status_url(uid: &OrderUid) -> String { + format!("https://orderbook.test/api/v1/orders/{uid}") + } + + #[test] + fn status_maps_the_orderbook_lifecycle() { + let config = config(); + let uid = OrderUid([0xAB; 56]); + let fetch = MockFetch::default(); + for (wire, status) in [ + ("presignaturePending", IntentStatus::Pending), + ("open", IntentStatus::Open), + ("fulfilled", IntentStatus::Fulfilled), + ("cancelled", IntentStatus::Cancelled), + ("expired", IntentStatus::Expired), + ] { + fetch.respond_to( + http::Method::GET, + status_url(&uid), + 200, + format!(r#"{{"status":"{wire}","uid":"{uid}"}}"#), + ); + assert_eq!( + status_with(&fetch, &config, uid.as_bytes()).expect("known status"), + status, + ); + } + } + + #[test] + fn status_refuses_a_short_receipt_and_retries_not_found() { + let config = config(); + let fetch = MockFetch::default(); + assert!(matches!( + status_with(&fetch, &config, &[0xAB; 3]), + Err(VenueError::InvalidReceipt) + )); + + let uid = OrderUid([0xAB; 56]); + fetch.respond_to(http::Method::GET, status_url(&uid), 404, "not found"); + assert!(matches!( + status_with(&fetch, &config, uid.as_bytes()), + Err(VenueError::Unavailable(_)) + )); + } + + // ── quote ──────────────────────────────────────────────────────── + + #[test] + fn quote_prices_the_body_and_pins_its_terms() { + let config = config(); + let fetch = MockFetch::default(); + let quote = serde_json::json!({ + "quote": { + "sellToken": format!("0x{}", "11".repeat(20)), + "buyToken": format!("0x{}", "22".repeat(20)), + "receiver": null, + "sellAmount": "42", + "buyAmount": "40", + "validTo": 1_700_000_000u32, + "appData": format!("0x{}", "44".repeat(32)), + "feeAmount": "2", + "kind": "sell", + "partiallyFillable": false, + "sellTokenBalance": "erc20", + "buyTokenBalance": "erc20", + "signingScheme": "eip1271", + }, + "from": format!("{:#x}", owner()), + "expiration": "2026-01-01T00:00:00Z", + "id": 7, + "verified": true, + }); + fetch.respond_to(http::Method::POST, QUOTE, 200, quote.to_string()); + + let quotation = quote_with(&fetch, &config, &signed_bytes()).expect("quoted"); + assert_eq!(quotation.gives.amount, vec![42]); + assert_eq!(quotation.wants.amount, vec![40]); + assert_eq!(quotation.fee.amount, vec![2]); + assert_eq!(quotation.valid_until_ms, 1_700_000_000_000); + + let posted: serde_json::Value = + serde_json::from_slice(&fetch.last_request().expect("one request").body) + .expect("posted JSON"); + assert_eq!(posted["kind"], "sell"); + assert_eq!(posted["sellAmountBeforeFee"], "42"); + assert_eq!(posted["validTo"], 1_700_000_000u32); + } + + #[test] + fn quote_for_an_unsigned_body_needs_the_configured_owner() { + let fetch = MockFetch::default(); + assert!(matches!( + quote_with(&fetch, &config(), &order_bytes()), + Err(VenueError::Unsupported) + )); + assert_eq!(fetch.request_count(), 0); + } + + // ── reconcile contract ─────────────────────────────────────────── + + /// The shared compliance fixture: one owner-configured config drives + /// both auth paths. + struct CowReconcile; + + impl CowReconcile { + fn cfg() -> AdapterConfig { + with_owner(owner()) + } + + fn uid() -> cowprotocol::OrderUid { + expected_uid(&Self::cfg()) + } + } + + impl ReconcileFixture for CowReconcile { + fn signed_body() -> Vec { + signed_bytes() + } + + fn presign_body() -> Vec { + order_bytes() + } + + fn receipt() -> Vec { + Self::uid().as_slice().to_vec() + } + + fn program_accept(fetch: &MockFetch) { + fetch.respond_to( + http::Method::POST, + ORDERS, + 201, + format!("\"{}\"", Self::uid()), + ); + } + + fn program_already_held(fetch: &MockFetch) { + reject(fetch, "DuplicatedOrder"); + } + + fn program_absent(fetch: &MockFetch) { + let uid = OrderUid::try_from(Self::receipt().as_slice()).expect("uid is 56 bytes"); + fetch.respond_to(http::Method::GET, status_url(&uid), 404, "not found"); + } + + fn submit(fetch: &MockFetch, body: &[u8]) -> Result { + submit_with(fetch, &Self::cfg(), body).map_err(VenueFault::from) + } + + fn status(fetch: &MockFetch, receipt: &[u8]) -> Result { + status_with(fetch, &Self::cfg(), receipt).map_err(VenueFault::from) + } + } + + videre_test::venue_reconcile_compliance!(CowReconcile); +} diff --git a/shepherd/crates/cow-venue/src/assembly.rs b/shepherd/crates/cow-venue/src/assembly.rs new file mode 100644 index 00000000..7f6745c4 --- /dev/null +++ b/shepherd/crates/cow-venue/src/assembly.rs @@ -0,0 +1,319 @@ +//! Chain-edge order assembly: projections between the on-chain +//! `GPv2OrderData` tuple, the typed `OrderData`, and the venue wire +//! [`OrderBody`], plus the orderbook submission bodies built from them. + +use alloy_primitives::{Address, Bytes}; +use alloy_sol_types::SolCall; +use cowprotocol::{ + BuyTokenDestination, Chain, GPv2OrderData, GPv2Settlement, OrderCreation, OrderData, OrderKind, + SellTokenSource, Signature, +}; + +use crate::order::OrderBody; + +/// Project a polled or placed [`GPv2OrderData`] into the typed +/// [`OrderData`]. `None` when a `bytes32` enum marker (`kind`, +/// `sellTokenBalance`, `buyTokenBalance`) is unrecognised; the caller +/// skips the order. `receiver = Address::ZERO` normalises to `None`. +#[must_use] +pub fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { + Some(OrderData { + sell_token: gpv2.sellToken, + buy_token: gpv2.buyToken, + receiver: (gpv2.receiver != Address::ZERO).then_some(gpv2.receiver), + sell_amount: gpv2.sellAmount, + buy_amount: gpv2.buyAmount, + valid_to: gpv2.validTo, + app_data: gpv2.appData, + fee_amount: gpv2.feeAmount, + kind: OrderKind::from_contract_bytes(gpv2.kind)?, + partially_fillable: gpv2.partiallyFillable, + sell_token_balance: SellTokenSource::from_contract_bytes(gpv2.sellTokenBalance)?, + buy_token_balance: BuyTokenDestination::from_contract_bytes(gpv2.buyTokenBalance)?, + }) +} + +/// Orderbook UID hex (`0x` + 112 hex chars) for the on-chain (order, +/// owner, chain) tuple, matching the server-side value so a client can +/// key idempotency before any network work. +/// +/// `None` on an unsupported chain id or an unknown enum marker. Only +/// the unknown marker also stops the submit path; on an unsupported +/// chain a caller keying idempotency here alone re-submits until +/// `validTo`. +#[must_use] +pub fn order_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { + let chain = Chain::try_from(chain_id).ok()?; + let order_data = gpv2_to_order_data(order)?; + Some(format!("{}", order_uid(chain, &order_data, owner))) +} + +/// Canonical 56-byte orderbook UID for `order` under `chain`'s +/// settlement domain. +#[must_use] +pub fn order_uid(chain: Chain, order: &OrderData, owner: Address) -> cowprotocol::OrderUid { + order.uid(&chain.settlement_domain(), owner) +} + +/// Project a typed [`OrderData`] into the venue wire [`OrderBody`]. Total. +#[must_use] +pub fn order_data_to_body(order: &OrderData) -> OrderBody { + OrderBody { + sell_token: order.sell_token, + buy_token: order.buy_token, + receiver: order.receiver, + sell_amount: order.sell_amount, + buy_amount: order.buy_amount, + valid_to: order.valid_to, + app_data: order.app_data.0, + fee_amount: order.fee_amount, + kind: match order.kind { + OrderKind::Sell => crate::order::OrderKind::Sell, + OrderKind::Buy => crate::order::OrderKind::Buy, + }, + partially_fillable: order.partially_fillable, + sell_token_balance: match order.sell_token_balance { + SellTokenSource::Erc20 => crate::order::SellTokenSource::Erc20, + SellTokenSource::External => crate::order::SellTokenSource::External, + SellTokenSource::Internal => crate::order::SellTokenSource::Internal, + }, + buy_token_balance: match order.buy_token_balance { + BuyTokenDestination::Erc20 => crate::order::BuyTokenDestination::Erc20, + BuyTokenDestination::Internal => crate::order::BuyTokenDestination::Internal, + }, + } +} + +/// [`order_data_to_body`]'s total inverse. +#[must_use] +pub fn body_to_order_data(body: &OrderBody) -> OrderData { + OrderData { + sell_token: body.sell_token, + buy_token: body.buy_token, + receiver: body.receiver, + sell_amount: body.sell_amount, + buy_amount: body.buy_amount, + valid_to: body.valid_to, + app_data: body.app_data.into(), + fee_amount: body.fee_amount, + kind: match body.kind { + crate::order::OrderKind::Sell => OrderKind::Sell, + crate::order::OrderKind::Buy => OrderKind::Buy, + }, + partially_fillable: body.partially_fillable, + sell_token_balance: match body.sell_token_balance { + crate::order::SellTokenSource::Erc20 => SellTokenSource::Erc20, + crate::order::SellTokenSource::External => SellTokenSource::External, + crate::order::SellTokenSource::Internal => SellTokenSource::Internal, + }, + buy_token_balance: match body.buy_token_balance { + crate::order::BuyTokenDestination::Erc20 => BuyTokenDestination::Erc20, + crate::order::BuyTokenDestination::Internal => BuyTokenDestination::Internal, + }, + } +} + +/// Assemble the orderbook `OrderCreation` for a polled order: hash-only +/// `appData` wire shape, EIP-1271 signature (the conditional-order +/// contract is the verifier). `Err` is a client-side precondition +/// failure that recurs on retry; the caller drops the watch. +pub fn build_order_creation( + order_data: &OrderData, + signature: &[u8], + from: Address, +) -> Result { + let signature = Signature::Eip1271(signature.to_vec()); + OrderCreation::new_app_data_hash_only(order_data, signature, from, None) +} + +/// Assemble the pre-sign `OrderCreation`: held signature-pending until +/// `from` settles authorisation via [`set_pre_signature_calldata`]. +pub fn build_presign_creation( + order_data: &OrderData, + from: Address, +) -> Result { + OrderCreation::new_app_data_hash_only(order_data, Signature::PreSign, from, None) +} + +/// ABI-encoded `setPreSignature(uid, true)` calldata to activate a +/// pre-sign order. +#[must_use] +pub fn set_pre_signature_calldata(uid: &cowprotocol::OrderUid) -> Vec { + GPv2Settlement::setPreSignatureCall { + orderUid: Bytes::copy_from_slice(uid.as_slice()), + signed: true, + } + .abi_encode() +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{B256, U256, address, keccak256}; + use cowprotocol::GPV2_SETTLEMENT; + + use super::*; + + fn submittable_gpv2() -> GPv2OrderData { + GPv2OrderData { + sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), + buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), + receiver: address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"), + sellAmount: U256::from(1_000_000_u64), + buyAmount: U256::from(999_u64), + validTo: 0xffff_ffff, + appData: cowprotocol::EMPTY_APP_DATA_HASH, + feeAmount: U256::ZERO, + kind: OrderKind::SELL, + partiallyFillable: false, + sellTokenBalance: SellTokenSource::ERC20, + buyTokenBalance: BuyTokenDestination::ERC20, + } + } + + #[test] + fn happy_path_round_trips_markers() { + let g = submittable_gpv2(); + let od = gpv2_to_order_data(&g).expect("known markers"); + assert_eq!(od.sell_token, g.sellToken); + assert_eq!(od.buy_token, g.buyToken); + assert_eq!(od.kind, OrderKind::Sell); + assert_eq!(od.sell_token_balance, SellTokenSource::Erc20); + assert_eq!(od.buy_token_balance, BuyTokenDestination::Erc20); + } + + #[test] + fn zero_receiver_normalises_to_none() { + let mut g = submittable_gpv2(); + g.receiver = Address::ZERO; + assert_eq!(gpv2_to_order_data(&g).unwrap().receiver, None); + } + + #[test] + fn non_zero_receiver_preserved() { + let g = submittable_gpv2(); + assert_eq!(gpv2_to_order_data(&g).unwrap().receiver, Some(g.receiver)); + } + + #[test] + fn unknown_kind_marker_returns_none() { + let mut g = submittable_gpv2(); + g.kind = B256::repeat_byte(0x42); + assert!(gpv2_to_order_data(&g).is_none()); + } + + #[test] + fn unknown_sell_token_balance_returns_none() { + let mut g = submittable_gpv2(); + g.sellTokenBalance = B256::repeat_byte(0x99); + assert!(gpv2_to_order_data(&g).is_none()); + } + + #[test] + fn unknown_buy_token_balance_returns_none() { + let mut g = submittable_gpv2(); + g.buyTokenBalance = B256::repeat_byte(0x55); + assert!(gpv2_to_order_data(&g).is_none()); + } + + #[test] + fn order_data_to_body_projects_every_field() { + let g = submittable_gpv2(); + let order = gpv2_to_order_data(&g).expect("known markers"); + let body = order_data_to_body(&order); + assert_eq!(body.sell_token, g.sellToken); + assert_eq!(body.buy_token, g.buyToken); + assert_eq!(body.receiver, Some(g.receiver)); + assert_eq!(body.sell_amount, g.sellAmount); + assert_eq!(body.buy_amount, g.buyAmount); + assert_eq!(body.valid_to, g.validTo); + assert_eq!(body.app_data, g.appData.0); + assert_eq!(body.fee_amount, g.feeAmount); + assert_eq!(body.kind, crate::order::OrderKind::Sell); + assert!(!body.partially_fillable); + assert_eq!( + body.sell_token_balance, + crate::order::SellTokenSource::Erc20 + ); + assert_eq!( + body.buy_token_balance, + crate::order::BuyTokenDestination::Erc20 + ); + } + + #[test] + fn body_round_trips_back_to_order_data() { + let order = gpv2_to_order_data(&submittable_gpv2()).expect("known markers"); + assert_eq!(body_to_order_data(&order_data_to_body(&order)), order); + + for (kind, sell, buy) in [ + ( + OrderKind::Buy, + SellTokenSource::External, + BuyTokenDestination::Internal, + ), + ( + OrderKind::Sell, + SellTokenSource::Internal, + BuyTokenDestination::Erc20, + ), + ] { + let mut varied = order; + varied.kind = kind; + varied.sell_token_balance = sell; + varied.buy_token_balance = buy; + varied.receiver = None; + assert_eq!(body_to_order_data(&order_data_to_body(&varied)), varied); + } + } + + const SEPOLIA: u64 = 11_155_111; + + #[test] + fn uid_hex_is_deterministic_and_canonical_shape() { + let g = submittable_gpv2(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let uid = order_uid_hex(SEPOLIA, &g, owner).expect("supported chain, known markers"); + // 56 bytes: 32 digest + 20 owner + 4 validTo. + assert_eq!(uid.len(), 2 + 112); + assert!(uid.starts_with("0x")); + assert!( + uid.to_lowercase() + .contains("00112233445566778899aabbccddeeff00112233",) + ); + assert_eq!(order_uid_hex(SEPOLIA, &g, owner).unwrap(), uid); + } + + #[test] + fn uid_hex_none_on_unsupported_chain_or_unknown_marker() { + let g = submittable_gpv2(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + assert!(order_uid_hex(u64::MAX, &g, owner).is_none()); + + let mut bad = submittable_gpv2(); + bad.kind = B256::repeat_byte(0x42); + assert!(order_uid_hex(SEPOLIA, &bad, owner).is_none()); + } + + #[test] + fn presign_creation_carries_the_presign_scheme() { + let order = gpv2_to_order_data(&submittable_gpv2()).expect("known markers"); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let creation = build_presign_creation(&order, owner).expect("valid order"); + assert_eq!(creation.signature, Signature::PreSign); + assert_eq!(creation.from, owner); + + assert!(build_presign_creation(&order, Address::ZERO).is_err()); + } + + #[test] + fn set_pre_signature_calldata_encodes_the_selector_and_uid() { + let order = gpv2_to_order_data(&submittable_gpv2()).expect("known markers"); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let uid = order_uid(Chain::Mainnet, &order, owner); + let data = set_pre_signature_calldata(&uid); + assert_eq!(&data[..4], &keccak256("setPreSignature(bytes,bool)")[..4]); + assert!(data.windows(56).any(|w| w == uid.as_slice())); + // The call targets the deterministic settlement deployment. + assert_ne!(GPV2_SETTLEMENT, Address::ZERO); + } +} diff --git a/shepherd/crates/cow-venue/src/body.rs b/shepherd/crates/cow-venue/src/body.rs new file mode 100644 index 00000000..1b860ab4 --- /dev/null +++ b/shepherd/crates/cow-venue/src/body.rs @@ -0,0 +1,124 @@ +//! The CoW intent body and its versioned `IntentBody` codec. +//! +//! [`CowIntent`] is the intent sum; [`CowIntentBody`] is the outer +//! per-venue version enum, and `#[derive(IntentBody)]` gives it the +//! borsh codec (one-byte version tag plus payload, an unknown tag +//! failing as a typed [`BodyError`](videre_sdk::BodyError)). Tag order +//! is the schema: append new versions, never reorder or remove. + +use borsh::{BorshDeserialize, BorshSerialize}; +use videre_sdk::IntentBody; + +use crate::order::{OrderBody, SignedOrder}; + +/// What the CoW venue accepts: an order for the orderbook. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub enum CowIntent { + /// A direct `GPv2Order` to place on the orderbook. + Order(OrderBody), + /// An owner-signed order with its EIP-1271 signature. + Signed(SignedOrder), +} + +/// The outer per-venue version enum: the schema the CoW venue publishes. +/// Tag order is the schema; append new versions, never reorder. +#[derive(IntentBody, Clone, Debug, PartialEq, Eq)] +pub enum CowIntentBody { + /// First published version: a [`CowIntent`] sum. + V1(CowIntent), +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, U256}; + use videre_test::{CodecVectors, Expectation}; + + use super::*; + use crate::order::{BuyToken, SellToken}; + + fn order_body() -> OrderBody { + OrderBody::sell( + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(2u64), + 1_700_000_000, + ) + .app_data([0x44; 32]) + .partially_fillable() + .build() + } + + /// The codec conformance set: round-trip vectors plus the typed + /// failure contract. + fn vectors() -> CodecVectors { + let mut vectors = CodecVectors::new("cow-venue/cow-intent-body"); + vectors + .push_round_trip( + "v1-order", + &CowIntentBody::V1(CowIntent::Order(order_body())), + ) + .expect("order body encodes"); + vectors + .push_round_trip( + "v1-signed", + &CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_body(), + owner: Address::repeat_byte(0x55), + signature: vec![0xC0, 0xFF, 0xEE], + })), + ) + .expect("signed order encodes"); + + let bytes = |intent: CowIntent| CowIntentBody::V1(intent).to_bytes().expect("body encodes"); + let mut unknown = bytes(CowIntent::Order(order_body())); + unknown[0] = 9; + vectors.push_failure( + "unknown-version", + unknown, + Expectation::UnknownVersion { version: 9 }, + ); + vectors.push_failure("empty", Vec::new(), Expectation::Empty); + let mut truncated = bytes(CowIntent::Order(order_body())); + truncated.truncate(truncated.len() - 1); + vectors.push_failure( + "truncated-payload", + truncated, + Expectation::Malformed { version: 0 }, + ); + let mut trailing = bytes(CowIntent::Order(order_body())); + trailing.push(0); + vectors.push_failure( + "trailing-bytes", + trailing, + Expectation::Malformed { version: 0 }, + ); + vectors + } + + #[test] + fn codec_conforms_to_its_vectors() { + vectors().assert_conforms::(); + } + + #[test] + fn wire_tag_is_the_declaration_index() { + let bytes = CowIntentBody::V1(CowIntent::Order(order_body())) + .to_bytes() + .unwrap(); + assert_eq!(bytes[0], 0); + } + + #[test] + fn divergent_codec_is_caught_by_the_vectors() { + // A vector claiming a different typed failure must fail the + // check, proving it has teeth on this schema. + let mut vectors = CodecVectors::new("cow-venue/cow-intent-body"); + vectors.push_failure( + "empty", + Vec::new(), + Expectation::UnknownVersion { version: 1 }, + ); + assert!(vectors.check::().is_err()); + } +} diff --git a/shepherd/crates/cow-venue/src/classification.rs b/shepherd/crates/cow-venue/src/classification.rs new file mode 100644 index 00000000..18565302 --- /dev/null +++ b/shepherd/crates/cow-venue/src/classification.rs @@ -0,0 +1,346 @@ +//! Table-driven CoW retry classification. +//! +//! The `errorType -> {try-next-block, backoff, drop}` policy ships as +//! data in `data/classification.toml`; `build.rs` validates it and +//! emits the static lookup table [`classify`] and +//! [`is_already_submitted`] read, so no TOML parser reaches the guest. +//! +//! Invariant: an `errorType` absent from the table (including +//! [`OrderbookApiErrorType::Unknown`]) classifies as +//! [`RetryAction::Drop`], a permanent refusal never retried every block. + +use cowprotocol::OrderbookApiErrorType; +use nexum_sdk::keeper::RetryAction; + +/// The shipped classification data, embedded verbatim for the parity test. +pub const CLASSIFICATION_TOML: &str = include_str!("../data/classification.toml"); + +/// The retry action a generated row selects; mapped to a keeper +/// [`RetryAction`] by [`GeneratedRow`]. Which variants appear is a +/// property of the shipped data, hence `allow(dead_code)`. +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GenAction { + TryNextBlock, + Backoff, + DropOnRepeat, + Drop, +} + +/// One classification row, generated from the shipped data table. +#[derive(Clone, Copy, Debug)] +struct GeneratedRow { + error_type: &'static str, + action: GenAction, + backoff_seconds: u64, + already_submitted: bool, +} + +impl GeneratedRow { + fn retry_action(&self) -> RetryAction { + match self.action { + GenAction::TryNextBlock => RetryAction::TryNextBlock, + // `build.rs` validated backoff rows non-zero; `.max(1)` keeps + // the mapping total even if that guard is ever relaxed. + GenAction::Backoff => RetryAction::Backoff { + seconds: self.backoff_seconds.max(1), + }, + GenAction::DropOnRepeat => RetryAction::DropOnRepeat, + GenAction::Drop => RetryAction::Drop, + } + } +} + +// `static GENERATED_ROWS: &[GeneratedRow]`, one row per TOML entry. +include!(concat!(env!("OUT_DIR"), "/classification_table.rs")); + +/// The shipped classification: a lookup over the generated rows. +/// Unlisted types classify as [`RetryAction::Drop`]. +#[derive(Clone, Copy, Debug)] +pub struct ClassificationTable { + rows: &'static [GeneratedRow], +} + +impl ClassificationTable { + /// Find the row for `error_type`; `Unknown` is unlisted by definition. + fn row(&self, error_type: &OrderbookApiErrorType) -> Option<&GeneratedRow> { + match error_type { + OrderbookApiErrorType::Unknown(_) => None, + known => self.rows.iter().find(|r| r.error_type == known.as_str()), + } + } + + /// The retry action for an orderbook `errorType`. Unlisted types + /// (including [`OrderbookApiErrorType::Unknown`]) are permanent: + /// [`RetryAction::Drop`]. + pub fn classify(&self, error_type: &OrderbookApiErrorType) -> RetryAction { + self.row(error_type) + .map_or(RetryAction::Drop, GeneratedRow::retry_action) + } + + /// Whether `error_type` means the orderbook already holds this order. + pub fn is_already_submitted(&self, error_type: &OrderbookApiErrorType) -> bool { + self.row(error_type).is_some_and(|r| r.already_submitted) + } + + /// Number of classified `errorType`s. + pub fn len(&self) -> usize { + self.rows.len() + } + + /// Whether the table carries no entries. + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +/// The classification table generated from the shipped data. +pub fn table() -> ClassificationTable { + ClassificationTable { + rows: GENERATED_ROWS, + } +} + +/// Classify an orderbook `errorType` via the shipped table; unlisted +/// types are permanent ([`RetryAction::Drop`]). +pub fn classify(error_type: OrderbookApiErrorType) -> RetryAction { + table().classify(&error_type) +} + +/// Whether an orderbook `errorType` means the order is already held. +pub fn is_already_submitted(error_type: OrderbookApiErrorType) -> bool { + table().is_already_submitted(&error_type) +} + +/// Retry action for a coarse `denied` refusal: the `{errorType}:` +/// prefix re-enters the table so a [`RetryAction::DropOnRepeat`] row +/// survives the collapse; every other denial is permanent. +pub fn classify_denied(detail: &str) -> RetryAction { + let error_type = detail.split_once(':').map_or(detail, |(prefix, _)| prefix); + match classify(OrderbookApiErrorType::from(error_type)) { + RetryAction::DropOnRepeat => RetryAction::DropOnRepeat, + _ => RetryAction::Drop, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::classification_data::{Action, ClassificationError, parse_and_validate}; + + /// Wire spelling to typed kind, as the adapter's `error_kind()` does. + fn kind(error_type: &str) -> OrderbookApiErrorType { + OrderbookApiErrorType::from(error_type) + } + + /// The generated table is non-empty. + #[test] + fn shipped_data_parses() { + assert!(!table().is_empty()); + } + + /// Data-vs-code parity: the generated table agrees with an + /// independent re-parse of the shipped file on every entry. + #[test] + fn data_matches_code_contract() { + let entries = parse_and_validate(CLASSIFICATION_TOML).expect("shipped data is valid"); + assert_eq!(table().len(), entries.len(), "row count matches the data"); + for entry in &entries { + let expected = match entry.action { + Action::TryNextBlock => RetryAction::TryNextBlock, + Action::Backoff => RetryAction::Backoff { + seconds: entry.backoff_seconds.max(1), + }, + Action::DropOnRepeat => RetryAction::DropOnRepeat, + Action::Drop => RetryAction::Drop, + }; + assert_eq!( + classify(kind(&entry.error_type)), + expected, + "classify {}", + entry.error_type, + ); + assert_eq!( + is_already_submitted(kind(&entry.error_type)), + entry.already_submitted, + "already-submitted {}", + entry.error_type, + ); + } + } + + /// Spot check: the exemplar rows the table must carry. + #[test] + fn known_rows_classify_as_documented() { + assert_eq!(classify(kind("InsufficientFee")), RetryAction::TryNextBlock); + assert_eq!( + classify(kind("TooManyLimitOrders")), + RetryAction::Backoff { seconds: 30 }, + ); + assert_eq!( + classify(kind("InvalidEip1271Signature")), + RetryAction::DropOnRepeat, + ); + assert_eq!(classify(kind("InvalidSignature")), RetryAction::Drop); + assert!(is_already_submitted(kind("DuplicatedOrder"))); + assert!(is_already_submitted(kind("DuplicateOrder"))); + } + + /// Unlisted types are permanent. + #[test] + fn unlisted_type_drops() { + let unknown = kind("NewlyMintedErrorType"); + assert!(matches!(unknown, OrderbookApiErrorType::Unknown(_))); + assert_eq!(classify(unknown.clone()), RetryAction::Drop); + assert!(!is_already_submitted(unknown)); + } + + /// Every retry arm is reachable from the table alone. + #[test] + fn table_reaches_every_arm() { + assert_eq!(classify(kind("InsufficientFee")), RetryAction::TryNextBlock); + assert!(matches!( + classify(kind("TooManyLimitOrders")), + RetryAction::Backoff { .. } + )); + assert_eq!( + classify(kind("InvalidEip1271Signature")), + RetryAction::DropOnRepeat, + ); + assert_eq!(classify(kind("InvalidSignature")), RetryAction::Drop); + } + + /// A denied detail re-enters the table by its `errorType` prefix; + /// only a drop-on-repeat row escapes the permanent default. + #[test] + fn denied_detail_refines_by_error_type_prefix() { + assert_eq!( + classify_denied("InvalidEip1271Signature: signature is not valid"), + RetryAction::DropOnRepeat, + ); + assert_eq!( + classify_denied("InvalidSignature: bad sig"), + RetryAction::Drop, + ); + assert_eq!( + classify_denied("InsufficientFee: too low"), + RetryAction::Drop, + ); + assert_eq!(classify_denied("policy refusal"), RetryAction::Drop); + assert_eq!(classify_denied(""), RetryAction::Drop); + } + + #[test] + fn duplicate_type_is_rejected() { + let toml = r#" + [[entry]] + error-type = "Dup" + action = "drop" + [[entry]] + error-type = "Dup" + action = "drop" + "#; + assert_eq!( + parse_and_validate(toml).unwrap_err(), + ClassificationError::Duplicate("Dup".to_string()), + ); + } + + #[test] + fn backoff_without_delay_is_rejected() { + let toml = r#" + [[entry]] + error-type = "Slow" + action = "backoff" + "#; + assert_eq!( + parse_and_validate(toml).unwrap_err(), + ClassificationError::ZeroBackoff("Slow".to_string()), + ); + } + + #[test] + fn already_submitted_must_try_next_block() { + let toml = r#" + [[entry]] + error-type = "Held" + action = "drop" + already-submitted = true + "#; + assert_eq!( + parse_and_validate(toml).unwrap_err(), + ClassificationError::AlreadySubmittedAction("Held".to_string()), + ); + } + + /// Every listed `error-type` names a real `errorType` in its exact + /// wire spelling. + #[test] + fn every_row_names_a_real_error_type() { + let entries = parse_and_validate(CLASSIFICATION_TOML).expect("shipped data is valid"); + for entry in &entries { + let kind = cowprotocol::OrderbookApiErrorType::from(entry.error_type.as_str()); + assert!( + !matches!(kind, cowprotocol::OrderbookApiErrorType::Unknown(_)), + "phantom errorType {}", + entry.error_type, + ); + assert_eq!(kind.as_str(), entry.error_type, "wire spelling"); + } + } + + /// The table's divergence from `retry_hint()` is exactly the + /// ratified set; a change forces re-ratification. + #[test] + fn divergence_from_upstream_is_exactly_the_ratified_set() { + const RATIFIED: [&str; 5] = [ + "InsufficientAllowance", + "InsufficientBalance", + "InvalidAppData", + "InvalidEip1271Signature", + "TooManyLimitOrders", + ]; + let entries = parse_and_validate(CLASSIFICATION_TOML).expect("shipped data is valid"); + let mut divergent: Vec<&str> = Vec::new(); + for entry in &entries { + let api = cowprotocol::ApiError { + error_type: entry.error_type.clone(), + description: String::new(), + data: None, + }; + // Project the upstream hint into the table's model; a hint + // variant this projection does not know is a divergence. + let upstream = match api.retry_hint() { + cowprotocol::RetryHint::Retry => Some((RetryAction::TryNextBlock, false)), + cowprotocol::RetryHint::Backoff { seconds } => { + Some((RetryAction::Backoff { seconds }, false)) + } + cowprotocol::RetryHint::Drop => Some((RetryAction::Drop, false)), + cowprotocol::RetryHint::AlreadySubmitted => Some((RetryAction::TryNextBlock, true)), + _ => None, + }; + let shepherd = ( + classify(kind(&entry.error_type)), + is_already_submitted(kind(&entry.error_type)), + ); + if upstream != Some(shepherd) { + divergent.push(&entry.error_type); + } + } + divergent.sort_unstable(); + assert_eq!(divergent, RATIFIED); + } + + /// The shipped file parses with an untyped TOML model, so any TOML + /// library reads it. + #[test] + fn non_rust_reader_sees_plain_toml() { + let value: toml::Table = + toml::from_str(CLASSIFICATION_TOML).expect("valid TOML for any reader"); + let entries = value["entry"].as_array().expect("entry is an array"); + assert!(!entries.is_empty()); + let first = entries[0].as_table().expect("entry is a table"); + assert!(first.contains_key("error-type")); + assert!(first.contains_key("action")); + } +} diff --git a/shepherd/crates/cow-venue/src/classification_data.rs b/shepherd/crates/cow-venue/src/classification_data.rs new file mode 100644 index 00000000..b02f67b8 --- /dev/null +++ b/shepherd/crates/cow-venue/src/classification_data.rs @@ -0,0 +1,87 @@ +//! Parse and validate the shipped classification data. +//! +//! The single source of the TOML schema and table invariants, compiled +//! by `build.rs` (to generate the lookup table) and by the tests (to +//! re-parse and check parity), never into a guest. + +use serde::Deserialize; + +/// One of the retry actions an `errorType` maps to on the wire. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Action { + /// Transient: a fresh submission on a later block may succeed. + TryNextBlock, + /// Throttle: gate the watch for `backoff_seconds` before retrying. + Backoff, + /// Permanent unless first-seen: retry on the next block once, then + /// drop on a repeat at a later block. + DropOnRepeat, + /// Permanent: remove the watch and its gates. + Drop, +} + +/// A single classification row as it appears in the TOML. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct Entry { + /// The orderbook `errorType` this row classifies. + pub error_type: String, + /// The retry action the row selects. + pub action: Action, + /// Required (and meaningful) only for `action = "backoff"`. + #[serde(default)] + pub backoff_seconds: u64, + /// Marks a rejection meaning the orderbook already holds this order. + #[serde(default)] + pub already_submitted: bool, +} + +#[derive(Debug, Deserialize)] +struct Document { + #[serde(default)] + entry: Vec, +} + +/// Why the shipped classification data could not be turned into a table. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] +pub enum ClassificationError { + /// The TOML did not parse or a field had the wrong type. + #[error("classification data is not valid TOML: {0}")] + Toml(String), + /// Two entries named the same `errorType`. + #[error("duplicate errorType `{0}` in classification data")] + Duplicate(String), + /// A `backoff` entry left `backoff-seconds` at zero (or absent). + #[error("errorType `{0}` is backoff but backoff-seconds is not >= 1")] + ZeroBackoff(String), + /// An `already-submitted` entry did not classify as try-next-block. + #[error("errorType `{0}` is already-submitted but action is not try-next-block")] + AlreadySubmittedAction(String), +} + +/// Parse a classification document and validate the table invariants: no +/// duplicate `errorType`, every `backoff` carries a positive delay, and +/// `already-submitted` implies `try-next-block`. +pub fn parse_and_validate(toml: &str) -> Result, ClassificationError> { + let doc: Document = + toml::from_str(toml).map_err(|e| ClassificationError::Toml(e.to_string()))?; + + let mut seen: Vec<&str> = Vec::with_capacity(doc.entry.len()); + for entry in &doc.entry { + if entry.action == Action::Backoff && entry.backoff_seconds == 0 { + return Err(ClassificationError::ZeroBackoff(entry.error_type.clone())); + } + if entry.already_submitted && entry.action != Action::TryNextBlock { + return Err(ClassificationError::AlreadySubmittedAction( + entry.error_type.clone(), + )); + } + if seen.contains(&entry.error_type.as_str()) { + return Err(ClassificationError::Duplicate(entry.error_type.clone())); + } + seen.push(&entry.error_type); + } + Ok(doc.entry) +} diff --git a/shepherd/crates/cow-venue/src/client.rs b/shepherd/crates/cow-venue/src/client.rs new file mode 100644 index 00000000..5fe0e19e --- /dev/null +++ b/shepherd/crates/cow-venue/src/client.rs @@ -0,0 +1,162 @@ +//! The CoW venue as a keeper types it. +//! +//! [`CowVenue`] names the venue: the id its adapter registers under and +//! the [`CowIntentBody`] schema, so keeper code drives it through +//! [`VenueClient`] with typed bodies. The retry +//! [`classify`](crate::classification::classify) API ships in the same +//! slice so client and classification version together. + +use videre_sdk::client::{HostVenues, Venue, VenueClient}; +use videre_sdk::keeper::submission_key; +use videre_sdk::{BodyError, IntentBody as _}; + +use crate::body::CowIntentBody; + +/// The CoW venue marker: `CowClient` calls route to [`Venue::ID`] and +/// encode a [`CowIntentBody`]; a receipt is the canonical +/// [`OrderUid`](crate::OrderUid) in wire form. +#[derive(Clone, Copy, Debug)] +pub struct CowVenue; + +// The id is held to `module.toml`'s `[module] name` at expansion. +#[videre_sdk::venue(id = "cow", body = CowIntentBody)] +impl Venue for CowVenue {} + +/// A typed client pre-bound to the CoW venue. +pub type CowClient = VenueClient; + +/// Deterministic intent-id for `body`: [`submission_key`] bound to +/// [`CowVenue::ID`], derivable before any network work. It covers the +/// encoded body, so a signed payload +/// ([`CowIntent::Signed`](crate::CowIntent::Signed)) keys on its +/// signature, not the economic order. +pub fn intent_id(body: &CowIntentBody) -> Result { + Ok(submission_key(&CowVenue::ID, &body.to_bytes()?)) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use videre_sdk::client::{VenueId, VenueTransport}; + use videre_sdk::{IntentStatus, Quotation, SubmitOutcome, VenueFault}; + + use super::*; + + /// One recorded submit: the venue it routed to and the wire bytes. + type SubmitLog = Rc)>>>; + + /// Records the venue every call routed to and the bytes submitted. + /// Cloneable over a shared log so the test can inspect it after the + /// handle moves into the client. + #[derive(Clone, Default)] + struct SpyClient { + submitted: SubmitLog, + } + + impl videre_sdk::client::sealed::SealedTransport for SpyClient {} + + impl VenueTransport for SpyClient { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit( + &self, + venue: &VenueId, + body: Vec, + ) -> Result { + self.submitted + .borrow_mut() + .push((venue.to_string(), body.clone())); + Ok(SubmitOutcome::Accepted(body)) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + fn sample_body() -> CowIntentBody { + use alloy_primitives::{Address, U256}; + + use crate::body::CowIntent; + use crate::order::{BuyToken, OrderBody, SellToken}; + CowIntentBody::V1(CowIntent::Order( + OrderBody::sell( + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(2u64), + 1_700_000_000, + ) + .app_data([0x44; 32]) + .partially_fillable() + .build(), + )) + } + + #[test] + fn intent_id_is_deterministic_and_body_scoped() { + use alloy_primitives::{Address, U256}; + use videre_sdk::IntentBody; + + use crate::body::CowIntent; + use crate::order::{BuyToken, OrderBody, SellToken, SignedOrder}; + + let body = sample_body(); + let id = intent_id(&body).expect("body encodes"); + assert_eq!(id, intent_id(&body.clone()).expect("body encodes")); + assert_eq!( + id, + submission_key(&CowVenue::ID, &body.to_bytes().expect("body encodes")), + "the id must be exactly the key the generic run journals", + ); + assert!(id.starts_with("cow:0x")); + + let other = CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: OrderBody::sell( + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(2u64), + 1_700_000_000, + ) + .build(), + owner: Address::repeat_byte(0x55), + signature: vec![0xC0], + })); + assert_ne!(id, intent_id(&other).expect("body encodes")); + } + + #[test] + fn submit_routes_to_the_cow_venue_with_encoded_body() { + use videre_sdk::IntentBody; + + let spy = SpyClient::default(); + let body = sample_body(); + let expected = body.to_bytes().expect("body encodes"); + + let client = CowClient::with_transport(spy.clone()); + assert_eq!(client.venue(), CowVenue::ID); + let std::task::Poll::Ready(result) = videre_sdk::client::poll_once(client.submit(&body)) + else { + panic!("guest futures complete in one poll"); + }; + result.expect("submit succeeds"); + + let calls = spy.submitted.borrow(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, CowVenue::ID.as_str()); + assert_eq!(calls[0].1, expected); + } +} diff --git a/shepherd/crates/cow-venue/src/lib.rs b/shepherd/crates/cow-venue/src/lib.rs new file mode 100644 index 00000000..03f6a63f --- /dev/null +++ b/shepherd/crates/cow-venue/src/lib.rs @@ -0,0 +1,58 @@ +//! # cow-venue +//! +//! The CoW venue, staged as feature slices. `body` (default) carries the +//! venue-neutral order body types and their borsh +//! [`IntentBody`](videre_sdk::IntentBody) codec. `client` adds the typed +//! `CowClient`, the deterministic `intent_id` journal key, and the +//! table-driven retry `classification` generated at build time from +//! `data/classification.toml`. `assembly` carries the chain-edge order +//! projections; `adapter` is the `venue-adapter` component +//! (`CowAdapter`) built for wasm32-wasip2, never linked by a keeper +//! module. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] +// wit_bindgen::generate! expands to host-import shims whose arity can +// exceed clippy's too-many-arguments threshold. +#![cfg_attr(feature = "adapter", allow(clippy::too_many_arguments))] + +#[cfg(feature = "body")] +pub mod body; + +#[cfg(feature = "body")] +pub mod order; + +#[cfg(feature = "adapter")] +pub mod adapter; + +#[cfg(feature = "assembly")] +pub mod assembly; + +#[cfg(feature = "client")] +pub mod classification; + +// The shared TOML parse and table invariants. `build.rs` includes this +// file to generate the classification table; the crate links it only in +// tests, to re-parse the shipped data and check parity. It never reaches +// a guest. +#[cfg(all(feature = "client", test))] +mod classification_data; + +#[cfg(feature = "client")] +pub mod client; + +#[cfg(feature = "body")] +pub use body::{CowIntent, CowIntentBody}; +#[cfg(feature = "body")] +pub use order::{ + BuyToken, BuyTokenDestination, OrderBody, OrderBuilder, OrderKind, OrderUid, SellToken, + SellTokenSource, SignedOrder, +}; + +#[cfg(feature = "adapter")] +pub use adapter::CowAdapter; + +#[cfg(feature = "client")] +pub use classification::{ClassificationTable, classify, classify_denied, is_already_submitted}; +#[cfg(feature = "client")] +pub use client::{CowClient, CowVenue, intent_id}; diff --git a/shepherd/crates/cow-venue/src/order.rs b/shepherd/crates/cow-venue/src/order.rs new file mode 100644 index 00000000..081a9728 --- /dev/null +++ b/shepherd/crates/cow-venue/src/order.rs @@ -0,0 +1,488 @@ +//! The venue-neutral CoW order body. +//! +//! The 12-field `GPv2Order` tuple over the alloy primitives, so it +//! borsh-encodes without the on-chain stack. The marker enums are +//! canonical wire forms, not on-chain keccak markers; the adapter owns +//! the projection to and from chain. + +use core::fmt; + +use alloy_primitives::{Address, U256}; +use borsh::{BorshDeserialize, BorshSerialize}; + +/// The token an order sells, typed against side swaps. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SellToken(pub Address); + +impl From
for SellToken { + fn from(address: Address) -> Self { + Self(address) + } +} + +/// The token an order buys, typed against side swaps. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BuyToken(pub Address); + +impl From
for BuyToken { + fn from(address: Address) -> Self { + Self(address) + } +} + +/// Which side of the trade is fixed. +#[derive(BorshSerialize, BorshDeserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum OrderKind { + /// Sell a fixed `sell_amount`; `buy_amount` is the limit. + Sell, + /// Buy a fixed `buy_amount`; `sell_amount` is the limit. + Buy, +} + +/// Where the settlement pulls the sell token from. +#[derive(BorshSerialize, BorshDeserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum SellTokenSource { + /// Ordinary ERC-20 `transferFrom`. + Erc20, + /// Balancer external balance. + External, + /// Balancer internal balance. + Internal, +} + +/// Where the settlement delivers the buy token. +#[derive(BorshSerialize, BorshDeserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum BuyTokenDestination { + /// Ordinary ERC-20 transfer. + Erc20, + /// Balancer internal balance. + Internal, +} + +/// The venue-neutral order body: the `GPv2Order` fields in wire form. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub struct OrderBody { + /// Token the owner sells. + pub sell_token: Address, + /// Token the owner buys. + pub buy_token: Address, + /// Recipient of the buy token; `None` sends it back to the owner. + pub receiver: Option
, + /// Sell amount, or its limit when `kind` is `Buy`. + pub sell_amount: U256, + /// Buy amount, or its limit when `kind` is `Sell`. + pub buy_amount: U256, + /// Unix-seconds expiry. + pub valid_to: u32, + /// The 32-byte on-chain app-data hash. + pub app_data: [u8; 32], + /// Fee amount taken in the sell token. + pub fee_amount: U256, + /// Which side is fixed. + pub kind: OrderKind, + /// Whether the order may partially fill. + pub partially_fillable: bool, + /// Where the sell token is sourced from. + pub sell_token_balance: SellTokenSource, + /// Where the buy token is delivered to. + pub buy_token_balance: BuyTokenDestination, +} + +impl OrderBody { + /// A sell order: `sell_amount` fixed, at least `buy_amount` in + /// return, expiring at `valid_to`. + #[must_use] + pub const fn sell( + sell: SellToken, + sell_amount: U256, + buy: BuyToken, + buy_amount: U256, + valid_to: u32, + ) -> OrderBuilder { + OrderBuilder::new( + OrderKind::Sell, + sell.0, + sell_amount, + buy.0, + buy_amount, + valid_to, + ) + } + + /// A buy order: `buy_amount` fixed, spending at most `sell_amount`, + /// expiring at `valid_to`. + #[must_use] + pub const fn buy( + buy: BuyToken, + buy_amount: U256, + sell: SellToken, + sell_amount: U256, + valid_to: u32, + ) -> OrderBuilder { + OrderBuilder::new( + OrderKind::Buy, + sell.0, + sell_amount, + buy.0, + buy_amount, + valid_to, + ) + } +} + +/// Builder for [`OrderBody`]: required fields are constructor args, the +/// optionals default (self-receive, zero `app_data`/`fee_amount`, +/// fill-or-kill, ERC-20 balances). Start from [`OrderBody::sell`] or +/// [`OrderBody::buy`]. +#[derive(Clone, Debug)] +pub struct OrderBuilder { + body: OrderBody, +} + +impl OrderBuilder { + const fn new( + kind: OrderKind, + sell_token: Address, + sell_amount: U256, + buy_token: Address, + buy_amount: U256, + valid_to: u32, + ) -> Self { + Self { + body: OrderBody { + sell_token, + buy_token, + receiver: None, + sell_amount, + buy_amount, + valid_to, + app_data: [0; 32], + fee_amount: U256::ZERO, + kind, + partially_fillable: false, + sell_token_balance: SellTokenSource::Erc20, + buy_token_balance: BuyTokenDestination::Erc20, + }, + } + } + + /// Set the absolute `validTo` (Unix seconds), overriding the + /// constructor. + #[must_use] + pub const fn valid_to(mut self, secs: u32) -> Self { + self.body.valid_to = secs; + self + } + + /// Expire `duration` seconds after `now`, saturating at `u32::MAX`. + /// `now` is the block timestamp, not a wall clock: `valid_to` feeds + /// the submission dedup key, so a wall clock would break replay + /// idempotency. + #[must_use] + pub const fn valid_for(mut self, now: u32, duration: u32) -> Self { + self.body.valid_to = now.saturating_add(duration); + self + } + + /// Deliver the buy token to `receiver` instead of the owner. + #[must_use] + pub const fn receiver(mut self, receiver: Address) -> Self { + self.body.receiver = Some(receiver); + self + } + + /// Set the 32-byte on-chain app-data hash. + #[must_use] + pub const fn app_data(mut self, app_data: [u8; 32]) -> Self { + self.body.app_data = app_data; + self + } + + /// Set the fee taken in the sell token. + #[must_use] + pub const fn fee_amount(mut self, fee_amount: U256) -> Self { + self.body.fee_amount = fee_amount; + self + } + + /// Allow the order to fill partially. + #[must_use] + pub const fn partially_fillable(mut self) -> Self { + self.body.partially_fillable = true; + self + } + + /// Source the sell token from `source`. + #[must_use] + pub const fn sell_token_balance(mut self, source: SellTokenSource) -> Self { + self.body.sell_token_balance = source; + self + } + + /// Deliver the buy token to `destination`. + #[must_use] + pub const fn buy_token_balance(mut self, destination: BuyTokenDestination) -> Self { + self.body.buy_token_balance = destination; + self + } + + /// The finished body. + #[must_use] + pub const fn build(self) -> OrderBody { + self.body + } +} + +/// An owner-signed order ready for the orderbook. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub struct SignedOrder { + /// The order to place. + pub order: OrderBody, + /// Order owner: the EIP-1271 verifier and the `from` of the + /// orderbook submission. + pub owner: Address, + /// Raw EIP-1271 signature bytes; the settlement verifies them + /// against `owner`. + pub signature: Vec, +} + +/// Canonical 56-byte orderbook UID (order digest, owner, `valid_to`) +/// in wire form. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct OrderUid(pub [u8; 56]); + +impl OrderUid { + /// The raw 56 bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 56] { + &self.0 + } +} + +impl From<[u8; 56]> for OrderUid { + fn from(bytes: [u8; 56]) -> Self { + Self(bytes) + } +} + +impl TryFrom<&[u8]> for OrderUid { + type Error = core::array::TryFromSliceError; + + fn try_from(bytes: &[u8]) -> Result { + Ok(Self(<[u8; 56]>::try_from(bytes)?)) + } +} + +impl From for Vec { + fn from(uid: OrderUid) -> Self { + uid.0.to_vec() + } +} + +impl fmt::Display for OrderUid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("0x")?; + for byte in self.0 { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +impl fmt::Debug for OrderUid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> OrderBody { + OrderBody { + sell_token: Address::repeat_byte(0x11), + buy_token: Address::repeat_byte(0x22), + receiver: Some(Address::repeat_byte(0x33)), + sell_amount: U256::from(0x2a_u64), + buy_amount: U256::MAX, + valid_to: 0xffff_ffff, + app_data: [0x44; 32], + fee_amount: U256::ZERO, + kind: OrderKind::Sell, + partially_fillable: false, + sell_token_balance: SellTokenSource::Erc20, + buy_token_balance: BuyTokenDestination::Erc20, + } + } + + #[test] + fn sell_builder_matches_the_literal() { + let built = OrderBody::sell( + SellToken(Address::repeat_byte(0x11)), + sample().sell_amount, + BuyToken(Address::repeat_byte(0x22)), + U256::MAX, + 0xffff_ffff, + ) + .receiver(Address::repeat_byte(0x33)) + .app_data([0x44; 32]) + .build(); + assert_eq!(built, sample()); + } + + #[test] + fn buy_builder_fixes_the_buy_side() { + let built = OrderBody::buy( + BuyToken(Address::repeat_byte(0x22)), + U256::MAX, + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), + 100, + ) + .partially_fillable() + .sell_token_balance(SellTokenSource::External) + .buy_token_balance(BuyTokenDestination::Internal) + .fee_amount(U256::from(5u64)) + .build(); + assert_eq!(built.kind, OrderKind::Buy); + assert_eq!(built.sell_token, Address::repeat_byte(0x11)); + assert_eq!(built.buy_token, Address::repeat_byte(0x22)); + assert_eq!(built.sell_amount, U256::from(1u64)); + assert_eq!(built.buy_amount, U256::MAX); + assert_eq!(built.valid_to, 100); + assert!(built.partially_fillable); + assert_eq!(built.sell_token_balance, SellTokenSource::External); + assert_eq!(built.buy_token_balance, BuyTokenDestination::Internal); + assert_eq!(built.fee_amount, U256::from(5u64)); + assert_eq!(built.receiver, None); + } + + #[test] + fn builder_defaults_are_the_wire_defaults() { + let built = OrderBody::sell( + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(2u64), + 1, + ) + .build(); + assert_eq!(built.receiver, None); + assert_eq!(built.app_data, [0; 32]); + assert_eq!(built.fee_amount, U256::ZERO); + assert!(!built.partially_fillable); + assert_eq!(built.sell_token_balance, SellTokenSource::Erc20); + assert_eq!(built.buy_token_balance, BuyTokenDestination::Erc20); + assert_eq!(built.kind, OrderKind::Sell); + } + + #[test] + fn valid_to_setter_overrides_the_constructor_argument() { + let built = OrderBody::sell( + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(2u64), + 1, + ) + .valid_to(0x1234_5678) + .build(); + assert_eq!(built.valid_to, 0x1234_5678); + } + + #[test] + fn valid_for_adds_the_duration_to_now() { + let built = OrderBody::sell( + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(2u64), + 1, + ) + .valid_for(1_700_000_000, 3_600) + .build(); + assert_eq!(built.valid_to, 1_700_003_600); + } + + #[test] + fn valid_for_saturates_on_overflow() { + let built = OrderBody::sell( + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(2u64), + 1, + ) + .valid_for(u32::MAX - 10, 3_600) + .build(); + assert_eq!(built.valid_to, u32::MAX); + } + + #[test] + fn order_body_borsh_round_trips() { + let body = sample(); + let bytes = borsh::to_vec(&body).expect("encode"); + assert_eq!(OrderBody::try_from_slice(&bytes).expect("decode"), body); + } + + #[test] + fn none_receiver_round_trips() { + let mut body = sample(); + body.receiver = None; + let bytes = borsh::to_vec(&body).expect("encode"); + assert_eq!(OrderBody::try_from_slice(&bytes).unwrap().receiver, None); + } + + #[test] + fn marker_enums_round_trip() { + for kind in [OrderKind::Sell, OrderKind::Buy] { + for sell in [ + SellTokenSource::Erc20, + SellTokenSource::External, + SellTokenSource::Internal, + ] { + for buy in [BuyTokenDestination::Erc20, BuyTokenDestination::Internal] { + let mut body = sample(); + body.kind = kind; + body.sell_token_balance = sell; + body.buy_token_balance = buy; + let bytes = borsh::to_vec(&body).unwrap(); + assert_eq!(OrderBody::try_from_slice(&bytes).unwrap(), body); + } + } + } + } + + #[test] + fn signed_order_borsh_round_trips() { + let signed = SignedOrder { + order: sample(), + owner: Address::repeat_byte(0x55), + signature: vec![0xC0, 0xFF, 0xEE], + }; + let bytes = borsh::to_vec(&signed).expect("encode"); + assert_eq!(SignedOrder::try_from_slice(&bytes).expect("decode"), signed); + } + + #[test] + fn order_uid_converts_only_from_56_bytes() { + let uid = OrderUid([0xAB; 56]); + assert_eq!(OrderUid::try_from(&uid.0[..]).expect("56 bytes"), uid); + assert!(OrderUid::try_from(&uid.0[..55]).is_err()); + assert_eq!(Vec::from(uid), vec![0xAB; 56]); + } + + #[test] + fn order_uid_displays_as_prefixed_hex() { + let mut bytes = [0u8; 56]; + bytes[0] = 0x01; + bytes[55] = 0xFF; + let uid = OrderUid(bytes); + let hex = uid.to_string(); + assert_eq!(hex.len(), 2 + 56 * 2); + assert!(hex.starts_with("0x01")); + assert!(hex.ends_with("ff")); + assert_eq!(format!("{uid:?}"), hex); + } +} diff --git a/shepherd/crates/cow-venue/tests/conformance.rs b/shepherd/crates/cow-venue/tests/conformance.rs new file mode 100644 index 00000000..24efdd12 --- /dev/null +++ b/shepherd/crates/cow-venue/tests/conformance.rs @@ -0,0 +1,158 @@ +//! Published-fixture conformance: the codec vectors and header goldens +//! under `tests/vectors/` replayed through the shipped body codec and +//! the adapter's own derivation. The files are the contract a non-Rust +//! adapter author reads. + +use alloy_primitives::{Address, U256}; +use cow_venue::{ + BuyToken, CowAdapter, CowIntent, CowIntentBody, OrderBody, SellToken, SignedOrder, +}; +use videre_sdk::{IntentBody as _, VenueAdapter}; +use videre_test::{CodecVectors, Expectation, HeaderGoldens}; + +fn fixture(name: &str) -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/vectors") + .join(name) +} + +/// The one order every published fixture carries. +fn order_body() -> OrderBody { + OrderBody::sell( + SellToken(Address::repeat_byte(0x11)), + U256::from(42u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(41u64), + 1_700_000_000, + ) + .app_data([0x44; 32]) + .build() +} + +fn signed_order() -> SignedOrder { + SignedOrder { + order: order_body(), + owner: Address::repeat_byte(0x55), + signature: vec![0xC0, 0xFF, 0xEE], + } +} + +fn encoded(intent: CowIntent) -> Vec { + CowIntentBody::V1(intent).to_bytes().expect("body encodes") +} + +/// Rebuild the published codec vectors from the shipped codec. +fn build_codec_vectors() -> CodecVectors { + let mut vectors = CodecVectors::new("cow-venue/cow-intent-body"); + vectors + .push_round_trip( + "v1-order", + &CowIntentBody::V1(CowIntent::Order(order_body())), + ) + .expect("order body encodes"); + vectors + .push_round_trip( + "v1-signed", + &CowIntentBody::V1(CowIntent::Signed(signed_order())), + ) + .expect("signed order encodes"); + let mut unknown = encoded(CowIntent::Order(order_body())); + unknown[0] = 9; + vectors.push_failure( + "unknown-version", + unknown, + Expectation::UnknownVersion { version: 9 }, + ); + vectors.push_failure("empty", Vec::new(), Expectation::Empty); + let mut truncated = encoded(CowIntent::Order(order_body())); + truncated.truncate(truncated.len() - 1); + vectors.push_failure( + "truncated-payload", + truncated, + Expectation::Malformed { version: 0 }, + ); + let mut trailing = encoded(CowIntent::Order(order_body())); + trailing.push(0); + vectors.push_failure( + "trailing-bytes", + trailing, + Expectation::Malformed { version: 0 }, + ); + vectors +} + +/// Rebuild the published header goldens through the adapter's own +/// mainnet-configured derivation. +fn build_header_goldens() -> HeaderGoldens { + CowAdapter::init(vec![("chain".to_owned(), "1".to_owned())]).expect("config parses"); + let mut goldens = HeaderGoldens::new("cow"); + goldens + .record( + "v1-order-presign", + encoded(CowIntent::Order(order_body())), + CowAdapter::derive_header, + ) + .expect("header derives") + .notes = Some("unsigned order: authorised by host-held keys (pre-sign)".to_owned()); + goldens + .record( + "v1-signed", + encoded(CowIntent::Signed(signed_order())), + CowAdapter::derive_header, + ) + .expect("header derives") + .notes = Some("owner-signed order: EIP-1271".to_owned()); + goldens +} + +#[test] +fn codec_conforms_to_the_published_vectors() { + let vectors = CodecVectors::load(fixture("cow-intent-body.json")).expect("vectors parse"); + vectors.assert_conforms::(); +} + +#[test] +fn derive_header_conforms_to_the_published_goldens() { + // The goldens pin mainnet; init drives the same configured path + // the host boots the component through. + CowAdapter::init(vec![("chain".to_owned(), "1".to_owned())]).expect("config parses"); + let goldens = HeaderGoldens::load(fixture("cow-header-goldens.json")).expect("goldens parse"); + goldens.assert_conforms(CowAdapter::derive_header); +} + +#[test] +fn published_vectors_match_regeneration() { + assert_eq!( + CodecVectors::load(fixture("cow-intent-body.json")) + .expect("vectors parse") + .to_json(), + build_codec_vectors().to_json(), + "cow-intent-body.json has drifted; run the ignored \ + regenerate_published_fixtures test and commit the result", + ); +} + +#[test] +fn published_goldens_match_regeneration() { + assert_eq!( + HeaderGoldens::load(fixture("cow-header-goldens.json")) + .expect("goldens parse") + .to_json(), + build_header_goldens().to_json(), + "cow-header-goldens.json has drifted; run the ignored \ + regenerate_published_fixtures test and commit the result", + ); +} + +/// Rewrite the published files after a deliberate wire change, then +/// commit the diff. +#[test] +#[ignore = "writes the published fixture files in place"] +fn regenerate_published_fixtures() { + build_codec_vectors() + .write(fixture("cow-intent-body.json")) + .unwrap(); + build_header_goldens() + .write(fixture("cow-header-goldens.json")) + .unwrap(); +} diff --git a/shepherd/crates/cow-venue/tests/vectors/cow-header-goldens.json b/shepherd/crates/cow-venue/tests/vectors/cow-header-goldens.json new file mode 100644 index 00000000..08555774 --- /dev/null +++ b/shepherd/crates/cow-venue/tests/vectors/cow-header-goldens.json @@ -0,0 +1,60 @@ +{ + "version": 1, + "venue": "cow", + "goldens": [ + { + "name": "v1-order-presign", + "body": "000011111111111111111111111111111111111111112222222222222222222222222222222222222222002a00000000000000000000000000000000000000000000000000000000000000290000000000000000000000000000000000000000000000000000000000000000f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "header": { + "gives": { + "asset": { + "erc20": { + "token": "1111111111111111111111111111111111111111" + } + }, + "amount": "2a" + }, + "wants": { + "asset": { + "erc20": { + "token": "2222222222222222222222222222222222222222" + } + }, + "amount": "29" + }, + "settlement": { + "chain": 1 + }, + "authorisation": "eip712" + }, + "notes": "unsigned order: authorised by host-held keys (pre-sign)" + }, + { + "name": "v1-signed", + "body": "000111111111111111111111111111111111111111112222222222222222222222222222222222222222002a00000000000000000000000000000000000000000000000000000000000000290000000000000000000000000000000000000000000000000000000000000000f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000555555555555555555555555555555555555555503000000c0ffee", + "header": { + "gives": { + "asset": { + "erc20": { + "token": "1111111111111111111111111111111111111111" + } + }, + "amount": "2a" + }, + "wants": { + "asset": { + "erc20": { + "token": "2222222222222222222222222222222222222222" + } + }, + "amount": "29" + }, + "settlement": { + "chain": 1 + }, + "authorisation": "eip1271" + }, + "notes": "owner-signed order: EIP-1271" + } + ] +} diff --git a/shepherd/crates/cow-venue/tests/vectors/cow-intent-body.json b/shepherd/crates/cow-venue/tests/vectors/cow-intent-body.json new file mode 100644 index 00000000..13b58d34 --- /dev/null +++ b/shepherd/crates/cow-venue/tests/vectors/cow-intent-body.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "schema": "cow-venue/cow-intent-body", + "vectors": [ + { + "name": "v1-order", + "bytes": "000011111111111111111111111111111111111111112222222222222222222222222222222222222222002a00000000000000000000000000000000000000000000000000000000000000290000000000000000000000000000000000000000000000000000000000000000f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "expect": "round-trip" + }, + { + "name": "v1-signed", + "bytes": "000111111111111111111111111111111111111111112222222222222222222222222222222222222222002a00000000000000000000000000000000000000000000000000000000000000290000000000000000000000000000000000000000000000000000000000000000f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000555555555555555555555555555555555555555503000000c0ffee", + "expect": "round-trip" + }, + { + "name": "unknown-version", + "bytes": "090011111111111111111111111111111111111111112222222222222222222222222222222222222222002a00000000000000000000000000000000000000000000000000000000000000290000000000000000000000000000000000000000000000000000000000000000f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "expect": { + "unknown-version": { + "version": 9 + } + } + }, + { + "name": "empty", + "bytes": "", + "expect": "empty" + }, + { + "name": "truncated-payload", + "bytes": "000011111111111111111111111111111111111111112222222222222222222222222222222222222222002a00000000000000000000000000000000000000000000000000000000000000290000000000000000000000000000000000000000000000000000000000000000f1536544444444444444444444444444444444444444444444444444444444444444440000000000000000000000000000000000000000000000000000000000000000000000", + "expect": { + "malformed": { + "version": 0 + } + } + }, + { + "name": "trailing-bytes", + "bytes": "000011111111111111111111111111111111111111112222222222222222222222222222222222222222002a00000000000000000000000000000000000000000000000000000000000000290000000000000000000000000000000000000000000000000000000000000000f15365444444444444444444444444444444444444444444444444444444444444444400000000000000000000000000000000000000000000000000000000000000000000000000", + "expect": { + "malformed": { + "version": 0 + } + } + } + ] +} diff --git a/shepherd/crates/cow-venue/tests/wit_layering.rs b/shepherd/crates/cow-venue/tests/wit_layering.rs new file mode 100644 index 00000000..6e26e1c7 --- /dev/null +++ b/shepherd/crates/cow-venue/tests/wit_layering.rs @@ -0,0 +1,25 @@ +//! Layering gate: no generic WIT package references `shepherd:cow`. +//! The bundle-layer package carries only the event ABIs; the generic +//! host and videre packages must never name it. + +use std::path::Path; + +#[test] +fn generic_wit_packages_never_reference_shepherd_cow() { + let wit_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../wit"); + for pkg in std::fs::read_dir(&wit_root).expect("wit dir") { + let pkg = pkg.expect("wit dir entry").path(); + if pkg.file_name().is_some_and(|n| n == "shepherd-cow") { + continue; + } + for file in std::fs::read_dir(&pkg).expect("wit package dir") { + let path = file.expect("wit package entry").path(); + let text = std::fs::read_to_string(&path).expect("read wit file"); + assert!( + !text.contains("shepherd:cow"), + "{} references shepherd:cow", + path.display(), + ); + } + } +} diff --git a/shepherd/crates/shepherd/Cargo.toml b/shepherd/crates/shepherd/Cargo.toml new file mode 100644 index 00000000..c5ed1b75 --- /dev/null +++ b/shepherd/crates/shepherd/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "shepherd" +version = "0.2.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[[bin]] +name = "shepherd" +path = "src/main.rs" + +[dependencies] +nexum-launch = { path = "../../../nexum/crates/nexum-launch" } +nexum-runtime = { path = "../../../nexum/crates/nexum-runtime" } +videre-host = { path = "../../../videre/crates/videre-host" } + +anyhow.workspace = true +tokio.workspace = true diff --git a/shepherd/crates/shepherd/src/main.rs b/shepherd/crates/shepherd/src/main.rs new file mode 100644 index 00000000..0bf1be75 --- /dev/null +++ b/shepherd/crates/shepherd/src/main.rs @@ -0,0 +1,47 @@ +//! The `shepherd` binary: the cow composition root. Boots the +//! reference backends, registers the videre venue platform, and hands +//! both to the generic launcher. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +use std::sync::Arc; + +use nexum_runtime::addons::{AddOns, PrometheusAddOn}; +use nexum_runtime::engine_config::EngineConfig; +use nexum_runtime::host::component::{ + ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, +}; +use nexum_runtime::host::extension::Extension; +use nexum_runtime::preset::{CoreRuntime, Runtime}; + +/// The cow preset: the reference core backends with the videre venue +/// platform and the Prometheus add-on. +#[derive(Debug, Clone, Copy, Default)] +struct ShepherdRuntime; + +impl nexum_runtime::sealed::SealedRuntime for ShepherdRuntime {} + +impl Runtime for ShepherdRuntime { + type Types = CoreRuntime; + type ChainBuilder = ProviderPoolBuilder; + type StoreBuilder = LocalStoreBuilder; + type ExtBuilder = (); + type LogsBuilder = LogPipelineBuilder; + + fn components(self) -> ComponentsBuilder { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + } + + fn add_ons(&self) -> AddOns { + vec![Box::new(PrometheusAddOn)] + } + + fn extensions(&self, config: &EngineConfig) -> Vec>> { + vec![Arc::new(videre_host::platform(config))] + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + nexum_launch::run("shepherd", ShepherdRuntime).await +} diff --git a/modules/twap-monitor/Cargo.toml b/shepherd/modules/ethflow-watcher/Cargo.toml similarity index 56% rename from modules/twap-monitor/Cargo.toml rename to shepherd/modules/ethflow-watcher/Cargo.toml index 1910ae93..790e747e 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/shepherd/modules/ethflow-watcher/Cargo.toml @@ -1,25 +1,23 @@ [package] -name = "twap-monitor" +name = "ethflow-watcher" version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true [lib] +# `cdylib` is the wasm-component artefact the engine loads at runtime. crate-type = ["cdylib"] [dependencies] -nexum-sdk = { path = "../../crates/nexum-sdk" } -shepherd-sdk = { path = "../../crates/shepherd-sdk" } +nexum-sdk = { path = "../../../nexum/crates/nexum-sdk" } +videre-sdk = { path = "../../../videre/crates/videre-sdk" } +cow-venue = { path = "../../crates/cow-venue", features = ["client", "assembly"] } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } -strum = { version = "0.28", default-features = false, features = ["derive"] } -thiserror = "2" tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] -shepherd-sdk-test = { path = "../../crates/shepherd-sdk-test" } -nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } +nexum-sdk-test = { path = "../../../nexum/crates/nexum-sdk-test" } diff --git a/modules/ethflow-watcher/module.toml b/shepherd/modules/ethflow-watcher/module.toml similarity index 60% rename from modules/ethflow-watcher/module.toml rename to shepherd/modules/ethflow-watcher/module.toml index 45b6519a..98b88cae 100644 --- a/modules/ethflow-watcher/module.toml +++ b/shepherd/modules/ethflow-watcher/module.toml @@ -1,6 +1,6 @@ -# ethflow-watcher: see `CoWSwapEthFlow.OrderPlacement`, lift the embedded -# `GPv2OrderData` into an `OrderCreation`, and submit it via the CoW -# Protocol orderbook with the EIP-1271 signing scheme. +# ethflow-watcher: decode `CoWSwapEthFlow.OrderPlacement` logs, compute +# the orderbook UID, and put it under the host's status watch via the +# cow venue adapter. Observe-only: the module never submits. [module] name = "ethflow-watcher" @@ -10,23 +10,22 @@ version = "0.1.0" component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" [capabilities] -# Least-privilege: the module exercises logging, local-store and -# cow-api today; `chain` is listed as optional so a follow-up (e.g. -# adding an eth_call to read the EthFlow refund pointer) -# can use it without manifest churn, without widening the required -# grant for a capability the module does not call yet. -required = ["logging", "local-store", "cow-api"] -optional = ["chain"] +# Least-privilege: `client` for the venue observe path, `logging` for +# structured runtime logs, `local-store` for the `observed:` journal. +required = ["client", "logging", "local-store"] +optional = [] [capabilities.http] -# All outbound HTTP goes through `cow-api`; no direct `http` calls. +# All venue I/O rides the venue registry; no direct `http` calls. allow = [] # --- subscriptions ------------------------------------------------------ # CoWSwapEthFlow.OrderPlacement on Sepolia. topic-0 = keccak256( # "OrderPlacement(address,(address,address,address,uint256,uint256,uint32, -# bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)"). +# bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)"), +# pinned in wit/shepherd-cow/cow-events.wit and parity-tested in +# keeper.rs. # `address` is the Sepolia ETH_FLOW_PRODUCTION deployment from # `cowprotocol/ethflowcontract/networks.prod.json`. Unlike # ComposableCoW's CREATE2 address, EthFlow has had multiple per-network @@ -37,3 +36,9 @@ kind = "chain-log" chain_id = 11155111 address = "0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC" event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" + +# Status transitions the registry polls for watched receipts at the cow +# venue; the module journals `observed:{uid}` on the first one. +[[subscription]] +kind = "intent-status" +venue = "cow" diff --git a/shepherd/modules/ethflow-watcher/src/keeper.rs b/shepherd/modules/ethflow-watcher/src/keeper.rs new file mode 100644 index 00000000..deed6689 --- /dev/null +++ b/shepherd/modules/ethflow-watcher/src/keeper.rs @@ -0,0 +1,585 @@ +//! Pure keeper logic for the ethflow-watcher module. +//! +//! Observes EthFlow placements through the venue registry: decode the +//! `OrderPlacement` log, compute the orderbook UID (the receipt at the +//! cow venue), and `observe` it. The registry polls the cow adapter's +//! `status` and fans transitions back as `intent-status` events; the +//! first records `observed:{uid}` in the journal so log re-delivery +//! no-ops. A refused observe stays unjournalled, so re-delivery +//! retries. World access flows through the `nexum_sdk::host` traits and +//! the typed [`CowClient`] over [`VenueTransport`]. + +use alloy_primitives::{Address, Bytes}; +use alloy_sol_types::SolEvent; +use cow_venue::assembly; +use cow_venue::client::{CowClient, CowVenue}; +use cowprotocol::{ + Chain, CoWSwapOnchainOrders::OrderPlacement, ETH_FLOW_PRODUCTION, ETH_FLOW_STAGING, + GPv2OrderData, OnchainSignature, OrderUid, +}; +use nexum_sdk::events::Log; +use nexum_sdk::host::{Fault, LocalStoreHost}; +use nexum_sdk::keeper::Journal; +use videre_sdk::client::{Venue, VenueTransport}; +use videre_sdk::status_body::StatusBody; + +/// Decoded payload of a `CoWSwapOnchainOrders.OrderPlacement` log. +#[derive(Debug)] +pub(crate) struct DecodedPlacement { + /// EthFlow contract that emitted the event; the EIP-1271 owner and + /// the UID `owner` input. + pub(crate) contract: Address, + /// Original native-token seller; not the orderbook owner. + pub(crate) sender: Address, + pub(crate) order: Box, + /// Decoded signature; not consumed by the observe path. + #[allow(dead_code)] + pub(crate) signature: OnchainSignature, + /// Opaque placer metadata from the event; kept for decoder parity. + #[allow(dead_code)] + pub(crate) data: Bytes, +} + +/// Decode every `OrderPlacement` log in a dispatch batch and put each +/// placement's UID under the host's status watch. +pub async fn on_chain_logs( + host: &H, + venue: &CowClient, + chain_id: u64, + logs: &[Log], +) -> Result<(), Fault> { + for log in logs { + if let Some(placement) = decode_order_placement(log) { + observe_placement(host, venue, chain_id, &placement).await?; + } + } + Ok(()) +} + +/// A registry status transition for a watched receipt. Foreign venues +/// are ignored; the first cow transition records `observed:{uid}`, and +/// every transition is logged. +pub fn on_intent_status( + host: &H, + venue: &str, + receipt: &[u8], + status: &[u8], +) -> Result<(), Fault> { + if venue != CowVenue::ID.as_str() { + return Ok(()); + } + let Ok(uid) = OrderUid::try_from(receipt) else { + tracing::warn!( + "ethflow status update with a non-uid receipt ({} bytes)", + receipt.len(), + ); + return Ok(()); + }; + let body = StatusBody::decode(status).map_err(|e| Fault::InvalidInput(e.to_string()))?; + let uid_hex = format!("{uid}"); + let journal = Journal::observed(host); + if !journal.contains(&uid_hex)? { + journal.record(&uid_hex)?; + } + tracing::info!("ethflow observed {uid_hex}: {:?}", body.status); + Ok(()) +} + +/// Decode a raw event log against `CoWSwapOnchainOrders.OrderPlacement`. +/// `None` when the contract address is neither `ETH_FLOW_PRODUCTION` +/// nor `ETH_FLOW_STAGING`, topic-0 misses the `shepherd:cow/cow-events` +/// pin, or the ABI body fails to decode. +pub(crate) fn decode_order_placement(log: &Log) -> Option { + let contract = log.address(); + if contract != ETH_FLOW_PRODUCTION && contract != ETH_FLOW_STAGING { + return None; + } + if log.topics().first() != Some(&OrderPlacement::SIGNATURE_HASH) { + return None; + } + let decoded = OrderPlacement::decode_log(&log.inner).ok()?; + Some(DecodedPlacement { + contract, + sender: decoded.data.sender, + order: Box::new(decoded.data.order), + signature: decoded.data.signature, + data: decoded.data.data, + }) +} + +/// Compute the orderbook UID and put it under the host's status watch. +/// A refused observe stays unjournalled, so re-delivery retries. +async fn observe_placement( + host: &H, + venue: &CowClient, + chain_id: u64, + placement: &DecodedPlacement, +) -> Result<(), Fault> { + let Some(uid) = compute_uid(chain_id, placement) else { + tracing::warn!( + "ethflow uid build skipped (sender={:#x}): unsupported chain {chain_id} or unknown order marker", + placement.sender, + ); + return Ok(()); + }; + let uid_hex = format!("{uid}"); + + // Idempotency: once observed, do not re-watch on log re-delivery + // (engine restart, reorg replay, supervisor restart). + let journal = Journal::observed(host); + if journal.contains(&uid_hex)? { + return Ok(()); + } + + match venue.observe(uid.as_slice()).await { + Ok(()) => { + tracing::info!( + "ethflow watching {uid_hex} (sender={:#x})", + placement.sender, + ); + } + Err(err) => { + tracing::warn!( + "ethflow watch failed {uid_hex}: {err} (sender={:#x})", + placement.sender, + ); + } + } + Ok(()) +} + +/// Canonical 56-byte orderbook UID: `digest || owner || valid_to`, +/// where owner is the EthFlow contract (EIP-1271 signer), not the +/// sender. +fn compute_uid(chain_id: u64, placement: &DecodedPlacement) -> Option { + let chain = Chain::try_from(chain_id).ok()?; + let order = assembly::gpv2_to_order_data(&placement.order)?; + Some(assembly::order_uid(chain, &order, placement.contract)) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::VecDeque; + use std::rc::Rc; + + use alloy_primitives::{U256, address, hex}; + use alloy_sol_types::SolValue; + use cowprotocol::{BuyTokenDestination, OnchainSigningScheme, OrderKind, SellTokenSource}; + use nexum_sdk::Level; + use nexum_sdk::host::LocalStoreHost as _; + use nexum_sdk_test::{MockHost, capture_tracing}; + use videre_sdk::client::VenueId; + use videre_sdk::client::poll_once; + use videre_sdk::status_body::IntentStatus as Lifecycle; + use videre_sdk::{IntentStatus, Quotation, SubmitOutcome, VenueFault}; + + use super::*; + + const SEPOLIA: u64 = 11_155_111; + + /// One recorded transport call; `observe` also records venue and + /// receipt. + #[derive(Clone, Debug, Eq, PartialEq)] + enum Call { + Quote, + Submit, + Observe(String, Vec), + Status, + Cancel, + } + + /// Records every call; `observe` pops a scripted response, + /// defaulting to accepted once the script drains. The other verbs + /// refuse. Cloneable over shared state so the test keeps a handle. + #[derive(Clone, Default)] + struct SpyVenues { + calls: Rc>>, + observe_script: Rc>>>, + } + + impl SpyVenues { + fn script_observe(&self, result: Result<(), VenueFault>) { + self.observe_script.borrow_mut().push_back(result); + } + + fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + + fn observe_count(&self) -> usize { + self.calls + .borrow() + .iter() + .filter(|c| matches!(c, Call::Observe(..))) + .count() + } + } + + impl videre_sdk::client::sealed::SealedTransport for SpyVenues {} + + impl VenueTransport for SpyVenues { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + self.calls.borrow_mut().push(Call::Quote); + Err(VenueFault::Unsupported) + } + + async fn submit( + &self, + _venue: &VenueId, + _body: Vec, + ) -> Result { + self.calls.borrow_mut().push(Call::Submit); + Err(VenueFault::Unsupported) + } + + async fn observe(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + self.calls + .borrow_mut() + .push(Call::Observe(venue.to_string(), receipt.to_vec())); + self.observe_script + .borrow_mut() + .pop_front() + .unwrap_or(Ok(())) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + self.calls.borrow_mut().push(Call::Status); + Err(VenueFault::Unsupported) + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + self.calls.borrow_mut().push(Call::Cancel); + Err(VenueFault::Unsupported) + } + } + + /// Drive the async keeper on the synchronous test boundary. + fn run_logs( + host: &MockHost, + spy: &SpyVenues, + chain_id: u64, + logs: &[Log], + ) -> Result<(), Fault> { + let client = CowClient::with_transport(spy.clone()); + match poll_once(on_chain_logs(host, &client, chain_id, logs)) { + std::task::Poll::Ready(output) => output, + std::task::Poll::Pending => panic!("guest futures complete in one poll"), + } + } + + fn open_status() -> Vec { + StatusBody { + status: Lifecycle::Open, + proof: None, + reason: None, + } + .encode() + .expect("status body encodes") + } + + fn sample_order() -> GPv2OrderData { + GPv2OrderData { + sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), + buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), + receiver: address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"), + sellAmount: U256::from(1_000_000_u64), + buyAmount: U256::from(999_u64), + validTo: 0xffff_ffff, + appData: cowprotocol::EMPTY_APP_DATA_HASH, + feeAmount: U256::ZERO, + kind: OrderKind::SELL, + partiallyFillable: false, + sellTokenBalance: SellTokenSource::ERC20, + buyTokenBalance: BuyTokenDestination::ERC20, + } + } + + fn sample_event() -> OrderPlacement { + OrderPlacement { + sender: address!("00112233445566778899aabbccddeeff00112233"), + order: sample_order(), + signature: OnchainSignature { + scheme: OnchainSigningScheme::Eip1271, + data: hex!("c0ffeec0ffeec0ffee").to_vec().into(), + }, + data: hex!("deadbeef").to_vec().into(), + } + } + + fn encode_log(event: &OrderPlacement) -> (Vec>, Vec) { + let mut sender_topic = vec![0u8; 12]; + sender_topic.extend_from_slice(event.sender.as_slice()); + let topics = vec![OrderPlacement::SIGNATURE_HASH.to_vec(), sender_topic]; + let data = ( + event.order.clone(), + event.signature.clone(), + event.data.clone(), + ) + .abi_encode_params(); + (topics, data) + } + + /// The alloy log a placement decodes from. + fn make_log(address_bytes: &[u8], topics: &[Vec], data: &[u8]) -> Log { + nexum_sdk::events::ChainLogParts { + address: address_bytes, + topics, + data, + ..Default::default() + } + .into() + } + + fn sample_log() -> Log { + let (topics, data) = encode_log(&sample_event()); + make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data) + } + + fn sample_uid() -> OrderUid { + let placement = decode_order_placement(&sample_log()).expect("decode succeeds"); + compute_uid(SEPOLIA, &placement).expect("sepolia + canonical markers") + } + + #[test] + fn decodes_well_formed_placement() { + let event = sample_event(); + let decoded = decode_order_placement(&sample_log()).expect("decode succeeds"); + assert_eq!(decoded.contract, ETH_FLOW_PRODUCTION); + assert_eq!(decoded.sender, event.sender); + assert_eq!(decoded.signature.scheme, OnchainSigningScheme::Eip1271); + } + + #[test] + fn rejects_unrelated_contract_address() { + let event = sample_event(); + let (topics, data) = encode_log(&event); + let stranger = address!("dead00000000000000000000000000000000dead"); + let log = make_log(stranger.as_slice(), &topics, &data); + assert!(decode_order_placement(&log).is_none()); + } + + #[test] + fn rejects_wrong_topic_signature() { + let event = sample_event(); + let (_, data) = encode_log(&event); + let bad_topic = vec![0xaa_u8; 32]; + let sender_topic = vec![0u8; 32]; + let log = make_log( + ETH_FLOW_PRODUCTION.as_slice(), + &[bad_topic, sender_topic], + &data, + ); + assert!(decode_order_placement(&log).is_none()); + } + + #[test] + fn compute_uid_pins_owner_to_ethflow_contract_and_validto() { + let event = sample_event(); + let uid = sample_uid(); + let bytes: [u8; 56] = uid.into(); + // owner suffix (bytes 32..52) = EthFlow contract address. + assert_eq!(&bytes[32..52], ETH_FLOW_PRODUCTION.as_slice()); + // valid_to suffix (bytes 52..56) = u32 BE of the on-chain validTo. + assert_eq!( + u32::from_be_bytes(bytes[52..56].try_into().unwrap()), + event.order.validTo, + ); + } + + #[test] + fn compute_uid_returns_none_on_unsupported_chain() { + let decoded = decode_order_placement(&sample_log()).unwrap(); + assert!(compute_uid(9999, &decoded).is_none()); + } + + /// A placement registers one cow status watch keyed on the computed + /// UID, and journals nothing until the first status transition. + #[test] + fn placement_log_registers_the_uid_watch() { + let host = MockHost::new(); + let spy = SpyVenues::default(); + let uid = sample_uid(); + + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); + + assert_eq!( + spy.calls(), + vec![Call::Observe("cow".to_owned(), uid.as_slice().to_vec())], + "exactly one observe, nothing else", + ); + assert!( + host.store.snapshot().is_empty(), + "observed:{{uid}} waits for the first status transition", + ); + } + + /// A refused observe warns, journals nothing, and re-delivery + /// retries. + #[test] + fn watch_refusal_warns_and_redelivery_retries() { + let host = MockHost::new(); + let spy = SpyVenues::default(); + spy.script_observe(Err(VenueFault::Unavailable("venue down".to_owned()))); + + let (result, logs) = capture_tracing(|| run_logs(&host, &spy, SEPOLIA, &[sample_log()])); + result.unwrap(); + + assert!(host.store.snapshot().is_empty()); + logs.expect_one(|e| e.level == Level::WARN && e.message.contains("watch failed")); + + // Unjournalled, so the re-delivered log observes again. + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); + assert_eq!(spy.observe_count(), 2); + } + + /// A placement already carrying `observed:{uid}` does not touch the + /// venue on re-delivery. + #[test] + fn previously_observed_placement_is_skipped_on_redelivery() { + let host = MockHost::new(); + let spy = SpyVenues::default(); + let uid = sample_uid(); + + host.store + .set(&format!("observed:{uid}"), b"") + .expect("seed observed marker"); + + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); + + assert!( + spy.calls().is_empty(), + "observed:{{uid}} must short-circuit before the venue call", + ); + } + + /// An unsupported chain id warns without panicking or touching the + /// venue. + #[test] + fn unsupported_chain_logs_warn_without_venue_call() { + let host = MockHost::new(); + let spy = SpyVenues::default(); + + // 9999 is not in cowprotocol::Chain. + let (result, logs) = capture_tracing(|| run_logs(&host, &spy, 9999, &[sample_log()])); + result.unwrap(); + + assert!(spy.calls().is_empty()); + assert!(host.store.snapshot().is_empty()); + logs.expect_one(|e| { + e.level == Level::WARN && e.message.contains("ethflow uid build skipped") + }); + } + + /// Observer-only: no call path reaches quote, submit, status, or + /// cancel. + #[test] + fn keeper_never_submits() { + let host = MockHost::new(); + let spy = SpyVenues::default(); + + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); + + assert!( + spy.calls().iter().all(|c| matches!(c, Call::Observe(..))), + "observe is the only verb the keeper may use", + ); + } + + /// The first cow transition journals `observed:{uid}` and logs it. + #[test] + fn status_update_journals_the_observed_marker() { + let host = MockHost::new(); + let uid = sample_uid(); + + let (result, logs) = + capture_tracing(|| on_intent_status(&host, "cow", uid.as_slice(), &open_status())); + result.unwrap(); + + assert!( + host.store + .snapshot() + .contains_key(&format!("observed:{uid}")), + "the first transition must write observed:{{uid}}", + ); + let ev = logs.expect_one(|e| e.message.contains("ethflow observed")); + assert_eq!(ev.level, Level::INFO); + } + + /// Later transitions keep the single marker and stay Ok. + #[test] + fn repeated_transitions_keep_one_marker() { + let host = MockHost::new(); + let uid = sample_uid(); + + on_intent_status(&host, "cow", uid.as_slice(), &open_status()).unwrap(); + let fulfilled = StatusBody { + status: Lifecycle::Fulfilled, + proof: None, + reason: None, + } + .encode() + .expect("status body encodes"); + on_intent_status(&host, "cow", uid.as_slice(), &fulfilled).unwrap(); + + assert_eq!(host.store.snapshot().len(), 1); + } + + /// A transition from a foreign venue is not ethflow's: ignored. + #[test] + fn foreign_venue_status_update_is_ignored() { + let host = MockHost::new(); + on_intent_status(&host, "echo-venue", sample_uid().as_slice(), &open_status()).unwrap(); + assert!(host.store.snapshot().is_empty()); + } + + /// A cow receipt that is not a 56-byte UID warns, no marker. + #[test] + fn non_uid_receipt_warns_without_marker() { + let host = MockHost::new(); + let (result, logs) = + capture_tracing(|| on_intent_status(&host, "cow", b"abc", &open_status())); + result.unwrap(); + assert!(host.store.snapshot().is_empty()); + logs.expect_one(|e| e.level == Level::WARN && e.message.contains("non-uid receipt")); + } + + /// An undecodable status body is a typed fault, never a marker. + #[test] + fn malformed_status_body_is_a_typed_fault() { + let host = MockHost::new(); + let err = on_intent_status(&host, "cow", sample_uid().as_slice(), &[0xFF, 0x00]) + .expect_err("undecodable status body"); + assert!(matches!(err, Fault::InvalidInput(_))); + assert!(host.store.snapshot().is_empty()); + } + + /// The `sol!` decoder's topic-0 matches the + /// `shepherd:cow/cow-events` pin; a drift would silently miss every + /// EthFlow event. + #[test] + fn topic0_matches_the_cow_events_package_of_record() { + let wit = include_str!("../../../../wit/shepherd-cow/cow-events.wit"); + let expected = format!("{:#x}", OrderPlacement::SIGNATURE_HASH); + assert!( + wit.contains(&expected), + "sol! topic-0 must match the shepherd:cow/cow-events pin ({expected})", + ); + } + + /// The shipped `module.toml` `event_signature` equals the decoder + /// topic-0; catches a manifest/code drift the wit assertion cannot. + #[test] + fn manifest_topic0_matches_order_placement_signature_hash() { + let manifest = include_str!("../module.toml"); + let expected = format!("{:#x}", OrderPlacement::SIGNATURE_HASH); + assert!( + manifest.contains(&expected), + "module.toml event_signature must equal the decoder topic-0 ({expected})", + ); + } +} diff --git a/shepherd/modules/ethflow-watcher/src/lib.rs b/shepherd/modules/ethflow-watcher/src/lib.rs new file mode 100644 index 00000000..5f4864ae --- /dev/null +++ b/shepherd/modules/ethflow-watcher/src/lib.rs @@ -0,0 +1,60 @@ +//! # ethflow-watcher (Shepherd module) +//! +//! Subscribes to `CoWSwapOnchainOrders.OrderPlacement` logs from the +//! canonical EthFlow contracts, computes each placement's orderbook UID, +//! and puts it under the host's status watch; the registry polls the cow +//! adapter and fans transitions back as `intent-status` events, journalled +//! as `observed:{uid}`. Observe-only, never submits. Pure logic lives in +//! `keeper`; `lib.rs` is the `#[videre_sdk::keeper]` glue. + +// wit_bindgen::generate! expands to host-import shims whose arity +// matches the WIT signatures, which can exceed clippy's +// too-many-arguments threshold. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +// The keeper glue only resolves against the engine's wasm component +// host. Cfg-gate it so a native build of this crate carries just the +// keeper code without dangling `extern "C"` imports; the +// `use wit_bindgen as _` line silences the unused-crate lint on native +// targets where the macro never expands. +#[cfg(not(target_arch = "wasm32"))] +use wit_bindgen as _; + +pub mod keeper; + +#[cfg(target_arch = "wasm32")] +mod glue { + use cow_venue::client::CowClient; + + use crate::keeper; + + struct EthFlowWatcher; + + #[videre_sdk::keeper] + impl EthFlowWatcher { + fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { + install_tracing(); + tracing::info!("ethflow-watcher init"); + Ok(()) + } + + async fn on_chain_logs(batch: nexum::host::types::ChainLogs) -> Result<(), Fault> { + let logs: Vec = + batch.logs.into_iter().map(Into::into).collect(); + keeper::on_chain_logs(&WitBindgenHost, &CowClient::new(), batch.chain_id, &logs) + .await?; + Ok(()) + } + + fn on_intent_status(update: videre_sdk::IntentStatusUpdate) -> Result<(), Fault> { + keeper::on_intent_status( + &WitBindgenHost, + &update.venue, + &update.receipt, + &update.status, + )?; + Ok(()) + } + } +} diff --git a/modules/examples/stop-loss/Cargo.toml b/shepherd/modules/twap-monitor/Cargo.toml similarity index 51% rename from modules/examples/stop-loss/Cargo.toml rename to shepherd/modules/twap-monitor/Cargo.toml index 0dab8781..0b89463a 100644 --- a/modules/examples/stop-loss/Cargo.toml +++ b/shepherd/modules/twap-monitor/Cargo.toml @@ -1,26 +1,28 @@ [package] -name = "stop-loss" +name = "twap-monitor" 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] -nexum-sdk = { path = "../../../crates/nexum-sdk" } -shepherd-sdk = { path = "../../../crates/shepherd-sdk" } +composable-cow = { path = "../../crates/composable-cow", features = ["run"] } +cow-venue = { path = "../../crates/cow-venue", features = ["client"] } +nexum-sdk = { path = "../../../nexum/crates/nexum-sdk" } +videre-sdk = { path = "../../../videre/crates/videre-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } +alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] -shepherd-sdk-test = { path = "../../../crates/shepherd-sdk-test" } -nexum-sdk-test = { path = "../../../crates/nexum-sdk-test" } -# Only used by tests in `strategy.rs` to encode a synthetic oracle -# return body; the production code uses `nexum_sdk::chain::chainlink`. -alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } +cow-venue = { path = "../../crates/cow-venue", features = ["client", "assembly"] } +serde_json = { version = "1", default-features = false, features = ["alloc"] } +# Parses the shipped `module.toml` so the subscription parity test reads the +# real `event_signature` value rather than scanning the file text. +toml.workspace = true +nexum-sdk-test = { path = "../../../nexum/crates/nexum-sdk-test" } diff --git a/shepherd/modules/twap-monitor/module.toml b/shepherd/modules/twap-monitor/module.toml new file mode 100644 index 00000000..e53a15e4 --- /dev/null +++ b/shepherd/modules/twap-monitor/module.toml @@ -0,0 +1,64 @@ +# twap-monitor: poll registered ComposableCoW conditional orders and +# submit ready ones to the CoW venue through the venue registry. + +[module] +name = "twap-monitor" +version = "0.1.0" +# Placeholder content hash. 0.2 parses but does not verify this; 0.3 will +# compare it against the sha256 of the loaded component bytes. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +# Host interfaces the module imports and exercises: +# - logging -> structured runtime logs +# - local-store -> watch: / next_block: / next_epoch: / submitted: / +# backoff: / dropped: persistence +# - chain -> eth_call into ComposableCoW.getTradeableOrderWithSignature +# - client -> videre:venue/client submit path to the cow adapter +required = ["logging", "local-store", "chain", "client"] +optional = [] + +[capabilities.http] +# All outbound HTTP is the cow adapter's; the keeper makes no direct +# `http` calls. +allow = [] + +# --- subscriptions ------------------------------------------------------ + +# ComposableCoW.ConditionalOrderCreated emissions on Sepolia. topic-0 = +# keccak256("ConditionalOrderCreated(address,(address,bytes32,bytes))"), +# parity-tested against the sol! decoder in keeper.rs. Both `address` +# and `event_signature` are pinned so the supervisor does not deliver +# unrelated logs to the module. +[[subscription]] +kind = "chain-log" +chain_id = 11155111 +address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" +event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361" + +# ComposableCoW v2 ConditionalOrderRemoved emissions. topic-0 = +# keccak256("ConditionalOrderRemoved(address,bytes32)"), parity-tested +# against the sol! decoder in keeper.rs. +# This stream and the created stream merge in arrival order, not +# chain order, so a removal drops the watch (with its gates) only +# when it postdates the watch's indexed create. +[[subscription]] +kind = "chain-log" +chain_id = 11155111 +address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" +event_signature = "0x67e0f2b23e842ce65d7edff49765689c9c0931f911fc5971d09bb598cc1af4a9" + +# New-block ticks drive the TWAP poll loop (`getTradeableOrderWithSignature`). +[[subscription]] +kind = "block" +chain_id = 11155111 + +# Status transitions the registry polls back for submitted orders. +[[subscription]] +kind = "intent-status" +venue = "cow" + +# The one body-schema version this keeper encodes; install refuses the +# keeper unless every installed venue adapter decodes it. +[venue] +body_version = 1 diff --git a/shepherd/modules/twap-monitor/src/keeper.rs b/shepherd/modules/twap-monitor/src/keeper.rs new file mode 100644 index 00000000..c56b7979 --- /dev/null +++ b/shepherd/modules/twap-monitor/src/keeper.rs @@ -0,0 +1,1228 @@ +//! Pure logic for the twap-monitor keeper: decode ComposableCoW +//! registration and removal logs into the watch set, and poll +//! `getTradeableOrderWithSignature` behind [`Poller`]. World access +//! flows through the `nexum_sdk::host` traits and the typed +//! [`CowClient`] over [`VenueTransport`]. Gate discipline, the +//! `submitted:` journal, and retry dispatch live in +//! `composable_cow::run`. + +use alloy_primitives::{Address, B256, Bytes, keccak256}; +use alloy_sol_types::{SolCall, SolEvent, SolValue}; +use composable_cow::{LegacyRevertAdapter, Verdict, run}; +use cow_venue::CowClient; +use cowprotocol::{ + COMPOSABLE_COW, + ComposableCoW::{ConditionalOrderCreated, ConditionalOrderRemoved}, + ConditionalOrderParams, GPv2OrderData, +}; +use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; +use nexum_sdk::events::Log; +use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost}; +use nexum_sdk::keeper::{Poller, Tick, WatchRef, WatchSet, watch_key}; +use videre_sdk::VenueTransport; + +/// Block fields the poll path reads on every dispatch. +pub struct BlockInfo { + pub chain_id: u64, + pub number: u64, + pub timestamp: u64, +} + +mod abi { + use alloy_sol_types::sol; + + sol! { + /// Wire-format mirror of `cowprotocol::ConditionalOrderParams`; the + /// ABI matches so the generated selector matches the contract. + struct Params { + address handler; + bytes32 salt; + bytes staticInput; + } + + /// Selector source for `eth_call`; the return decodes into + /// `cowprotocol::GPv2OrderData`. + function getTradeableOrderWithSignature( + address owner, + Params params, + bytes offchainInput, + bytes32[] proof + ) external view; + } +} + +/// Decode every ComposableCoW registration and removal log in a +/// dispatch batch. A create persists a watch stamped with the log's +/// chain position; a removal drops the watch and its gates only when it +/// postdates that stamp. Streams merge in arrival order, not chain +/// order, so the stamp keeps a stale removal from dropping a +/// re-registered watch. +pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { + for log in logs { + if let Some((owner, params)) = decode_conditional_order_created(log) { + persist_watch(host, owner, ¶ms, LogPosition::of(log))?; + } else if let Some((owner, hash)) = decode_conditional_order_removed(log) { + remove_watch(host, owner, &hash, LogPosition::of(log))?; + } + } + Ok(()) +} + +/// Run the keeper over every gate-ready watch through the shared +/// composition, submitting through the typed client. Block timestamp is +/// milliseconds; the tick carries Unix seconds. +pub fn on_block(host: &H, venue: &CowClient, block: BlockInfo) -> Result<(), Fault> +where + H: ChainHost + LocalStoreHost, + T: VenueTransport, +{ + let tick = Tick { + chain_id: block.chain_id, + block: block.number, + epoch_s: block.timestamp / 1000, + }; + run(host, venue, &TwapSource, &tick) +} + +/// Chain position of a mined log, ordered as the chain orders logs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct LogPosition { + block: u64, + index: u64, +} + +impl LogPosition { + /// `None` for a pending log. + fn of(log: &Log) -> Option { + Some(Self { + block: log.block_number?, + index: log.log_index?, + }) + } +} + +/// Header tag + `(block, log-index)` little-endian words. +const ROW_HEADER_LEN: usize = 17; + +/// Watch row payload: a position header ahead of the ABI-encoded +/// `ConditionalOrderParams`, pinning where the create sits on chain. +fn encode_row(indexed_at: Option, params: &[u8]) -> Vec { + let mut row = Vec::with_capacity(ROW_HEADER_LEN + params.len()); + match indexed_at { + Some(at) => { + row.push(1); + row.extend_from_slice(&at.block.to_le_bytes()); + row.extend_from_slice(&at.index.to_le_bytes()); + } + None => row.extend_from_slice(&[0; ROW_HEADER_LEN]), + } + row.extend_from_slice(params); + row +} + +/// Split a watch row into its position stamp and params bytes; `None` +/// on a malformed row. +fn decode_row(row: &[u8]) -> Option<(Option, &[u8])> { + let (header, params) = row.split_first_chunk::()?; + let [tag, position @ ..] = header; + let (block, index) = position.split_first_chunk::<8>()?; + let indexed_at = match tag { + 0 => None, + 1 => Some(LogPosition { + block: u64::from_le_bytes(*block), + index: u64::from_le_bytes(index.try_into().ok()?), + }), + _ => return None, + }; + Some((indexed_at, params)) +} + +/// Topic-0 gates before the ABI decode; pin parity-tested against +/// `shepherd:cow/cow-events`. +fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOrderParams)> { + if log.topics().first() != Some(&ConditionalOrderCreated::SIGNATURE_HASH) { + return None; + } + let decoded = ConditionalOrderCreated::decode_log(&log.inner).ok()?; + Some((decoded.data.owner, decoded.data.params)) +} + +/// Overwrites in place, keeping the latest indexed stamp, so +/// re-indexing (re-org replay, cursor rewind) never ages the row. +fn persist_watch( + host: &H, + owner: Address, + params: &ConditionalOrderParams, + indexed_at: Option, +) -> Result<(), Fault> { + let encoded = params.abi_encode(); + let hash = keccak256(&encoded); + let watches = WatchSet::new(host); + let prior = WatchRef::parse(&watch_key(&owner, &hash)) + .map(|watch| watches.get(watch)) + .transpose()? + .flatten() + .and_then(|row| decode_row(&row).and_then(|(at, _)| at)); + let row = encode_row(prior.max(indexed_at), &encoded); + let key = watches.put(&owner, &hash, &row)?; + tracing::info!("indexed {key}"); + Ok(()) +} + +/// Topic-0 gates before the ABI decode; pin parity-tested against +/// `shepherd:cow/cow-events`. +fn decode_conditional_order_removed(log: &Log) -> Option<(Address, B256)> { + if log.topics().first() != Some(&ConditionalOrderRemoved::SIGNATURE_HASH) { + return None; + } + let decoded = ConditionalOrderRemoved::decode_log(&log.inner).ok()?; + Some((decoded.data.owner, decoded.data.singleOrderHash)) +} + +/// Drops the watch only when the removal provably postdates its create +/// stamp; an unprovable ordering keeps the watch (self-heals via the +/// poll drop path). An unknown or already-dropped order is a no-op. +fn remove_watch( + host: &H, + owner: Address, + hash: &B256, + removed_at: Option, +) -> Result<(), Fault> { + let key = watch_key(&owner, hash); + let Some(watch) = WatchRef::parse(&key) else { + return Ok(()); + }; + let watches = WatchSet::new(host); + let Some(row) = watches.get(watch)? else { + return Ok(()); + }; + let indexed_at = decode_row(&row).and_then(|(at, _)| at); + match (indexed_at, removed_at) { + (Some(indexed_at), Some(removed_at)) if indexed_at < removed_at => { + watches.remove(watch)?; + tracing::info!("removed {key}"); + } + _ => tracing::info!("kept {key}: removal does not postdate its create"), + } + Ok(()) +} + +/// TWAP conditional source: decode the stored row and evaluate +/// `getTradeableOrderWithSignature` on chain. An undecodable row polls +/// again next block rather than tearing down the run. +struct TwapSource; + +impl Poller for TwapSource { + type Outcome = Verdict; + + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { + let Some((_, params)) = decode_row(params) else { + tracing::warn!("watch {} carried an unparseable row; skipping", watch.key()); + return Verdict::TryNextBlock { reason: [0; 4] }; + }; + let Ok(params) = ConditionalOrderParams::abi_decode(params) else { + tracing::warn!("watch {} carried unparseable params; skipping", watch.key()); + return Verdict::TryNextBlock { reason: [0; 4] }; + }; + let Ok(owner) = watch.owner_hex().parse::
() else { + tracing::warn!( + "watch {} carried an unparseable owner; skipping", + watch.key() + ); + return Verdict::TryNextBlock { reason: [0; 4] }; + }; + let outcome = poll_one(host, tick.chain_id, &owner, ¶ms); + tracing::info!("poll {} -> {}", watch.key(), outcome_label(&outcome)); + outcome + } + + fn label(&self) -> &'static str { + "twap" + } +} + +fn poll_one( + host: &H, + chain_id: u64, + owner: &Address, + params: &ConditionalOrderParams, +) -> Verdict { + let call = abi::getTradeableOrderWithSignatureCall { + owner: *owner, + params: abi::Params { + handler: params.handler, + salt: params.salt, + staticInput: params.staticInput.clone(), + }, + offchainInput: Bytes::new(), + proof: Vec::new(), + }; + let params_json = eth_call_params(&COMPOSABLE_COW, &call.abi_encode()); + match host.request(chain_id, "eth_call", ¶ms_json) { + Ok(result_json) => parse_eth_call_result(&result_json) + .and_then(|bytes| decode_return(&bytes)) + .unwrap_or(Verdict::TryNextBlock { reason: [0; 4] }), + // `LegacyRevertAdapter::classify` is the one policy for what a failed + // poll call means to the watch lifecycle; the diagnostics here + // cover the cases where the raw error carries information the + // outcome alone does not. + Err(err) => { + let outcome = LegacyRevertAdapter::classify(&err); + match &err { + ChainError::Fault(fault) => { + tracing::warn!("eth_call failed ({fault}); retrying next block"); + } + // A permanent drop deserves its cause on the record: + // the revert selector and the node's message are + // unrecoverable once the watch is gone. + ChainError::Rpc(rpc) if matches!(outcome, Verdict::Invalid { .. }) => { + let selector = rpc + .data + .as_deref() + .and_then(|data| data.get(..4)) + .map(alloy_primitives::hex::encode_prefixed) + .unwrap_or_else(|| "none".to_string()); + tracing::warn!( + "eth_call reverted permanently (selector {selector}, {}); \ + dropping watch", + rpc.message, + ); + } + _ => {} + } + outcome + } + } +} + +/// Decode a successful `getTradeableOrderWithSignature` return into +/// `Verdict::Post`. The 1.x contract carries no next-poll hint, so +/// `next_poll_timestamp` is `None`. +fn decode_return(data: &[u8]) -> Option { + let (order, signature) = <(GPv2OrderData, Bytes)>::abi_decode_params(data).ok()?; + Some(Verdict::Post { + order: Box::new(order), + signature, + next_poll_timestamp: None, + }) +} + +fn outcome_label(o: &Verdict) -> &'static str { + match o { + Verdict::Post { .. } => "Post", + Verdict::WaitTimestamp { .. } => "WaitTimestamp", + Verdict::WaitBlock { .. } => "WaitBlock", + Verdict::TryNextBlock { .. } => "TryNextBlock", + Verdict::Invalid { .. } => "Invalid", + Verdict::NeedsInput { .. } => "NeedsInput", + } +} + +#[cfg(test)] +fn parse_watch_key(key: &str) -> Option<(&str, &str)> { + let watch = WatchRef::parse(key)?; + Some((watch.owner_hex(), watch.hash_hex())) +} + +#[cfg(test)] +fn signed_intent_body( + order: &GPv2OrderData, + signature: &Bytes, + owner: Address, +) -> Option { + use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; + use cow_venue::{CowIntent, CowIntentBody, SignedOrder}; + let order_data = gpv2_to_order_data(order)?; + Some(CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner, + signature: signature.to_vec(), + }))) +} + +#[cfg(test)] +fn compute_intent_id(order: &GPv2OrderData, signature: &Bytes, owner: Address) -> Option { + cow_venue::intent_id(&signed_intent_body(order, signature, owner)?).ok() +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::VecDeque; + + use alloy_primitives::{B256, U256, address, b256, hex}; + use cow_venue::CowVenue; + use cowprotocol::{BuyTokenDestination, OrderKind, SellTokenSource}; + use nexum_sdk::Level; + use nexum_sdk::host::LocalStoreHost as _; + use nexum_sdk_test::{MockHost, capture_tracing}; + use videre_sdk::client::sealed::SealedTransport; + use videre_sdk::{ + IntentBody as _, IntentStatus, Quotation, SubmitOutcome, Venue as _, VenueFault, VenueId, + }; + + use super::*; + + const SEPOLIA: u64 = 11_155_111; + + /// Scripted [`VenueTransport`]: one submit outcome per queued entry. + /// Quote, status, and cancel are off the poll path. + #[derive(Default)] + struct MockVenue { + outcomes: RefCell>>, + submits: RefCell)>>, + } + + impl MockVenue { + fn enqueue_submit(&self, outcome: Result) { + self.outcomes.borrow_mut().push_back(outcome); + } + + fn submits(&self) -> Vec<(String, Vec)> { + self.submits.borrow().clone() + } + + fn submit_count(&self) -> usize { + self.submits.borrow().len() + } + } + + impl SealedTransport for &MockVenue {} + + impl VenueTransport for &MockVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit( + &self, + venue: &VenueId, + body: Vec, + ) -> Result { + self.submits.borrow_mut().push((venue.to_string(), body)); + self.outcomes.borrow_mut().pop_front().unwrap_or_else(|| { + Err(VenueFault::Unavailable( + "MockVenue: unscripted submit".into(), + )) + }) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + /// Dispatch one block through `on_block` over the scripted transport. + fn dispatch(host: &MockHost, venue: &MockVenue, block: BlockInfo) -> Result<(), Fault> { + on_block(host, &CowClient::with_transport(venue), block) + } + + /// `validTo` `seconds` from the wall clock, saturating. + fn valid_to_in(seconds: u32) -> u32 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the epoch") + .as_secs(); + u32::try_from(now) + .expect("wall clock fits u32") + .saturating_add(seconds) + } + + fn sample_params() -> ConditionalOrderParams { + ConditionalOrderParams { + handler: address!("ffeeddccbbaa00998877665544332211ffeeddcc"), + salt: b256!("0101010101010101010101010101010101010101010101010101010101010101"), + staticInput: hex!("deadbeef").to_vec().into(), + } + } + + fn sample_order() -> GPv2OrderData { + GPv2OrderData { + sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), + buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), + receiver: address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"), + sellAmount: U256::from(1_000_u64), + buyAmount: U256::from(2_000_u64), + validTo: 1_700_000_000, + appData: B256::repeat_byte(0xaa), + feeAmount: U256::ZERO, + kind: B256::repeat_byte(0xbb), + partiallyFillable: false, + sellTokenBalance: B256::repeat_byte(0xcc), + buyTokenBalance: B256::repeat_byte(0xdd), + } + } + + fn submittable_order() -> GPv2OrderData { + GPv2OrderData { + sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), + buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), + receiver: Address::ZERO, + sellAmount: U256::from(1_000_000_u64), + buyAmount: U256::from(999_u64), + validTo: valid_to_in(3_600), + appData: cowprotocol::EMPTY_APP_DATA_HASH, + feeAmount: U256::ZERO, + kind: OrderKind::SELL, + partiallyFillable: false, + sellTokenBalance: SellTokenSource::ERC20, + buyTokenBalance: BuyTokenDestination::ERC20, + } + } + + #[test] + fn decodes_well_formed_log() { + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let log = make_log(owner, ¶ms, at(1, 0)); + + let (decoded_owner, decoded_params) = + decode_conditional_order_created(&log).expect("decode succeeds"); + assert_eq!(decoded_owner, owner); + assert_eq!(decoded_params, params); + } + + #[test] + fn rejects_wrong_topic() { + let topics = vec![ + b256!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").to_vec(), + ]; + let log: Log = nexum_sdk::events::ChainLogParts { + address: COMPOSABLE_COW.as_slice(), + topics: &topics, + ..Default::default() + } + .into(); + assert!(decode_conditional_order_created(&log).is_none()); + } + + #[test] + fn rejects_empty_topics() { + let log: Log = nexum_sdk::events::ChainLogParts { + address: COMPOSABLE_COW.as_slice(), + ..Default::default() + } + .into(); + assert!(decode_conditional_order_created(&log).is_none()); + } + + #[test] + fn decode_return_round_trip() { + let order = sample_order(); + let sig: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); + let wire = (order.clone(), sig.clone()).abi_encode_params(); + + match decode_return(&wire).expect("decode succeeds") { + Verdict::Post { + order: o, + signature: s, + next_poll_timestamp, + } => { + assert_eq!(o.sellToken, order.sellToken); + assert_eq!(o.buyAmount, order.buyAmount); + assert_eq!(s, sig); + assert_eq!(next_poll_timestamp, None, "legacy path carries no hint"); + } + other => panic!("expected Post, got {other:?}"), + } + } + + #[test] + fn watch_key_round_trips_via_parse() { + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let hash = b256!("0202020202020202020202020202020202020202020202020202020202020202"); + let key = watch_key(&owner, &hash); + let (o, h) = parse_watch_key(&key).expect("parse"); + assert_eq!(o.parse::
().unwrap(), owner); + assert_eq!(h.parse::().unwrap(), hash); + } + + /// Chain position shorthand for the log builders. + fn at(block: u64, index: u64) -> LogPosition { + LogPosition { block, index } + } + + /// A mined ComposableCoW log at `position`. + fn make_event_log(owner: Address, topic0: B256, data: &[u8], position: LogPosition) -> Log { + let mut owner_topic = vec![0u8; 12]; + owner_topic.extend_from_slice(owner.as_slice()); + let topics = vec![topic0.to_vec(), owner_topic]; + nexum_sdk::events::ChainLogParts { + address: COMPOSABLE_COW.as_slice(), + topics: &topics, + data, + block_number: Some(position.block), + log_index: Some(position.index), + ..Default::default() + } + .into() + } + + /// A well-formed `ConditionalOrderCreated` mined at `position`. + fn make_log(owner: Address, params: &ConditionalOrderParams, position: LogPosition) -> Log { + make_event_log( + owner, + ConditionalOrderCreated::SIGNATURE_HASH, + ¶ms.abi_encode(), + position, + ) + } + + /// A well-formed v2 `ConditionalOrderRemoved` mined at `position`. + fn make_removed_log(owner: Address, hash: B256, position: LogPosition) -> Log { + make_event_log( + owner, + ConditionalOrderRemoved::SIGNATURE_HASH, + &hash.abi_encode(), + position, + ) + } + + /// Build the `params_json` `poll_one` passes to `host.request`. + fn programmed_eth_call_params(owner: Address, params: &ConditionalOrderParams) -> String { + let call = abi::getTradeableOrderWithSignatureCall { + owner, + params: abi::Params { + handler: params.handler, + salt: params.salt, + staticInput: params.staticInput.clone(), + }, + offchainInput: Bytes::new(), + proof: Vec::new(), + }; + eth_call_params(&COMPOSABLE_COW, &call.abi_encode()) + } + + /// JSON-encode a hex blob as a JSON-RPC `result` field. + fn quoted_hex(bytes: &[u8]) -> String { + let hex = alloy_primitives::hex::encode_prefixed(bytes); + serde_json::to_string(&hex).unwrap() + } + + /// Pre-seed a `watch:` row as the indexer would for a create at + /// block 1, index 0. + fn seed_watch(host: &MockHost, owner: Address, params: &ConditionalOrderParams) -> String { + let encoded = params.abi_encode(); + let key = watch_key(&owner, &keccak256(&encoded)); + host.store + .set(&key, &encode_row(Some(at(1, 0)), &encoded)) + .unwrap(); + key + } + + fn sample_block(number: u64) -> BlockInfo { + BlockInfo { + chain_id: SEPOLIA, + number, + timestamp: 1_700_000_000_000, + } + } + + #[test] + fn index_records_new_watch_on_conditional_order_created() { + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let log = make_log(owner, ¶ms, at(42, 9)); + + on_chain_logs(&host, &[log]).unwrap(); + + let expected_key = watch_key(&owner, &keccak256(params.abi_encode())); + assert_eq!(host.store.len(), 1); + let store = host.store.snapshot(); + let row = store.get(&expected_key).expect("watch row present"); + let (indexed_at, stored) = decode_row(row).expect("row decodes"); + assert_eq!(indexed_at, Some(at(42, 9)), "row carries the log position"); + assert_eq!(stored, params.abi_encode(), "row carries the params"); + } + + #[test] + fn index_overwrites_in_place_on_redelivered_log() { + // Re-indexing the same `(owner, params)` + // pair must be a no-op on top of the existing watch - re-org + // replays and overlapping subscription windows are normal. + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + + on_chain_logs(&host, &[make_log(owner, ¶ms, at(42, 9))]).unwrap(); + // Re-deliver the same log. + on_chain_logs(&host, &[make_log(owner, ¶ms, at(42, 9))]).unwrap(); + + assert_eq!(host.store.len(), 1, "redelivery must not duplicate watches"); + } + + #[test] + fn decodes_well_formed_removed_log() { + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let hash = b256!("0303030303030303030303030303030303030303030303030303030303030303"); + let log = make_removed_log(owner, hash, at(1, 0)); + + let (decoded_owner, decoded_hash) = + decode_conditional_order_removed(&log).expect("decode succeeds"); + assert_eq!(decoded_owner, owner); + assert_eq!(decoded_hash, hash); + // The two decoders never cross-match: topic-0 keeps them apart. + assert!(decode_conditional_order_created(&log).is_none()); + assert!( + decode_conditional_order_removed(&make_log(owner, &sample_params(), at(1, 0))) + .is_none() + ); + } + + #[test] + fn watch_row_codec_round_trips() { + for indexed_at in [None, Some(at(3, 7))] { + let row = encode_row(indexed_at, b"payload"); + let (decoded_at, params) = decode_row(&row).expect("row decodes"); + assert_eq!(decoded_at, indexed_at); + assert_eq!(params, b"payload"); + } + assert!(decode_row(&[]).is_none(), "short row is malformed"); + let mut bad_tag = encode_row(None, b"payload"); + bad_tag[0] = 2; + assert!(decode_row(&bad_tag).is_none(), "unknown tag is malformed"); + } + + #[test] + fn removal_drops_watch_and_gates_and_spares_the_rest() { + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let key = seed_watch(&host, owner, ¶ms); + let (owner_hex, hash_hex) = parse_watch_key(&key).unwrap(); + host.store + .set( + &format!("next_block:{owner_hex}:{hash_hex}"), + &500u64.to_le_bytes(), + ) + .unwrap(); + host.store + .set( + &format!("next_epoch:{owner_hex}:{hash_hex}"), + &1_700_000_000u64.to_le_bytes(), + ) + .unwrap(); + // A sibling watch under a different hash must survive. + let mut other = sample_params(); + other.salt = b256!("0202020202020202020202020202020202020202020202020202020202020202"); + let other_key = seed_watch(&host, owner, &other); + + let hash = keccak256(params.abi_encode()); + on_chain_logs(&host, &[make_removed_log(owner, hash, at(2, 0))]).unwrap(); + + let store = host.store.snapshot(); + assert!(!store.contains_key(&key), "removal must drop the watch"); + assert!(!store.contains_key(&format!("next_block:{owner_hex}:{hash_hex}"))); + assert!(!store.contains_key(&format!("next_epoch:{owner_hex}:{hash_hex}"))); + assert!(store.contains_key(&other_key), "sibling watch survives"); + } + + #[test] + fn removal_of_an_unindexed_watch_is_a_no_op() { + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let hash = keccak256(sample_params().abi_encode()); + + on_chain_logs(&host, &[make_removed_log(owner, hash, at(2, 0))]).unwrap(); + + assert_eq!(host.store.len(), 0); + } + + #[test] + fn later_removal_in_its_own_dispatch_drops_the_watch() { + // The runtime dispatches each log singly; a create and its + // genuine removal always arrive as two separate calls. + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let hash = keccak256(params.abi_encode()); + + on_chain_logs(&host, &[make_log(owner, ¶ms, at(7, 5))]).unwrap(); + on_chain_logs(&host, &[make_removed_log(owner, hash, at(9, 0))]).unwrap(); + + assert_eq!(host.store.len(), 0, "a postdating removal lands"); + } + + #[test] + fn same_block_later_removal_drops_the_watch() { + // A create and its removal can share a block, so ordering rests + // on the log index alone. This is the later-index half of the + // boundary; the stale test below pins the earlier-index half. + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let hash = keccak256(params.abi_encode()); + + on_chain_logs(&host, &[make_log(owner, ¶ms, at(7, 5))]).unwrap(); + on_chain_logs(&host, &[make_removed_log(owner, hash, at(7, 6))]).unwrap(); + + assert_eq!( + host.store.len(), + 0, + "a same-block removal at a later log index lands", + ); + } + + #[test] + fn stale_removal_arriving_after_a_re_registered_create_is_ignored() { + // `remove(hash)` + `create(same params)` in one call + // re-registers the same hash at a later log index. The two + // subscription streams merge in arrival order, so the earlier + // remove can arrive after the later create, each as its own + // single-log dispatch. The live watch must survive. + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let hash = keccak256(params.abi_encode()); + let key = watch_key(&owner, &hash); + + on_chain_logs(&host, &[make_log(owner, ¶ms, at(7, 5))]).unwrap(); + on_chain_logs(&host, &[make_removed_log(owner, hash, at(7, 4))]).unwrap(); + + assert!( + host.store.snapshot().contains_key(&key), + "a stale removal must not drop the re-registered watch", + ); + } + + #[test] + fn redelivered_older_create_keeps_the_later_stamp() { + // A cursor rewind can redeliver an old create after a newer + // registration of the same params; the stamp must not age, or + // the stale removal between them would land. + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let hash = keccak256(params.abi_encode()); + let key = watch_key(&owner, &hash); + + on_chain_logs(&host, &[make_log(owner, ¶ms, at(7, 5))]).unwrap(); + on_chain_logs(&host, &[make_log(owner, ¶ms, at(2, 0))]).unwrap(); + on_chain_logs(&host, &[make_removed_log(owner, hash, at(7, 4))]).unwrap(); + + assert!( + host.store.snapshot().contains_key(&key), + "the stamp keeps the latest indexed position", + ); + } + + #[test] + fn removal_without_a_mined_position_keeps_the_watch() { + // A removal whose position cannot be proven later than the + // create is ignored; the poll path's drop verdict is the + // self-healing teardown for a truly removed order. + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let hash = keccak256(params.abi_encode()); + let key = watch_key(&owner, &hash); + + on_chain_logs(&host, &[make_log(owner, ¶ms, at(7, 5))]).unwrap(); + let mut pending = make_removed_log(owner, hash, at(9, 0)); + pending.block_number = None; + pending.log_index = None; + on_chain_logs(&host, &[pending]).unwrap(); + + assert!(host.store.snapshot().contains_key(&key)); + } + + #[test] + fn poll_skips_when_next_block_gate_is_in_future() { + let host = MockHost::new(); + let venue = MockVenue::default(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let key = seed_watch(&host, owner, ¶ms); + let (_, hash_hex) = parse_watch_key(&key).unwrap(); + let owner_hex = format!("{owner:#x}"); + // Gate the watch at block 500; poll at block 100. + host.store + .set( + &format!("next_block:{owner_hex}:{hash_hex}"), + &500u64.to_le_bytes(), + ) + .unwrap(); + + dispatch(&host, &venue, sample_block(100)).unwrap(); + + assert_eq!( + host.chain.call_count(), + 0, + "gated watch must not issue eth_call" + ); + assert_eq!(venue.submit_count(), 0); + } + + #[test] + fn poll_ready_submits_the_intent_body_through_the_pool() { + let host = MockHost::new(); + let venue = MockVenue::default(); + let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); + let params = sample_params(); + seed_watch(&host, owner, ¶ms); + + let ready_order = submittable_order(); + let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); + let wire = (ready_order.clone(), signature.clone()).abi_encode_params(); + host.chain.respond_to( + "eth_call", + programmed_eth_call_params(owner, ¶ms), + Ok(quoted_hex(&wire)), + ); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(hex!("feedface").to_vec()))); + + dispatch(&host, &venue, sample_block(1_000)).unwrap(); + + let expected_body = signed_intent_body(&ready_order, &signature, owner) + .expect("canonical markers") + .to_bytes() + .expect("body encodes"); + let expected_id = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); + assert_eq!(host.chain.call_count(), 1); + let submits = venue.submits(); + assert_eq!(submits.len(), 1); + assert_eq!( + submits[0].0, + CowVenue::ID.as_str(), + "routed to the cow venue" + ); + assert_eq!( + submits[0].1, expected_body, + "the wire carries the intent body" + ); + assert!( + host.store + .snapshot() + .contains_key(&format!("submitted:{expected_id}")), + "expected submitted:{{intent_id}} marker" + ); + assert!( + !host.store.snapshot().contains_key("submitted:0xfeedface"), + "marker must key on the pre-submit intent-id, not the venue receipt" + ); + } + + /// Guard: a repeated Ready tuple in consecutive ticks must not + /// re-submit; the `submitted:{intent_id}` short-circuit in + /// `submit_ready` prevents it. + #[test] + fn poll_ready_skips_submit_when_the_intent_id_is_already_journalled() { + let host = MockHost::new(); + let venue = MockVenue::default(); + let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); + let params = sample_params(); + seed_watch(&host, owner, ¶ms); + + let ready_order = submittable_order(); + let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); + let wire = (ready_order.clone(), signature.clone()).abi_encode_params(); + host.chain.respond_to( + "eth_call", + programmed_eth_call_params(owner, ¶ms), + Ok(quoted_hex(&wire)), + ); + + // Seed the marker that a previous successful poll-tick would + // have written. The poll path must read this and skip; the + // venue submit must not be attempted. + let already_submitted = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); + host.store + .set(&format!("submitted:{already_submitted}"), b"") + .expect("seed submitted marker"); + + dispatch(&host, &venue, sample_block(1_000)).unwrap(); + + assert_eq!( + host.chain.call_count(), + 1, + "poll still consults the chain to see Ready", + ); + assert_eq!( + venue.submit_count(), + 0, + "the venue must NOT be touched when submitted:{{intent_id}} already exists", + ); + } + + /// A Ready order's non-empty `appData` digest rides the intent body + /// verbatim; assembly into the orderbook wire shape is the adapter's. + #[test] + fn poll_ready_carries_a_non_empty_app_data_digest_in_the_body() { + let host = MockHost::new(); + let venue = MockVenue::default(); + let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); + let params = sample_params(); + seed_watch(&host, owner, ¶ms); + + let app_data_hash = keccak256(b"registered elsewhere; this client never sees the doc"); + let mut ready_order = submittable_order(); + ready_order.appData = app_data_hash; + + let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); + let wire = (ready_order.clone(), signature.clone()).abi_encode_params(); + host.chain.respond_to( + "eth_call", + programmed_eth_call_params(owner, ¶ms), + Ok(quoted_hex(&wire)), + ); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(hex!("feedface").to_vec()))); + + dispatch(&host, &venue, sample_block(1_000)).unwrap(); + + assert_eq!( + host.chain.call_count(), + 1, + "exactly one eth_call to poll Ready" + ); + let submits = venue.submits(); + assert_eq!(submits.len(), 1, "exactly one venue submit"); + let cow_venue::CowIntentBody::V1(cow_venue::CowIntent::Signed(signed)) = + cow_venue::CowIntentBody::from_bytes(&submits[0].1).expect("body decodes") + else { + panic!("expected a signed V1 intent"); + }; + assert_eq!( + signed.order.app_data, app_data_hash.0, + "the digest goes out verbatim in the body", + ); + let expected_id = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); + assert!( + host.store + .snapshot() + .contains_key(&format!("submitted:{expected_id}")), + "submitted:{{intent_id}} marker must be written after a successful submit" + ); + } + + #[test] + fn submit_transient_fault_leaves_state_unchanged_for_next_block() { + let host = MockHost::new(); + let venue = MockVenue::default(); + let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); + let params = sample_params(); + let watch_key_str = seed_watch(&host, owner, ¶ms); + + let ready_order = submittable_order(); + let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); + let wire = (ready_order, signature).abi_encode_params(); + host.chain.respond_to( + "eth_call", + programmed_eth_call_params(owner, ¶ms), + Ok(quoted_hex(&wire)), + ); + + // The adapter projects a retriable rejection onto `unavailable`, + // which the retry table folds to TryNextBlock. + venue.enqueue_submit(Err(VenueFault::Unavailable( + "InsufficientFee: fee too low".into(), + ))); + + let (result, logs) = capture_tracing(|| dispatch(&host, &venue, sample_block(1_000))); + result.unwrap(); + + // Watch still present, no gate written, no submitted marker. + assert!(host.store.snapshot().contains_key(&watch_key_str)); + let (owner_hex, hash_hex) = parse_watch_key(&watch_key_str).unwrap(); + assert!( + !host + .store + .snapshot() + .contains_key(&format!("next_epoch:{owner_hex}:{hash_hex}")), + ); + assert!( + !host + .store + .snapshot() + .keys() + .any(|k| k.starts_with("submitted:")), + ); + logs.expect_one(|e| { + e.level == Level::WARN && e.message.contains("submit retry-next-block") + }); + } + + /// A rate-limited refusal backs the watch off on the epoch clock + /// instead of hot-looping the submit. + #[test] + fn submit_rate_limited_backs_off_on_the_epoch_gate() { + let host = MockHost::new(); + let venue = MockVenue::default(); + let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); + let params = sample_params(); + let watch_key_str = seed_watch(&host, owner, ¶ms); + + let ready_order = submittable_order(); + let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); + let wire = (ready_order, signature).abi_encode_params(); + host.chain.respond_to( + "eth_call", + programmed_eth_call_params(owner, ¶ms), + Ok(quoted_hex(&wire)), + ); + venue.enqueue_submit(Err(VenueFault::RateLimited { + retry_after_ms: Some(2_500), + })); + + dispatch(&host, &venue, sample_block(1_000)).unwrap(); + + let snapshot = host.store.snapshot(); + assert!( + snapshot.contains_key(&watch_key_str), + "backoff must keep the watch" + ); + let (owner_hex, hash_hex) = parse_watch_key(&watch_key_str).unwrap(); + assert_eq!( + snapshot + .get(&format!("next_epoch:{owner_hex}:{hash_hex}")) + .unwrap(), + &(1_700_000_000_u64 + 3).to_le_bytes().to_vec(), + "2500ms rounds up to a 3s backoff from the tick clock", + ); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); + } + + #[test] + fn submit_denied_drops_watch() { + let host = MockHost::new(); + let venue = MockVenue::default(); + let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); + let params = sample_params(); + let watch_key_str = seed_watch(&host, owner, ¶ms); + + let ready_order = submittable_order(); + let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); + let wire = (ready_order, signature).abi_encode_params(); + host.chain.respond_to( + "eth_call", + programmed_eth_call_params(owner, ¶ms), + Ok(quoted_hex(&wire)), + ); + + // The adapter projects a permanent rejection onto `denied`, + // which the retry table folds to Drop. + venue.enqueue_submit(Err(VenueFault::Denied("InvalidSignature: bad sig".into()))); + + dispatch(&host, &venue, sample_block(1_000)).unwrap(); + + let store = host.store.snapshot(); + assert!( + !store.contains_key(&watch_key_str), + "permanent refusal must drop the watch" + ); + let (owner_hex, hash_hex) = parse_watch_key(&watch_key_str).unwrap(); + assert!(!store.contains_key(&format!("next_block:{owner_hex}:{hash_hex}"))); + assert!(!store.contains_key(&format!("next_epoch:{owner_hex}:{hash_hex}"))); + assert!(!store.keys().any(|k| k.starts_with("submitted:"))); + } + + #[test] + fn poll_invalid_drops_watch_and_gates() { + // When `LegacyRevertAdapter` produces `Invalid`, the lifecycle + // layer must delete the watch and any stale gates. Simulate the + // wire shape the chain backend forwards: a `ChainError::Rpc` + // carrying the already-decoded `OrderNotValid` revert bytes. + use alloy_sol_types::SolError; + use composable_cow::IConditionalOrder; + use nexum_sdk::host::RpcError; + + let host = MockHost::new(); + let venue = MockVenue::default(); + let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); + let params = sample_params(); + let watch_key_str = seed_watch(&host, owner, ¶ms); + let (owner_hex, hash_hex) = parse_watch_key(&watch_key_str).unwrap(); + host.store + .set( + &format!("next_block:{owner_hex}:{hash_hex}"), + &0u64.to_le_bytes(), + ) + .unwrap(); + + let revert = IConditionalOrder::OrderNotValid { + reason: "dead".into(), + } + .abi_encode(); + host.chain.respond_to( + "eth_call", + programmed_eth_call_params(owner, ¶ms), + Err(ChainError::Rpc(RpcError { + code: -32000, + message: "execution reverted".into(), + data: Some(revert.into()), + })), + ); + + let (result, logs) = capture_tracing(|| dispatch(&host, &venue, sample_block(1_000))); + result.unwrap(); + + assert!(!host.store.snapshot().contains_key(&watch_key_str)); + assert!( + !host + .store + .snapshot() + .contains_key(&format!("next_block:{owner_hex}:{hash_hex}")), + ); + assert_eq!(venue.submit_count(), 0, "revert-to-drop path never submits"); + // The destructive drop carries its cause: the revert selector + // and the node's message ride the Warn, and the keeper logs + // the removal itself. + let warn = logs.expect_one(|e| { + e.level == Level::WARN && e.message.contains("eth_call reverted permanently") + }); + assert!(warn.message.contains("execution reverted")); + let selector_hex = + alloy_primitives::hex::encode_prefixed(&IConditionalOrder::OrderNotValid::SELECTOR[..]); + assert!( + warn.message.contains(&selector_hex), + "the four-byte selector must be greppable: {}", + warn.message, + ); + logs.expect_one(|e| { + e.message + .contains(&format!("dropped watch {watch_key_str}")) + }); + } + + /// The supervisor builds log filters from this manifest's chain-log + /// `event_signature` pins, so a drift from a decoder topic-0 + /// subscribes to one topic and decodes another. Compares the two + /// sets, so a missing or unhandled pin fails too. + #[test] + fn manifest_topics_match_the_decoder_signature_hashes() { + let manifest: toml::Value = + toml::from_str(include_str!("../module.toml")).expect("module.toml parses"); + let pinned: std::collections::BTreeSet = manifest["subscription"] + .as_array() + .expect("module.toml declares subscriptions") + .iter() + .filter(|sub| sub.get("kind").and_then(toml::Value::as_str) == Some("chain-log")) + .map(|sub| { + sub.get("event_signature") + .and_then(toml::Value::as_str) + .expect("every chain-log subscription pins an event_signature") + .parse() + .expect("event_signature is a b256") + }) + .collect(); + let decoded = std::collections::BTreeSet::from([ + ConditionalOrderCreated::SIGNATURE_HASH, + ConditionalOrderRemoved::SIGNATURE_HASH, + ]); + assert_eq!( + pinned, decoded, + "module.toml chain-log topics and the sol! decoder topic-0s have diverged", + ); + } +} diff --git a/shepherd/modules/twap-monitor/src/lib.rs b/shepherd/modules/twap-monitor/src/lib.rs new file mode 100644 index 00000000..3f24ebbe --- /dev/null +++ b/shepherd/modules/twap-monitor/src/lib.rs @@ -0,0 +1,56 @@ +//! # twap-monitor (Shepherd keeper module) +//! +//! Indexes `ComposableCoW.ConditionalOrderCreated` and v2 +//! `ConditionalOrderRemoved` logs and polls each watched conditional +//! order on every block, submitting tranches to the CoW venue as they +//! go live. Pure logic lives in `keeper`; `lib.rs` is the +//! `#[videre_sdk::keeper]` glue. + +// wit_bindgen::generate! expands to host-import shims whose arity +// matches the WIT signatures, which can exceed clippy's +// too-many-arguments threshold. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +mod keeper; + +use cow_venue::CowClient; +use nexum::host::types; + +struct TwapMonitor; + +#[videre_sdk::keeper] +impl TwapMonitor { + fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { + install_tracing(); + tracing::info!("twap-monitor init"); + Ok(()) + } + + fn on_chain_logs(batch: types::ChainLogs) -> Result<(), Fault> { + let logs: Vec = batch.logs.into_iter().map(Into::into).collect(); + keeper::on_chain_logs(&WitBindgenHost, &logs)?; + Ok(()) + } + + fn on_block(block: types::Block) -> Result<(), Fault> { + let info = keeper::BlockInfo { + chain_id: block.chain_id, + number: block.number, + timestamp: block.timestamp, + }; + keeper::on_block(&WitBindgenHost, &CowClient::new(), info)?; + Ok(()) + } + + fn on_intent_status(update: videre_sdk::IntentStatusUpdate) -> Result<(), Fault> { + let body = videre_sdk::status_body::StatusBody::decode(&update.status) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; + tracing::info!( + "cow intent status {:?} ({} receipt bytes)", + body.status, + update.receipt.len(), + ); + Ok(()) + } +} diff --git a/tools/baseline-latency/.gitignore b/shepherd/tools/baseline-latency/.gitignore similarity index 100% rename from tools/baseline-latency/.gitignore rename to shepherd/tools/baseline-latency/.gitignore diff --git a/tools/baseline-latency/baseline_latency.py b/shepherd/tools/baseline-latency/baseline_latency.py similarity index 100% rename from tools/baseline-latency/baseline_latency.py rename to shepherd/tools/baseline-latency/baseline_latency.py diff --git a/tools/baseline-latency/data/arbitrum_one.json b/shepherd/tools/baseline-latency/data/arbitrum_one.json similarity index 100% rename from tools/baseline-latency/data/arbitrum_one.json rename to shepherd/tools/baseline-latency/data/arbitrum_one.json diff --git a/tools/baseline-latency/data/base.json b/shepherd/tools/baseline-latency/data/base.json similarity index 100% rename from tools/baseline-latency/data/base.json rename to shepherd/tools/baseline-latency/data/base.json diff --git a/tools/baseline-latency/data/gnosis.json b/shepherd/tools/baseline-latency/data/gnosis.json similarity index 100% rename from tools/baseline-latency/data/gnosis.json rename to shepherd/tools/baseline-latency/data/gnosis.json diff --git a/tools/baseline-latency/data/mainnet.json b/shepherd/tools/baseline-latency/data/mainnet.json similarity index 100% rename from tools/baseline-latency/data/mainnet.json rename to shepherd/tools/baseline-latency/data/mainnet.json diff --git a/tools/baseline-latency/data/sepolia.json b/shepherd/tools/baseline-latency/data/sepolia.json similarity index 100% rename from tools/baseline-latency/data/sepolia.json rename to shepherd/tools/baseline-latency/data/sepolia.json diff --git a/tools/orderbook-mock/Cargo.toml b/shepherd/tools/orderbook-mock/Cargo.toml similarity index 100% rename from tools/orderbook-mock/Cargo.toml rename to shepherd/tools/orderbook-mock/Cargo.toml diff --git a/tools/orderbook-mock/src/main.rs b/shepherd/tools/orderbook-mock/src/main.rs similarity index 82% rename from tools/orderbook-mock/src/main.rs rename to shepherd/tools/orderbook-mock/src/main.rs index 0ad9d4aa..897e1b46 100644 --- a/tools/orderbook-mock/src/main.rs +++ b/shepherd/tools/orderbook-mock/src/main.rs @@ -1,23 +1,10 @@ //! Mock CoW orderbook for shepherd load tests. //! -//! Serves the one endpoint shepherd's `cow-api` host backend hits on -//! every order submission: -//! -//! - `POST /api/v1/orders` - accepts any body, returns a synthetic -//! 56-byte OrderUid as a JSON-encoded hex string. Counts a request -//! for the operator report. -//! -//! Operator knobs (CLI): -//! - `--port` (default 9999) -//! - `--latency-ms` artificial latency injected into every response -//! - `--error-rate` fraction of `POST /api/v1/orders` responses that -//! return a recognised `ApiError` envelope; lets the load test -//! exercise the strategy's `Drop` / `TryNextBlock` paths. -//! -//! Not a faithful orderbook simulator - the load test cares about -//! shepherd's throughput when the orderbook responds quickly, not -//! about the orderbook's own behaviour. For real-orderbook fidelity -//! see the backtest against live `/api/v1/quote`. +//! Serves `POST /api/v1/orders`: accepts any body, returns a synthetic +//! 56-byte OrderUid as a JSON hex string. CLI knobs `--port`, +//! `--latency-ms`, and `--error-rate` (fraction of responses returning +//! a recognised `ApiError` envelope, exercising the strategy's `Drop` / +//! `TryNextBlock` paths). Not a faithful simulator. #![cfg_attr(not(test), warn(unused_crate_dependencies))] @@ -51,11 +38,9 @@ struct Cli { #[arg(long, default_value_t = 0)] latency_ms: u64, - /// Fraction of POST /api/v1/orders responses that return a - /// recognised error envelope instead of a 201 success. 0.0 = all - /// success; 1.0 = all error. Errors cycle between - /// `InsufficientFee` (transient -> TryNextBlock) and - /// `InvalidSignature` (permanent -> Drop). + /// Fraction of `POST /api/v1/orders` responses returning an error + /// envelope (0.0 all success, 1.0 all error). Errors cycle + /// `InsufficientFee` (transient) and `InvalidSignature` (permanent). #[arg(long, default_value_t = 0.0)] error_rate: f64, } @@ -147,7 +132,7 @@ async fn post_orders(State(state): State>, body: String) -> impl I state.counters.submits_err.fetch_add(1, Ordering::Relaxed); // Alternate transient + permanent so the load test exercises // both `TryNextBlock` and `Drop` paths through - // `shepherd_sdk::cow::classify_api_error`. + // `cow_venue::classification::classify`. let n = state.counters.submits_err.load(Ordering::Relaxed); let api = if n.is_multiple_of(2) { ApiError { @@ -183,10 +168,7 @@ async fn post_orders(State(state): State>, body: String) -> impl I (StatusCode::CREATED, uid_hex).into_response() } -/// Tiny inline hex encoder - the mock does not depend on `alloy` to -/// keep its dependency surface minimal. (The engine uses -/// `alloy_primitives::hex::encode_prefixed` instead; that rule -/// applies to the engine, not to one-off test tooling.) +/// Inline hex encoder; keeps the mock's dependency surface minimal. fn hex_encode_inline(bytes: &[u8]) -> String { use std::fmt::Write as _; let mut s = String::with_capacity(bytes.len() * 2); diff --git a/tools/backtest-collect/backtest_collect.py b/tools/backtest-collect/backtest_collect.py deleted file mode 100644 index 19ef9cd8..00000000 --- a/tools/backtest-collect/backtest_collect.py +++ /dev/null @@ -1,578 +0,0 @@ -#!/usr/bin/env python3 -"""Collect a Sepolia event window for the pre-soak backtest. - -Pulls every on-chain -- `CoWSwapEthFlow.OrderPlacement` (EthFlow lane), and -- `ComposableCoW.ConditionalOrderCreated` (TWAP lane) - -in the trailing `--days` window on Sepolia, ABI-decodes the payloads, -derives the EthFlow `OrderUid` via EIP-712, resolves any non-empty -`appData` hashes via the orderbook's `/api/v1/app_data/{hash}` lookup, -and emits a single fixtures JSON the Rust replay harness -(`crates/shepherd-backtest`) consumes. - -The script is read-only (no on-chain submissions, no orderbook PUTs). -It only hits the configured RPC endpoint + `GET` against the cow.fi -orderbook. - -## Scope - -Phase 1 MVP collects events + decoded payloads + app_data only. It -does NOT walk every TWAP watch with `eth_call(getTradeableOrderWith -Signature)` per block — that requires an archive-tier RPC plan. -The replay harness will perform that walk on demand -once a paid endpoint is wired; until then the TWAP replay is bounded -to "would the strategy assemble a child body on the first `Ready` -window?" and the EthFlow replay is fully exercisable from the -collected fixtures alone. - -## Output shape - -``` -{ - "metadata": { - "collected_at": "2026-06-22T15:00:00Z", - "chain_id": 11155111, - "chain_name": "Sepolia", - "window_days": 7, - "from_block": 11065713, - "to_block": 11116113, - "rpc_url": "https://sepolia.drpc.org", - "cow_api": "https://api.cow.fi/sepolia/api/v1", - "ethflow_owner": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "composable_cow": "0xfdafc9d1902f4e0b84f65f49f244b32b31013b74" - }, - "ethflow_orders": [ { "uid": "0x...", "block_number": ..., "block_timestamp": ..., "tx_hash": "0x...", "log_index": ..., "sender": "0x...", "contract": "0x...", "gpv2_order": {...}, "signature": {"scheme": 0, "payload": "0x..."}, "extra_data": "0x...", "app_data_resolved": null | {"hash": "0x...", "document": "..."} } ], - "twap_conditionals": [ { "owner": "0x...", "block_number": ..., "block_timestamp": ..., "tx_hash": "0x...", "log_index": ..., "params": {"handler": "0x...", "salt": "0x...", "static_input": "0x..."} } ] -} -``` - -Usage: - - python3 tools/backtest-collect/backtest_collect.py \ - --days 7 \ - --out tools/backtest-collect/fixtures-$(date -u +%Y-%m-%d).json -""" - -from __future__ import annotations - -import argparse -import json -import os -import sys -import time -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -try: - import requests - from eth_abi import decode as abi_decode - from eth_utils import keccak -except ImportError: - sys.stderr.write( - "missing deps. install with: " - "pip3 install requests eth-abi eth-utils \"eth-hash[pycryptodome]\"\n" - ) - sys.exit(1) - - -# ----------------------------------------------------------------- pinned identities - -# EthFlow contract Sepolia deployment (see docs/operations/e2e-prep.md). -ETH_FLOW_SEPOLIA = "0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC" - -# ComposableCoW is CREATE2'd to the same address on every chain. -COMPOSABLE_COW = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" - -# GPv2Settlement is also identical across chains. -GPV2_SETTLEMENT = "0x9008D19f58AAbD9eD0D60971565AA8510560ab41" - -# topic0 = keccak("OrderPlacement(address,(...12 GPv2Order fields...),(uint8,bytes),bytes)") -ORDER_PLACEMENT_TOPIC = ( - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" -) - -# topic0 = keccak("ConditionalOrderCreated(address,(address,bytes32,bytes))") -CONDITIONAL_ORDER_CREATED_TOPIC = ( - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361" -) - - -# ----------------------------------------------------------------- EIP-712 - -EIP712_DOMAIN_TYPEHASH = keccak( - b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" -) -GPV2_DOMAIN_NAME_HASH = keccak(b"Gnosis Protocol") -GPV2_DOMAIN_VERSION_HASH = keccak(b"v2") -ORDER_TYPEHASH = keccak( - b"Order(address sellToken,address buyToken,address receiver," - b"uint256 sellAmount,uint256 buyAmount,uint32 validTo," - b"bytes32 appData,uint256 feeAmount,string kind," - b"bool partiallyFillable,string sellTokenBalance,string buyTokenBalance)" -) - - -def domain_separator(chain_id: int) -> bytes: - """GPv2Settlement EIP-712 domain separator for a given chain id.""" - return keccak( - EIP712_DOMAIN_TYPEHASH - + GPV2_DOMAIN_NAME_HASH - + GPV2_DOMAIN_VERSION_HASH - + chain_id.to_bytes(32, "big") - + bytes(12) + bytes.fromhex(GPV2_SETTLEMENT[2:]) - ) - - -def _pad20(addr_str: str) -> bytes: - raw = bytes.fromhex(addr_str[2:] if addr_str.startswith("0x") else addr_str) - if len(raw) == 20: - return bytes(12) + raw - if len(raw) == 32: - return raw - raise ValueError(f"bad address length: {len(raw)}") - - -def order_uid(order: dict, owner: str, chain_id: int) -> str: - """Derive the 56-byte OrderUid for a GPv2OrderData + owner.""" - struct_hash = keccak( - ORDER_TYPEHASH - + _pad20(order["sellToken"]) - + _pad20(order["buyToken"]) - + _pad20(order["receiver"]) - + order["sellAmount"].to_bytes(32, "big") - + order["buyAmount"].to_bytes(32, "big") - + order["validTo"].to_bytes(32, "big") - + bytes(order["appData"]) - + order["feeAmount"].to_bytes(32, "big") - + bytes(order["kind"]) - + (b"\x00" * 31 + (b"\x01" if order["partiallyFillable"] else b"\x00")) - + bytes(order["sellTokenBalance"]) - + bytes(order["buyTokenBalance"]) - ) - order_digest = keccak(b"\x19\x01" + domain_separator(chain_id) + struct_hash) - owner_b = bytes.fromhex(owner[2:] if owner.startswith("0x") else owner) - if len(owner_b) != 20: - raise ValueError(f"bad owner length: {len(owner_b)}") - return "0x" + (order_digest + owner_b + order["validTo"].to_bytes(4, "big")).hex() - - -# ----------------------------------------------------------------- decoding - -def decode_order_placement(log_data_hex: str) -> dict | None: - """ABI-decode `OrderPlacement.data` into GPv2OrderData + signature + extra. - - Event signature: - OrderPlacement( - address indexed sender, // topic1 - GPv2Order order, - OnchainSignature signature, // (uint8 scheme, bytes payload) - bytes data, - ) - - The data payload encodes `(order, signature, data)`. - """ - raw = bytes.fromhex(log_data_hex[2:] if log_data_hex.startswith("0x") else log_data_hex) - try: - order, sig, extra = abi_decode( - [ - "(address,address,address,uint256,uint256,uint32," - "bytes32,uint256,bytes32,bool,bytes32,bytes32)", - "(uint8,bytes)", - "bytes", - ], - raw, - ) - except Exception: - return None - return { - "order": { - "sellToken": order[0], - "buyToken": order[1], - "receiver": order[2], - "sellAmount": order[3], - "buyAmount": order[4], - "validTo": order[5], - "appData": order[6], - "feeAmount": order[7], - "kind": order[8], - "partiallyFillable": order[9], - "sellTokenBalance": order[10], - "buyTokenBalance": order[11], - }, - "signature": {"scheme": sig[0], "payload": "0x" + sig[1].hex()}, - "extra_data": "0x" + extra.hex(), - } - - -def decode_conditional_order_params(log_data_hex: str) -> dict | None: - """ABI-decode `ConditionalOrderCreated.data` into the ConditionalOrderParams tuple. - - Event signature: - ConditionalOrderCreated( - address indexed owner, // topic1 - ConditionalOrderParams params, // (address handler, bytes32 salt, bytes staticInput) - ) - """ - raw = bytes.fromhex(log_data_hex[2:] if log_data_hex.startswith("0x") else log_data_hex) - try: - (params,) = abi_decode(["(address,bytes32,bytes)"], raw) - except Exception: - return None - handler, salt, static_input = params - return { - "handler": handler, - "salt": "0x" + salt.hex(), - "static_input": "0x" + static_input.hex(), - } - - -def order_to_json(order: dict) -> dict: - """Re-serialise a decoded GPv2Order as JSON-safe types.""" - return { - "sellToken": order["sellToken"], - "buyToken": order["buyToken"], - "receiver": order["receiver"], - "sellAmount": str(order["sellAmount"]), - "buyAmount": str(order["buyAmount"]), - "validTo": order["validTo"], - "appData": "0x" + order["appData"].hex(), - "feeAmount": str(order["feeAmount"]), - "kind": "0x" + order["kind"].hex(), - "partiallyFillable": order["partiallyFillable"], - "sellTokenBalance": "0x" + order["sellTokenBalance"].hex(), - "buyTokenBalance": "0x" + order["buyTokenBalance"].hex(), - } - - -# ----------------------------------------------------------------- rpc - -def rpc_call(url: str, method: str, params: list, timeout: int = 30) -> Any: - """Minimal JSON-RPC helper. Raises on transport or response errors.""" - r = requests.post( - url, - json={"jsonrpc": "2.0", "method": method, "params": params, "id": 1}, - timeout=timeout, - ) - r.raise_for_status() - data = r.json() - if "error" in data: - raise RuntimeError(f"rpc {method} error: {data['error']}") - return data["result"] - - -def get_block_number(url: str) -> int: - return int(rpc_call(url, "eth_blockNumber", []), 16) - - -def get_block_timestamp(url: str, block_number: int) -> int: - block = rpc_call(url, "eth_getBlockByNumber", [hex(block_number), False]) - if not block: - raise RuntimeError(f"block {block_number} not found") - return int(block["timestamp"], 16) - - -class RpcLimited(RuntimeError): - """Endpoint refused even our smallest chunk size — paid RPC needed.""" - - -def get_logs_chunked( - rpc_url: str, - address: str, - topic0: str, - from_block: int, - to_block: int, - chunk: int = 2000, - consecutive_fail_budget: int = 3, -) -> list[dict]: - """`eth_getLogs` in chunks with halving retry. Mirrors the - baseline-latency tool's behaviour (PR #57): if the endpoint - rejects a chunk we halve it down to a 50-block floor; if we hit - `consecutive_fail_budget` failures even at the floor we raise - `RpcLimited` so the caller can record the constraint.""" - out: list[dict] = [] - cursor = from_block - consecutive_fails = 0 - while cursor <= to_block: - end = min(cursor + chunk - 1, to_block) - try: - logs = rpc_call( - rpc_url, - "eth_getLogs", - [ - { - "fromBlock": hex(cursor), - "toBlock": hex(end), - "address": address, - "topics": [topic0], - } - ], - ) - out.extend(logs) - cursor = end + 1 - consecutive_fails = 0 - except Exception as e: - if chunk > 50: - chunk //= 2 - sys.stderr.write(f" chunk halving to {chunk} after error: {e}\n") - continue - consecutive_fails += 1 - if consecutive_fails >= consecutive_fail_budget: - raise RpcLimited( - f"endpoint refused {consecutive_fails} consecutive calls at chunk={chunk}: {e}" - ) from e - sys.stderr.write( - f" WARN: skipping blocks {cursor}-{end} on chunk={chunk}: {e}\n" - ) - cursor = end + 1 - return out - - -# ----------------------------------------------------------------- app_data - -def fetch_app_data(cow_api: str, app_data_hash_hex: str) -> dict | None: - """`GET /api/v1/app_data/{hash}`. Returns the resolved JSON - document (a dict with `fullAppData` etc.), or `None` on 404. - - The orderbook's `app_data` endpoint exists specifically so - relayers can look up the user-supplied app_data JSON - associated with a given hash (the on-chain order only carries - the hash, not the JSON). Replays that re-submit need the JSON - so the digest matches; the live equivalent is - in twap-monitor / ethflow-watcher.""" - r = requests.get(f"{cow_api}/app_data/{app_data_hash_hex}", timeout=30) - if r.status_code == 404: - return None - r.raise_for_status() - return r.json() - - -# ----------------------------------------------------------------- main - -EMPTY_BYTES32_HEX = "0x" + "00" * 32 - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--days", type=int, default=7) - parser.add_argument( - "--rpc", - default=os.environ.get( - "RPC_URL_SEPOLIA_HTTP", "https://sepolia.drpc.org" - ), - ) - parser.add_argument( - "--cow-api", - default="https://api.cow.fi/sepolia/api/v1", - ) - parser.add_argument( - "--out", - type=Path, - default=Path("tools/backtest-collect") - / f"fixtures-{datetime.now(timezone.utc):%Y-%m-%d}.json", - ) - parser.add_argument( - "--max-events-per-stream", - type=int, - default=500, - help="cap per event type so app_data resolution stays bounded", - ) - args = parser.parse_args() - - chain_id = 11155111 # Sepolia - sys.stderr.write(f"=== backtest-collect (Sepolia, days={args.days}) ===\n") - sys.stderr.write(f" rpc: {args.rpc}\n") - sys.stderr.write(f" cow-api: {args.cow_api}\n") - - head = get_block_number(args.rpc) - head_ts = get_block_timestamp(args.rpc, head) - from_block = max(0, head - args.days * 86400 // 12) - sys.stderr.write(f" scanning blocks {from_block}..{head}\n") - - # ---- EthFlow OrderPlacement ---- - sys.stderr.write("\n[ethflow] fetching OrderPlacement logs\n") - notes: list[str] = [] - try: - ethflow_logs = get_logs_chunked( - args.rpc, ETH_FLOW_SEPOLIA, ORDER_PLACEMENT_TOPIC, from_block, head - ) - except RpcLimited as e: - notes.append(f"ethflow eth_getLogs RPC-LIMITED: {e}") - ethflow_logs = [] - sys.stderr.write(f" events: {len(ethflow_logs)}\n") - if args.max_events_per_stream and len(ethflow_logs) > args.max_events_per_stream: - notes.append( - f"ethflow capped to last {args.max_events_per_stream} of {len(ethflow_logs)}" - ) - ethflow_logs = ethflow_logs[-args.max_events_per_stream:] - - # ---- ComposableCoW ConditionalOrderCreated ---- - sys.stderr.write("\n[twap] fetching ConditionalOrderCreated logs\n") - try: - twap_logs = get_logs_chunked( - args.rpc, COMPOSABLE_COW, CONDITIONAL_ORDER_CREATED_TOPIC, from_block, head - ) - except RpcLimited as e: - notes.append(f"twap eth_getLogs RPC-LIMITED: {e}") - twap_logs = [] - sys.stderr.write(f" events: {len(twap_logs)}\n") - if args.max_events_per_stream and len(twap_logs) > args.max_events_per_stream: - notes.append( - f"twap capped to last {args.max_events_per_stream} of {len(twap_logs)}" - ) - twap_logs = twap_logs[-args.max_events_per_stream:] - - # ---- block timestamp cache (one eth_getBlockByNumber per unique block) ---- - block_ts_cache: dict[int, int] = {head: head_ts} - - def block_ts(b: int) -> int: - if b not in block_ts_cache: - block_ts_cache[b] = get_block_timestamp(args.rpc, b) - return block_ts_cache[b] - - # ---- EthFlow fixtures ---- - sys.stderr.write("\n[ethflow] decoding + UID derivation\n") - ethflow_fixtures: list[dict] = [] - decode_failed = 0 - app_data_hashes_seen: set[str] = set() - for log in ethflow_logs: - decoded = decode_order_placement(log["data"]) - if decoded is None: - decode_failed += 1 - continue - # The OrderPlacement.sender is the indexed topic1 (32 bytes, - # right-padded address). - sender_topic = log["topics"][1] if len(log["topics"]) > 1 else None - sender = "0x" + sender_topic[-40:] if sender_topic else None - # Derive UID via EIP-712 against the EthFlow contract owner. - try: - uid = order_uid(decoded["order"], ETH_FLOW_SEPOLIA, chain_id).lower() - except Exception as e: - sys.stderr.write(f" uid derive failed for {log.get('transactionHash')}: {e}\n") - decode_failed += 1 - continue - block_num = int(log["blockNumber"], 16) - app_data_hex = "0x" + decoded["order"]["appData"].hex() - if app_data_hex.lower() != EMPTY_BYTES32_HEX: - app_data_hashes_seen.add(app_data_hex.lower()) - ethflow_fixtures.append( - { - "uid": uid, - "block_number": block_num, - "block_timestamp": block_ts(block_num), - "tx_hash": log.get("transactionHash"), - "log_index": int(log.get("logIndex", "0x0"), 16), - "contract": ETH_FLOW_SEPOLIA.lower(), - "sender": sender, - "gpv2_order": order_to_json(decoded["order"]), - "signature": decoded["signature"], - "extra_data": decoded["extra_data"], - "app_data_hash": app_data_hex, - "app_data_resolved": None, # filled in below - # Raw eth_getLogs payload so the Rust replay harness - # can reconstruct an exact `ChainLogView` (topics + data - # bytes) without re-encoding from the decoded - # fields. The strategy decodes from raw bytes; fidelity - # matters when the goal is "would the strategy have - # done the same thing it does live?" - "raw_log": { - "topics": log["topics"], - "data": log["data"], - }, - } - ) - if decode_failed: - notes.append(f"ethflow: {decode_failed} events failed to decode/derive") - sys.stderr.write( - f" fixtures: {len(ethflow_fixtures)} (failed: {decode_failed})\n" - ) - - # ---- TWAP fixtures ---- - sys.stderr.write("\n[twap] decoding ConditionalOrderParams\n") - twap_fixtures: list[dict] = [] - twap_decode_failed = 0 - for log in twap_logs: - params = decode_conditional_order_params(log["data"]) - if params is None: - twap_decode_failed += 1 - continue - owner_topic = log["topics"][1] if len(log["topics"]) > 1 else None - owner = "0x" + owner_topic[-40:] if owner_topic else None - block_num = int(log["blockNumber"], 16) - twap_fixtures.append( - { - "owner": owner, - "block_number": block_num, - "block_timestamp": block_ts(block_num), - "tx_hash": log.get("transactionHash"), - "log_index": int(log.get("logIndex", "0x0"), 16), - "params": params, - "raw_log": { - "topics": log["topics"], - "data": log["data"], - }, - } - ) - if twap_decode_failed: - notes.append(f"twap: {twap_decode_failed} events failed to decode") - sys.stderr.write( - f" fixtures: {len(twap_fixtures)} (failed: {twap_decode_failed})\n" - ) - - # ---- app_data resolution (EthFlow only — TWAP staticInput carries its own data) ---- - if app_data_hashes_seen: - sys.stderr.write( - f"\n[app_data] resolving {len(app_data_hashes_seen)} unique hashes\n" - ) - resolved: dict[str, dict | None] = {} - for h in sorted(app_data_hashes_seen): - doc = fetch_app_data(args.cow_api, h) - resolved[h] = doc - if doc is None: - sys.stderr.write(f" {h[:14]}.. 404\n") - not_found = sum(1 for v in resolved.values() if v is None) - if not_found: - notes.append( - f"app_data: {not_found}/{len(app_data_hashes_seen)} hashes 404'd " - f"(not mirrored by orderbook — expected for some external app_data flows)" - ) - # Stitch the resolved documents back into each fixture row. - for fx in ethflow_fixtures: - fx["app_data_resolved"] = resolved.get(fx["app_data_hash"]) - sys.stderr.write( - f" resolved: {len(resolved) - not_found}/{len(app_data_hashes_seen)}\n" - ) - - # ---- write fixtures file ---- - out_doc = { - "metadata": { - "collected_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - "chain_id": chain_id, - "chain_name": "Sepolia", - "window_days": args.days, - "from_block": from_block, - "to_block": head, - "rpc_url": args.rpc, - "cow_api": args.cow_api, - "ethflow_owner": ETH_FLOW_SEPOLIA.lower(), - "composable_cow": COMPOSABLE_COW.lower(), - "notes": notes, - }, - "ethflow_orders": ethflow_fixtures, - "twap_conditionals": twap_fixtures, - } - args.out.parent.mkdir(parents=True, exist_ok=True) - args.out.write_text(json.dumps(out_doc, indent=2)) - sys.stderr.write( - f"\nfixtures written: {args.out}\n" - f" ethflow_orders: {len(ethflow_fixtures)}\n" - f" twap_conditionals: {len(twap_fixtures)}\n" - f" notes: {len(notes)}\n" - ) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/backtest-collect/fixtures-2026-06-22.json b/tools/backtest-collect/fixtures-2026-06-22.json deleted file mode 100644 index 6344593d..00000000 --- a/tools/backtest-collect/fixtures-2026-06-22.json +++ /dev/null @@ -1,9873 +0,0 @@ -{ - "metadata": { - "collected_at": "2026-06-22T15:47:06Z", - "chain_id": 11155111, - "chain_name": "Sepolia", - "window_days": 7, - "from_block": 11066372, - "to_block": 11116772, - "rpc_url": "https://sepolia.drpc.org", - "cow_api": "https://api.cow.fi/sepolia/api/v1", - "ethflow_owner": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "composable_cow": "0xfdafc9d1902f4e0b84f65f49f244b32b31013b74", - "notes": [] - }, - "ethflow_orders": [ - { - "uid": "0x5e43c58407ded1f8efb366d8172bd37b5219dfe52ca8381d5a5664006625df61ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066776, - "block_timestamp": 1781541696, - "tx_hash": "0x2ed8b17bb6e600ecfb3c8238a29461291992e921714c48a9660388b4e9f3d239", - "log_index": 231, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5aaa986eac50c844f866c6a8a6d3cfab5792b694", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x5aaa986eac50c844f866c6a8a6d3cfab5792b694", - "sellAmount": "3000000000000000", - "buyAmount": "893897411", - "validTo": 4294967295, - "appData": "0xa6ccf4bf36287699d17afd1871cb9d30074b6525f15b80c0cac2d4e8b3df1b49", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711b36a30323b", - "app_data_hash": "0xa6ccf4bf36287699d17afd1871cb9d30074b6525f15b80c0cac2d4e8b3df1b49", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":552,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005aaa986eac50c844f866c6a8a6d3cfab5792b694" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000005aaa986eac50c844f866c6a8a6d3cfab5792b694000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000003547cac300000000000000000000000000000000000000000000000000000000ffffffffa6ccf4bf36287699d17afd1871cb9d30074b6525f15b80c0cac2d4e8b3df1b490000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711b36a30323b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xf56cba95511a3d7cb0aa311c420cc403f51c6b18f76c25043163d5e048266788ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066777, - "block_timestamp": 1781541708, - "tx_hash": "0x14a46b4eb9bc6e94fbaa07a9a13b2d3a8440c9bc4c5e78948f5821e33d07189f", - "log_index": 47, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5aaa986eac50c844f866c6a8a6d3cfab5792b694", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x5aaa986eac50c844f866c6a8a6d3cfab5792b694", - "sellAmount": "3000000000000000", - "buyAmount": "57610257793", - "validTo": 4294967295, - "appData": "0x387164afa7d6ef1febb500d0c66cc903869fe223a99f8259866a9ba2a99857a1", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711b66a303246", - "app_data_hash": "0x387164afa7d6ef1febb500d0c66cc903869fe223a99f8259866a9ba2a99857a1", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":518,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005aaa986eac50c844f866c6a8a6d3cfab5792b694" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000005aaa986eac50c844f866c6a8a6d3cfab5792b694000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000d69d6c58100000000000000000000000000000000000000000000000000000000ffffffff387164afa7d6ef1febb500d0c66cc903869fe223a99f8259866a9ba2a99857a10000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711b66a3032460000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb47d7c7fa5b80751bd9d012b4414fd5e0ecc3b72615ff8a492c0a60e65f3943bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066798, - "block_timestamp": 1781541960, - "tx_hash": "0xaca14558c97e2966e5fb51d00a8a217bda4f0474b2c7d3693a6ba0a95aa1972e", - "log_index": 281, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5d36e5b6c1155d57053b14917c7e3dbb1522ecb7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x5d36e5b6c1155d57053b14917c7e3dbb1522ecb7", - "sellAmount": "3000000000000000", - "buyAmount": "875843830", - "validTo": 4294967295, - "appData": "0xd7fe9aef70f98f3d6663542be4ba448ac0b7d2f98b893d14e5716763cdb9d49d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711bd6a303344", - "app_data_hash": "0xd7fe9aef70f98f3d6663542be4ba448ac0b7d2f98b893d14e5716763cdb9d49d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":563,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005d36e5b6c1155d57053b14917c7e3dbb1522ecb7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000005d36e5b6c1155d57053b14917c7e3dbb1522ecb7000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000343450f600000000000000000000000000000000000000000000000000000000ffffffffd7fe9aef70f98f3d6663542be4ba448ac0b7d2f98b893d14e5716763cdb9d49d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711bd6a3033440000000000000000000000000000000000000000" - } - }, - { - "uid": "0x4069c497a70a51e913fa21c89e88bf79a521feb2d831e442fa9a0e9b87de6858ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066799, - "block_timestamp": 1781541972, - "tx_hash": "0xb24d400f4a8b7f6d3b6cd516404b5839c46bb897e41ac50bcba57a7a10819672", - "log_index": 173, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5d36e5b6c1155d57053b14917c7e3dbb1522ecb7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x5d36e5b6c1155d57053b14917c7e3dbb1522ecb7", - "sellAmount": "3000000000000000", - "buyAmount": "72317308401", - "validTo": 4294967295, - "appData": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711c06a303350", - "app_data_hash": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":526,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005d36e5b6c1155d57053b14917c7e3dbb1522ecb7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000005d36e5b6c1155d57053b14917c7e3dbb1522ecb7000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000010d6728df100000000000000000000000000000000000000000000000000000000fffffffff802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711c06a3033500000000000000000000000000000000000000000" - } - }, - { - "uid": "0xed31c79320e02a546523805a8586247db50dc8cb8cfececa8cb04428deffe30eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066818, - "block_timestamp": 1781542200, - "tx_hash": "0xb03bee8862a49aa6502bb889163ce11c6c08d40ff7305ce7bae3eb1ce55fde36", - "log_index": 181, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x000fb5b28fefa72f5252e7e82ffe46dc67594faf", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x000fb5b28fefa72f5252e7e82ffe46dc67594faf", - "sellAmount": "3000000000000000", - "buyAmount": "874340793", - "validTo": 4294967295, - "appData": "0x6971197d13e6bdc6ca676e2d7c86dc2d224e12a0faaf362dd296ccdbd9ad2321", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711c86a303430", - "app_data_hash": "0x6971197d13e6bdc6ca676e2d7c86dc2d224e12a0faaf362dd296ccdbd9ad2321", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":554,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000000fb5b28fefa72f5252e7e82ffe46dc67594faf" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000000fb5b28fefa72f5252e7e82ffe46dc67594faf000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000341d61b900000000000000000000000000000000000000000000000000000000ffffffff6971197d13e6bdc6ca676e2d7c86dc2d224e12a0faaf362dd296ccdbd9ad23210000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711c86a3034300000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5da63e4eb1ae72ba1b6d9ba7848a28052a9d381f9a066be8d7c9644be9a9e509ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066819, - "block_timestamp": 1781542212, - "tx_hash": "0x63adf8a8a7593c296380cf050e612fada0122b004cd5c7f70a3e844b86ab26c2", - "log_index": 204, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x000fb5b28fefa72f5252e7e82ffe46dc67594faf", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x000fb5b28fefa72f5252e7e82ffe46dc67594faf", - "sellAmount": "3000000000000000", - "buyAmount": "70059533490", - "validTo": 4294967295, - "appData": "0xf6c610744bb68398a39b86e84c66c3fe3614b3f49974bc955be42d2e6a01e298", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711ca6a30343b", - "app_data_hash": "0xf6c610744bb68398a39b86e84c66c3fe3614b3f49974bc955be42d2e6a01e298", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":539,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000000fb5b28fefa72f5252e7e82ffe46dc67594faf" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000000fb5b28fefa72f5252e7e82ffe46dc67594faf000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000104fdfa4b200000000000000000000000000000000000000000000000000000000fffffffff6c610744bb68398a39b86e84c66c3fe3614b3f49974bc955be42d2e6a01e2980000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711ca6a30343b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6c3227cbd2bd33b3e550ea4bd700c264356c382c9868a770898dd47d39bd96e8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066844, - "block_timestamp": 1781542512, - "tx_hash": "0xf5447d774e1d8b343c98a66904616bf270d528aed658bd22cacc33c1a59725f4", - "log_index": 57, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdc3b40688cdd7f098c7e0651b6008e0a2fd2f772", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xdc3b40688cdd7f098c7e0651b6008e0a2fd2f772", - "sellAmount": "3000000000000000", - "buyAmount": "861864447", - "validTo": 4294967295, - "appData": "0x8591b2ac2a2de1d38c5297f143cf311fd46ad9b3c9eb04cfcd40ba810e25c20c", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711d96a303564", - "app_data_hash": "0x8591b2ac2a2de1d38c5297f143cf311fd46ad9b3c9eb04cfcd40ba810e25c20c", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":529,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dc3b40688cdd7f098c7e0651b6008e0a2fd2f772" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000dc3b40688cdd7f098c7e0651b6008e0a2fd2f772000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000335f01ff00000000000000000000000000000000000000000000000000000000ffffffff8591b2ac2a2de1d38c5297f143cf311fd46ad9b3c9eb04cfcd40ba810e25c20c0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711d96a3035640000000000000000000000000000000000000000" - } - }, - { - "uid": "0x97fa5d54846ee99feee01f8323378584110e5584d5e98401456092e770b021e1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066844, - "block_timestamp": 1781542512, - "tx_hash": "0x8135d17f6866504867a51b8c49c2f551de61f8b5e20d7da5a899aaabb7de9df4", - "log_index": 84, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdc3b40688cdd7f098c7e0651b6008e0a2fd2f772", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xdc3b40688cdd7f098c7e0651b6008e0a2fd2f772", - "sellAmount": "3000000000000000", - "buyAmount": "68704353178", - "validTo": 4294967295, - "appData": "0x1874540551cf00f1a41fdd9b040fad35aef82b22a33c971b368af4dca9c11c81", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711da6a30356d", - "app_data_hash": "0x1874540551cf00f1a41fdd9b040fad35aef82b22a33c971b368af4dca9c11c81", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":511,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dc3b40688cdd7f098c7e0651b6008e0a2fd2f772" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000dc3b40688cdd7f098c7e0651b6008e0a2fd2f772000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000fff193b9a00000000000000000000000000000000000000000000000000000000ffffffff1874540551cf00f1a41fdd9b040fad35aef82b22a33c971b368af4dca9c11c810000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711da6a30356d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe5197c475a0b9889a27248a2b74fe0cbadfddae0d458e757a30e5f605a646d9bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066863, - "block_timestamp": 1781542740, - "tx_hash": "0x6bbddfbb73ea21b8fe6d625600275fe79cfa4e2e255d45e4f3b9644cc027b38b", - "log_index": 104, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7f52ef75763939cc397ed91612ee654cb69840d9", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x7f52ef75763939cc397ed91612ee654cb69840d9", - "sellAmount": "3000000000000000", - "buyAmount": "839961443", - "validTo": 4294967295, - "appData": "0x12042ecdcc200a4f5c5c27fe083d247b7e8fa2d7466fb7efebcb8ca38c4d7c0d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711e26a30364b", - "app_data_hash": "0x12042ecdcc200a4f5c5c27fe083d247b7e8fa2d7466fb7efebcb8ca38c4d7c0d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":579,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007f52ef75763939cc397ed91612ee654cb69840d9" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000007f52ef75763939cc397ed91612ee654cb69840d9000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000003210cb6300000000000000000000000000000000000000000000000000000000ffffffff12042ecdcc200a4f5c5c27fe083d247b7e8fa2d7466fb7efebcb8ca38c4d7c0d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711e26a30364b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xf13dc9a187aab785bded8dbecf92fba5e598d2285ed27535f73ebd8d28deb122ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066863, - "block_timestamp": 1781542740, - "tx_hash": "0x976895ebb161a590de944a65850d4f63e2716099667c06e1359ddb087499d620", - "log_index": 108, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7f52ef75763939cc397ed91612ee654cb69840d9", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x7f52ef75763939cc397ed91612ee654cb69840d9", - "sellAmount": "3000000000000000", - "buyAmount": "65718536214", - "validTo": 4294967295, - "appData": "0xebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b00", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711e36a303652", - "app_data_hash": "0xebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b00", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":571,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007f52ef75763939cc397ed91612ee654cb69840d9" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000007f52ef75763939cc397ed91612ee654cb69840d9000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000f4d21481600000000000000000000000000000000000000000000000000000000ffffffffebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b000000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711e36a3036520000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6118ba38dfcce88864c9b5c91107ecd13fb61aedfb01fd04efe7e12e0be5b19aba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11067068, - "block_timestamp": 1781545200, - "tx_hash": "0x6de4b3ad33ddc765168c0108e457351c748f9c9cee4772056e3785fadf644752", - "log_index": 344, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "104011000000000000", - "buyAmount": "5880686427776813191", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017124d6a303fe7", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b0000000000000000000000000000000000000000000000000171857413bbb000000000000000000000000000000000000000000000000000519c65261b19088700000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017124d6a303fe70000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb2efc9f12e071f643bed89a7d278ce74f13d981edac7143f40bbe30ce9c6c59fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11067190, - "block_timestamp": 1781546664, - "tx_hash": "0x1232bdd6cbf0afb5c0a3ca6f357886a8329483936ca2a74ae062776aa1dd4fca", - "log_index": 422, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "6000000000000000", - "buyAmount": "130555809198", - "validTo": 4294967295, - "appData": "0x16c818092983304daad176cee4a83c07de4adf516f34e83bea3d1603b05233fa", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001712826a3045a2", - "app_data_hash": "0x16c818092983304daad176cee4a83c07de4adf516f34e83bea3d1603b05233fa", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":286,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000001550f7dca700000000000000000000000000000000000000000000000000000000001e65bb8dae00000000000000000000000000000000000000000000000000000000ffffffff16c818092983304daad176cee4a83c07de4adf516f34e83bea3d1603b05233fa0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001712826a3045a20000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7f76bfa162f20f42438fd164be5126cf5231e76f744694eda4c21845e8e9b873ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11067200, - "block_timestamp": 1781546784, - "tx_hash": "0x90566fd038873990ab5db1d634b6f73810c0211426ec3e9918ff8875a2d78f7f", - "log_index": 236, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "5649000718389264639", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017128d6a304611", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000004e6548394384c0ff00000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017128d6a3046110000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2c6cca8204aa358431960a0eefcf58959a1c05a4e3db4ef9bcf9c970f5caeddcba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11067729, - "block_timestamp": 1781553132, - "tx_hash": "0x4a81ee44791c6a1de2631323035854a596f3dd5569d4de7d5a9051b3731f6df1", - "log_index": 22, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "sellAmount": "100000000000000000", - "buyAmount": "578956100796", - "validTo": 4294967295, - "appData": "0x99ecc17f6c6c9c69e2436d17b5928a17465525de93755e72afbed1b8d52f9233", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001713446a305ec6", - "app_data_hash": "0x99ecc17f6c6c9c69e2436d17b5928a17465525de93755e72afbed1b8d52f9233", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000086cc7904bc00000000000000000000000000000000000000000000000000000000ffffffff99ecc17f6c6c9c69e2436d17b5928a17465525de93755e72afbed1b8d52f92330000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001713446a305ec60000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe32c85412667253adcf7cb0ea81f26cc98a620dc34a2e18c787a647ebfa3cb88ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11068198, - "block_timestamp": 1781558760, - "tx_hash": "0x7f148d5b878488be5e4477b9a1517f7e3639e352f25d90f457a3395befa22b4c", - "log_index": 146, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "sellAmount": "10000000000000000", - "buyAmount": "3048799539", - "validTo": 4294967295, - "appData": "0x71364a17193fae1c28deab9c7c71b12c0bdb9863699b9ca62cb36ff8f63a14fc", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017140e6a3074d3", - "app_data_hash": "0x71364a17193fae1c28deab9c7c71b12c0bdb9863699b9ca62cb36ff8f63a14fc", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":171,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000b5b8fd3300000000000000000000000000000000000000000000000000000000ffffffff71364a17193fae1c28deab9c7c71b12c0bdb9863699b9ca62cb36ff8f63a14fc0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017140e6a3074d30000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5af30b3e1fda697a33e6a3b4a25baddc48bd0107799510bd1fbd4420db4103f1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11068620, - "block_timestamp": 1781563836, - "tx_hash": "0x9772ca0fd1f1194222bf606861394b9cc70c3266ff4f2f76f18fcdce43c2c141", - "log_index": 23, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "sellAmount": "100000000000000000", - "buyAmount": "190771165678", - "validTo": 4294967295, - "appData": "0x64786fd7cb86927db36a84de66ec04845f109e9a59df63c9b99e8e6d5d96b665", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001714ce6a3088a3", - "app_data_hash": "0x64786fd7cb86927db36a84de66ec04845f109e9a59df63c9b99e8e6d5d96b665", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000002c6ad8f9ee00000000000000000000000000000000000000000000000000000000ffffffff64786fd7cb86927db36a84de66ec04845f109e9a59df63c9b99e8e6d5d96b6650000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001714ce6a3088a30000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfa067d014ba286bd6ed78e671122083db93a2676d94536181143ff9c9cb3f8baba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11069102, - "block_timestamp": 1781569632, - "tx_hash": "0xc6fd5e60d24961151bd5619c99856f7b57842882cad5c18ecbdc0fd477771de7", - "log_index": 985, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8fbaa25c419790eb5dc0aad10429bf3f91c4d891", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x8fbaa25c419790eb5dc0aad10429bf3f91c4d891", - "sellAmount": "1000000000000000000", - "buyAmount": "69250732514", - "validTo": 4294967295, - "appData": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017152d6a309f44", - "app_data_hash": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008fbaa25c419790eb5dc0aad10429bf3f91c4d891" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000008fbaa25c419790eb5dc0aad10429bf3f91c4d8910000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000101faa51e200000000000000000000000000000000000000000000000000000000ffffffff24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017152d6a309f440000000000000000000000000000000000000000" - } - }, - { - "uid": "0xf242a25b6da691a5e8a0b05fc51acbf1796e5cea40799aaaefbb80e37530c04fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11069119, - "block_timestamp": 1781569836, - "tx_hash": "0x750d70ee942aa52a1ca2f0a9a546992c0c1c884e6f936fce6b5d38d4006b7a0e", - "log_index": 368, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8fbaa25c419790eb5dc0aad10429bf3f91c4d891", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x8fbaa25c419790eb5dc0aad10429bf3f91c4d891", - "sellAmount": "1000000000000000000", - "buyAmount": "541394958706", - "validTo": 4294967295, - "appData": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001715306a30a019", - "app_data_hash": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008fbaa25c419790eb5dc0aad10429bf3f91c4d891" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000008fbaa25c419790eb5dc0aad10429bf3f91c4d8910000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000007e0da7797200000000000000000000000000000000000000000000000000000000ffffffff24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001715306a30a0190000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8bd36dc7509f06176e1f014c9a98a5b3a7b3f0358a2e12b96576f8bfd9a013f9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11069495, - "block_timestamp": 1781574348, - "tx_hash": "0xf2c736b9009ba16a237118c5f2d575ebec080ed0fd9421a616a73775772da406", - "log_index": 166, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "19870009077", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017155c6a30b1c6", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000004a05846f500000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017155c6a30b1c60000000000000000000000000000000000000000" - } - }, - { - "uid": "0x591da4be8d1d0eaafe73f6bed1ae5d3df69e1b8ae125cebde6299502650f47cfba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11069501, - "block_timestamp": 1781574420, - "tx_hash": "0xbf7a6f32d894960eee415dd823c00dcd49f73ae2df838858d6a933bb5fc72cbe", - "log_index": 131, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "89218327641", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017155e6a30b20b", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000014c5d3a45900000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017155e6a30b20b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa9a747d544a3cd20a53140da95a008b2fb46fea417499fc6b33850e0258c5f88ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11069948, - "block_timestamp": 1781579796, - "tx_hash": "0xc42df3d9eb8d640848f103615521b0ea6709610d0044148062114639c3d3967a", - "log_index": 114, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x4e1e429b50de3bf686858acb771590a5b99cb019", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x4e1e429b50de3bf686858acb771590a5b99cb019", - "sellAmount": "50000000000000000", - "buyAmount": "83120067359", - "validTo": 4294967295, - "appData": "0x4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001715af6a30c70b", - "app_data_hash": "0x4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":77,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000004e1e429b50de3bf686858acb771590a5b99cb019" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000004e1e429b50de3bf686858acb771590a5b99cb01900000000000000000000000000000000000000000000000000b1a2bc2ec50000000000000000000000000000000000000000000000000000000000135a57931f00000000000000000000000000000000000000000000000000000000ffffffff4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001715af6a30c70b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8b778cc7d7e269088a375b3ae33b82b171dd54eb43e0eaef75f05a2e1a3ec5e9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11070077, - "block_timestamp": 1781581344, - "tx_hash": "0x708362d1cf729b96124c8c027fb98c750726089389c891973f6b14328c7ebccc", - "log_index": 154, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xf7e7c1aa347f441c716948f186b0ef6c6234d8c4", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xf7e7c1aa347f441c716948f186b0ef6c6234d8c4", - "sellAmount": "500000000000000000", - "buyAmount": "132444734549", - "validTo": 4294967295, - "appData": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001715b36a30cd0d", - "app_data_hash": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":52,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000f7e7c1aa347f441c716948f186b0ef6c6234d8c4" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000f7e7c1aa347f441c716948f186b0ef6c6234d8c400000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000000000001ed652445500000000000000000000000000000000000000000000000000000000ffffffffacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce5190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001715b36a30cd0d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x0a56f14f14f30cb5026b5c379b003031a1d72bc543c2e798043255a094087399ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11070107, - "block_timestamp": 1781581704, - "tx_hash": "0xb090e903904bbfd0c2f98815ebf9147d02c1b77e6d9b6744d6a3af72d55a4294", - "log_index": 101, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xf7e7c1aa347f441c716948f186b0ef6c6234d8c4", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xf7e7c1aa347f441c716948f186b0ef6c6234d8c4", - "sellAmount": "500000000000000000", - "buyAmount": "319493841113", - "validTo": 4294967295, - "appData": "0x46bf24a40af1ee801d88ca976876d9ddf899c6e101a3ba7dc6acbdd6ab50e2a7", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001715b56a30ce7f", - "app_data_hash": "0x46bf24a40af1ee801d88ca976876d9ddf899c6e101a3ba7dc6acbdd6ab50e2a7", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":53,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000f7e7c1aa347f441c716948f186b0ef6c6234d8c4" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000f7e7c1aa347f441c716948f186b0ef6c6234d8c400000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000000000004a635120d900000000000000000000000000000000000000000000000000000000ffffffff46bf24a40af1ee801d88ca976876d9ddf899c6e101a3ba7dc6acbdd6ab50e2a70000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001715b56a30ce7f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x4344512447bfa709d91a7e0eaf234fb48b19c6b8d83a2c7d96f08def593697a9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11070306, - "block_timestamp": 1781584104, - "tx_hash": "0x9fa96f5bec18125c9561588321bcfd6044ca16f4eb49986a284b0fcf2e764e87", - "log_index": 548, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xa634030a2603a0d70b7cddf6cc9ad90d93f6db50", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xa634030a2603a0d70b7cddf6cc9ad90d93f6db50", - "sellAmount": "1000000000000000000", - "buyAmount": "446250020796", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001715bd6a30d36c", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000a634030a2603a0d70b7cddf6cc9ad90d93f6db50" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000a634030a2603a0d70b7cddf6cc9ad90d93f6db500000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000067e692efbc00000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001715bd6a30d36c0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x01026a746ad179cd13bbf8cc1952c15246e90d0a23c52acf97264081f2bd971dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11070324, - "block_timestamp": 1781584320, - "tx_hash": "0x09cb5a006b001cc1b29d45425c999739abccb2cf4ae06a999b6b22bac5112e2e", - "log_index": 600, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xa634030a2603a0d70b7cddf6cc9ad90d93f6db50", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xa634030a2603a0d70b7cddf6cc9ad90d93f6db50", - "sellAmount": "1000000000000000000", - "buyAmount": "79256498744", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001715cd6a30d8be", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000a634030a2603a0d70b7cddf6cc9ad90d93f6db50" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000a634030a2603a0d70b7cddf6cc9ad90d93f6db500000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000012740e323800000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001715cd6a30d8be0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x71b74cf65745a5b32d108cdfc9afef39cbf23031120895665cbc06b5e324ec59ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11070394, - "block_timestamp": 1781585160, - "tx_hash": "0x3921052be15a828321da0c7ba50342045430184d76f65da30ae27e88c34db72d", - "log_index": 89, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xf7e7c1aa347f441c716948f186b0ef6c6234d8c4", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xf7e7c1aa347f441c716948f186b0ef6c6234d8c4", - "sellAmount": "100000000000000000", - "buyAmount": "26138260745", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001715de6a30dbfc", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000f7e7c1aa347f441c716948f186b0ef6c6234d8c4" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000f7e7c1aa347f441c716948f186b0ef6c6234d8c4000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000615f6350900000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001715de6a30dbfc0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8834595af5f88e9129f12f62859b821c9cc81137595f9ff4940be6cfe757e55dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11070716, - "block_timestamp": 1781589024, - "tx_hash": "0x11edfdcb9c2b414083cc2abaa14fed6b67a8bd33f7ad95d01001858127e779c3", - "log_index": 285, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8f708151adaa0786803dd647934f1d0481df2b01", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x8f708151adaa0786803dd647934f1d0481df2b01", - "sellAmount": "2000000000000000", - "buyAmount": "603498383", - "validTo": 4294967295, - "appData": "0x6f9554a900ec9ffaf1ae8d45a17b5b78e80dd977782af02739da113104596e83", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001716526a30eb15", - "app_data_hash": "0x6f9554a900ec9ffaf1ae8d45a17b5b78e80dd977782af02739da113104596e83", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":795,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008f708151adaa0786803dd647934f1d0481df2b01" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000008f708151adaa0786803dd647934f1d0481df2b0100000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000023f8a78f00000000000000000000000000000000000000000000000000000000ffffffff6f9554a900ec9ffaf1ae8d45a17b5b78e80dd977782af02739da113104596e830000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001716526a30eb150000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6f4fae101582d61c82bd155446182394295c797a51119f38f8d90b4ee5e24e6dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071674, - "block_timestamp": 1781600520, - "tx_hash": "0x4c39afc25d8527f6fc73af58485dd7148abb883b92be02415fc10a318179297a", - "log_index": 13, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "27010997047", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717936a3117f9", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000649fb1b3700000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717936a3117f90000000000000000000000000000000000000000" - } - }, - { - "uid": "0x50211d94802faefd058477b136f5cfb8948e1963e878277038d4477b0455a70fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071675, - "block_timestamp": 1781600532, - "tx_hash": "0x0641e0eebe66e93ad9572d1192e30d9d8a13da8f66ed18f4365a1da71ffd8eb4", - "log_index": 6, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "27001132773", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717956a31180d", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000006496496e500000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717956a31180d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xcd925b4dc34f565a21d12238b5cbb5e2f82cfa5be72db5573f042c7909e0d7d9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071676, - "block_timestamp": 1781600544, - "tx_hash": "0x1ae827c196ce7d32d2dfcbba231d239d5c3bf6e829200ad28d3cdab921633567", - "log_index": 6, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "27011897771", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717976a31181a", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000064a08d9ab00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717976a31181a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x297cb16dfdf1b35bf093df7d9bada0df328d7de685a6845fb2025a56a82a37c4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071677, - "block_timestamp": 1781600556, - "tx_hash": "0x5ec223cdf81a56b1a54ac6dafeaf62f51aa00fd9359dc1791e543b1745849024", - "log_index": 16, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "15471675566", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017179a6a31182a", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000039a2f08ae00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017179a6a31182a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x57e246419eaa6bd5ef694ab05e4ecdb2b0ce8d416790413a2921ec1af8a275c0ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071679, - "block_timestamp": 1781600580, - "tx_hash": "0x5285537ed66fc9e8f5b334b570ea778c15ae654c0b1d49b57d8c05a44efe37a6", - "log_index": 41, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "15468548658", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017179c6a311835", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000399ff523200000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017179c6a3118350000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb30c35c2a132725a956fe6e642e3a66cfd74e5e79d7eababd699144677eb3b9eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071681, - "block_timestamp": 1781600604, - "tx_hash": "0x4dab8bf7b0f6e6777dbe78933594f2faa5a8e41824de3ea747f9d18e8bdf1156", - "log_index": 41, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "15472079403", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717a16a31184b", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000039a35322b00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717a16a31184b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x743f96095cc6bd65ad14eec58e23b8f9dd386c5f2931710ba4025c41f9049209ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071682, - "block_timestamp": 1781600616, - "tx_hash": "0x0fe33600fd357441d049a09965014ecc0929da005353e6cd1a2576f8510e11cc", - "log_index": 26, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "13735449713", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717a66a311862", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000332b2547100000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717a66a3118620000000000000000000000000000000000000000" - } - }, - { - "uid": "0x713bc2862800b9e39458735758a79a4ab41a86d73948e89df308e6d76a94dc72ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071683, - "block_timestamp": 1781600628, - "tx_hash": "0x4d80ae379f4acc953561fe59567a04ac6c14340a905ef227aadc8c60d2ca84c6", - "log_index": 22, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "13730353734", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717aa6a311870", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000033264924600000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717aa6a3118700000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7925f2365b42ff994ae507d0fa423a5d0ea1670879b82a05bb5b473b67b48764ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071684, - "block_timestamp": 1781600640, - "tx_hash": "0xe134ba4a1256429ac69942ddbfa4fc6f0d7809bd45317dbef495c4d4f07cf537", - "log_index": 5, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "13727635152", - "validTo": 4294967295, - "appData": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717ad6a31187a", - "app_data_hash": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":64,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000003323b16d000000000000000000000000000000000000000000000000000000000ffffffff4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717ad6a31187a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x11d613bf846ad2854276646c262d1fdffa4da25073fe8a4e527ce25cd1d5bbd6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071687, - "block_timestamp": 1781600676, - "tx_hash": "0x4bde12bf86a0b46d72673f6cac98a22a54da8ed76c2a2c28032e77eb2818179f", - "log_index": 7, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "12876644477", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717b86a31189a", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000002ff82007d00000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717b86a31189a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd42de36dcd8b95635744e8779bbc25e160cee28a1cdd13388d05f8a4273bffe8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072025, - "block_timestamp": 1781604732, - "tx_hash": "0xd1ca15f5a2148b77247ea19788f686d06b28361e8d7ae15c78b2f04f31d24ddc", - "log_index": 459, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xc6c1be3a571a9ebaaef228d3a6c950e3b8ffc17b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xc6c1be3a571a9ebaaef228d3a6c950e3b8ffc17b", - "sellAmount": "1000000000000000", - "buyAmount": "52714844299323364", - "validTo": 4294967295, - "appData": "0x738dff43ec778d7c45a9d00783e24b8c49757ba2e308e0ea01d1c6ebe97ff719", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017187f6a31286a", - "app_data_hash": "0x738dff43ec778d7c45a9d00783e24b8c49757ba2e308e0ea01d1c6ebe97ff719", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1919,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000c6c1be3a571a9ebaaef228d3a6c950e3b8ffc17b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000c6c1be3a571a9ebaaef228d3a6c950e3b8ffc17b00000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000bb47df20d9e7e400000000000000000000000000000000000000000000000000000000ffffffff738dff43ec778d7c45a9d00783e24b8c49757ba2e308e0ea01d1c6ebe97ff7190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017187f6a31286a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe81f36153af14af0302c2db20738ca3b00480d8a03401f2e14ba7d7b1a0b9bf4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072165, - "block_timestamp": 1781606412, - "tx_hash": "0x7c280e716fb0f6ef5a727e38a52a4162117f3a808fc987a423688e5fe893d584", - "log_index": 78, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "50000000000000000", - "buyAmount": "321861324592", - "validTo": 4294967295, - "appData": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001718dc6a312f03", - "app_data_hash": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":76,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da553297000000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000004af06e0f3000000000000000000000000000000000000000000000000000000000ffffffff4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001718dc6a312f030000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfe8b70cc3481aecea2727b2212175cd9d79c2f56bce67474e7037e4ce3292a5cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072663, - "block_timestamp": 1781612388, - "tx_hash": "0x33ce7545031a104e4dd4e5740156b607b94fdeb761f15373f5a92bcf22b5362d", - "log_index": 212, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x898116872cc7a0c093bfaec82d9ddd3f0de65a0a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x898116872cc7a0c093bfaec82d9ddd3f0de65a0a", - "sellAmount": "100000000000000000", - "buyAmount": "92561484587", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001719d76a314658", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000898116872cc7a0c093bfaec82d9ddd3f0de65a0a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000898116872cc7a0c093bfaec82d9ddd3f0de65a0a000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000158d182b2b00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001719d76a3146580000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfb9ebfe279eb19122c6e7b9125e00c2c7f7cb80a5808ea873452c3b1235c3701ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072665, - "block_timestamp": 1781612412, - "tx_hash": "0x4877f1293f630dac7f92738eaa406fe120213f57f582f87ecf46a69089566c07", - "log_index": 334, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x898116872cc7a0c093bfaec82d9ddd3f0de65a0a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x898116872cc7a0c093bfaec82d9ddd3f0de65a0a", - "sellAmount": "100000000000000000", - "buyAmount": "92566945390", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001719da6a314673", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000898116872cc7a0c093bfaec82d9ddd3f0de65a0a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000898116872cc7a0c093bfaec82d9ddd3f0de65a0a000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000158d6b7e6e00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001719da6a3146730000000000000000000000000000000000000000" - } - }, - { - "uid": "0x76c0a5ea09f8289b5bd03f3a2bb4220ab2726b39071f7717ddee3e30f5799d04ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072730, - "block_timestamp": 1781613192, - "tx_hash": "0x412838658e207389b7e10a23d74c59c53704d6d45123de938fcb725ef0801b07", - "log_index": 505, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x51229afb8db031d9bc864e191f84c543cd4f9527", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x51229afb8db031d9bc864e191f84c543cd4f9527", - "sellAmount": "10000000000000000", - "buyAmount": "26333692857", - "validTo": 4294967295, - "appData": "0x9967387c9a79ca88e1dc6ff8d198c78348ff54d54a408f8afe44ebfca97aff98", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a016a314972", - "app_data_hash": "0x9967387c9a79ca88e1dc6ff8d198c78348ff54d54a408f8afe44ebfca97aff98", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":190,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000051229afb8db031d9bc864e191f84c543cd4f9527" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000051229afb8db031d9bc864e191f84c543cd4f9527000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000006219c43b900000000000000000000000000000000000000000000000000000000ffffffff9967387c9a79ca88e1dc6ff8d198c78348ff54d54a408f8afe44ebfca97aff980000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a016a3149720000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb2e7c4b5f72f18f275774d47d19b096b409dc5a886c12a9f9a91432593ea8fccba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072738, - "block_timestamp": 1781613288, - "tx_hash": "0x6492482df5d321f2caf21d051e9426260f618c4e306db622ddcc8f10c2b349f3", - "log_index": 194, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x51229afb8db031d9bc864e191f84c543cd4f9527", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x51229afb8db031d9bc864e191f84c543cd4f9527", - "sellAmount": "10000000000000000", - "buyAmount": "21789124716", - "validTo": 4294967295, - "appData": "0x1b34a38083107c63bff5fc8fb6e02d487b9ef1e82d3f562866b6969f1bf22434", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a066a3149d9", - "app_data_hash": "0x1b34a38083107c63bff5fc8fb6e02d487b9ef1e82d3f562866b6969f1bf22434", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":179,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000051229afb8db031d9bc864e191f84c543cd4f9527" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000051229afb8db031d9bc864e191f84c543cd4f9527000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000000000512bba86c00000000000000000000000000000000000000000000000000000000ffffffff1b34a38083107c63bff5fc8fb6e02d487b9ef1e82d3f562866b6969f1bf224340000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a066a3149d90000000000000000000000000000000000000000" - } - }, - { - "uid": "0x20484bb9f64267699903e513fe6c49b3dc7a1726533ff26dd6f5fe60d6798f94ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072742, - "block_timestamp": 1781613336, - "tx_hash": "0xbbc3e3cb7b2b34a8609e744b8e5df354b564c542e31044b09f40b65ccab1797d", - "log_index": 150, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x51229afb8db031d9bc864e191f84c543cd4f9527", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x51229afb8db031d9bc864e191f84c543cd4f9527", - "sellAmount": "10000000000000000", - "buyAmount": "18236648912", - "validTo": 4294967295, - "appData": "0x62107cf706bdba60dcd54b74b55984b42df43217a7b67e6dc2766c905ac4549f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a096a314a0a", - "app_data_hash": "0x62107cf706bdba60dcd54b74b55984b42df43217a7b67e6dc2766c905ac4549f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":187,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000051229afb8db031d9bc864e191f84c543cd4f9527" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000051229afb8db031d9bc864e191f84c543cd4f9527000000000000000000000000000000000000000000000000002386f26fc10000000000000000000000000000000000000000000000000000000000043efd2dd000000000000000000000000000000000000000000000000000000000ffffffff62107cf706bdba60dcd54b74b55984b42df43217a7b67e6dc2766c905ac4549f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a096a314a0a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x541fb237960f6d8153b157c77bde23e934b4d6f2ce0dca4bc9d8566571ad701bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072774, - "block_timestamp": 1781613720, - "tx_hash": "0x35ca0c32803283544d7742e5a6c2d9fa8e87b106c4228883414520c9a53a930e", - "log_index": 225, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x384f7724d79f5a7b583a220de92a30904194b212", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x384f7724d79f5a7b583a220de92a30904194b212", - "sellAmount": "3000000000000000", - "buyAmount": "4441821088", - "validTo": 4294967295, - "appData": "0x33704e0fd722698ca109301285107e0c368723a5eef29ecc64275822c1ab1575", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a1d6a314b85", - "app_data_hash": "0x33704e0fd722698ca109301285107e0c368723a5eef29ecc64275822c1ab1575", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":531,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000108c0cfa000000000000000000000000000000000000000000000000000000000ffffffff33704e0fd722698ca109301285107e0c368723a5eef29ecc64275822c1ab15750000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a1d6a314b850000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2404f184f0512bb7e2a8b6b0b82bde41e259ba6529bbc69e89c581eed6186dafba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072774, - "block_timestamp": 1781613720, - "tx_hash": "0x2adc37446bd8ea5458d9e5cb44e557bb4b5e765bcba7872b09245c22efcb2394", - "log_index": 252, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x384f7724d79f5a7b583a220de92a30904194b212", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x384f7724d79f5a7b583a220de92a30904194b212", - "sellAmount": "3000000000000000", - "buyAmount": "2387543146", - "validTo": 4294967295, - "appData": "0xa1301781465d1f008067fc72dfcd0b3114fe8b2642a0c92f530eea6e414738a9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a1e6a314b92", - "app_data_hash": "0xa1301781465d1f008067fc72dfcd0b3114fe8b2642a0c92f530eea6e414738a9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":513,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000008e4f046a00000000000000000000000000000000000000000000000000000000ffffffffa1301781465d1f008067fc72dfcd0b3114fe8b2642a0c92f530eea6e414738a90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a1e6a314b920000000000000000000000000000000000000000" - } - }, - { - "uid": "0x30e44c5398175513ef4700b3a38e2cea113a9921fa3ce323e2736531e9bedc01ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072791, - "block_timestamp": 1781613924, - "tx_hash": "0x01dfa4ffcae8a50424897ebb859e872895f81432b4c88785063f8c1182891302", - "log_index": 240, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x384f7724d79f5a7b583a220de92a30904194b212", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x384f7724d79f5a7b583a220de92a30904194b212", - "sellAmount": "3000000000000000", - "buyAmount": "3857852888", - "validTo": 4294967295, - "appData": "0x499a1601c6014dd5f047d6a71f5a4be621a5e26b098dcd7a375a7aa3f8bc0f20", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a276a314c61", - "app_data_hash": "0x499a1601c6014dd5f047d6a71f5a4be621a5e26b098dcd7a375a7aa3f8bc0f20", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":572,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000e5f229d800000000000000000000000000000000000000000000000000000000ffffffff499a1601c6014dd5f047d6a71f5a4be621a5e26b098dcd7a375a7aa3f8bc0f200000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a276a314c610000000000000000000000000000000000000000" - } - }, - { - "uid": "0x9a340499f139e282ecf0815e3429781261a4ecc7d016444749f2c3dae58b16dbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072801, - "block_timestamp": 1781614044, - "tx_hash": "0x453ef40ffcd481e37423ec06980dcebeea5a369bc2b83559d2909cf415c8f25c", - "log_index": 169, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x384f7724d79f5a7b583a220de92a30904194b212", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x384f7724d79f5a7b583a220de92a30904194b212", - "sellAmount": "3000000000000000", - "buyAmount": "3628945089", - "validTo": 4294967295, - "appData": "0x15e6ad044637c621a9610df83bef56845d38da755a4c4a1fe6a56c4e74b37adf", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a2f6a314cd1", - "app_data_hash": "0x15e6ad044637c621a9610df83bef56845d38da755a4c4a1fe6a56c4e74b37adf", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":516,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000d84d4ec100000000000000000000000000000000000000000000000000000000ffffffff15e6ad044637c621a9610df83bef56845d38da755a4c4a1fe6a56c4e74b37adf0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a2f6a314cd10000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7f7b151d3a1c04eab0c0aadbd4ff781e50f523310cbb23a9bcf66e6cca1fd560ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072849, - "block_timestamp": 1781614620, - "tx_hash": "0xfb4608e9f984b2917bea5cb950e9696ded0d43f8b3d125137151770671f492a6", - "log_index": 199, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xda7ffa0d9e85c521fd320fced929bc903efb4fe7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xda7ffa0d9e85c521fd320fced929bc903efb4fe7", - "sellAmount": "3000000000000000", - "buyAmount": "3332943805", - "validTo": 4294967295, - "appData": "0x02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f80", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a4c6a314f18", - "app_data_hash": "0x02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f80", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":553,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000da7ffa0d9e85c521fd320fced929bc903efb4fe7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000da7ffa0d9e85c521fd320fced929bc903efb4fe7000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000c6a8afbd00000000000000000000000000000000000000000000000000000000ffffffff02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f800000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a4c6a314f180000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb68eeaf4ca2524fde27b8e79827619475a60bad6b3c881f09ca9ba893be6835fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072850, - "block_timestamp": 1781614632, - "tx_hash": "0xee50f593bc102291a85509339e00fb27ef085636da8430a904b194c21a2860a5", - "log_index": 106, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xda7ffa0d9e85c521fd320fced929bc903efb4fe7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xda7ffa0d9e85c521fd320fced929bc903efb4fe7", - "sellAmount": "3000000000000000", - "buyAmount": "1991265682", - "validTo": 4294967295, - "appData": "0x6971197d13e6bdc6ca676e2d7c86dc2d224e12a0faaf362dd296ccdbd9ad2321", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a4b6a314f1e", - "app_data_hash": "0x6971197d13e6bdc6ca676e2d7c86dc2d224e12a0faaf362dd296ccdbd9ad2321", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":554,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000da7ffa0d9e85c521fd320fced929bc903efb4fe7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000da7ffa0d9e85c521fd320fced929bc903efb4fe7000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000076b04d9200000000000000000000000000000000000000000000000000000000ffffffff6971197d13e6bdc6ca676e2d7c86dc2d224e12a0faaf362dd296ccdbd9ad23210000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a4b6a314f1e0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5395405d6c65df4b6a42b9f8c60139a52cda62d3ecdebbc4e7a8387ab02837d0ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072881, - "block_timestamp": 1781615004, - "tx_hash": "0xe66e7407a3105e94fe878f1f1ede17fd0c506b0a6cd162d88982502decdd3b6f", - "log_index": 162, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xc83673385fc52f3bcbac6fed3225e216788f4919", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xc83673385fc52f3bcbac6fed3225e216788f4919", - "sellAmount": "3000000000000000", - "buyAmount": "3140775257", - "validTo": 4294967295, - "appData": "0x0d0878cfe0cb1d84a860e63bc771ff4fd72ffa61b1f4f11aee18dccff7c9238c", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a646a315098", - "app_data_hash": "0x0d0878cfe0cb1d84a860e63bc771ff4fd72ffa61b1f4f11aee18dccff7c9238c", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":510,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000c83673385fc52f3bcbac6fed3225e216788f4919" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000c83673385fc52f3bcbac6fed3225e216788f4919000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000bb346d5900000000000000000000000000000000000000000000000000000000ffffffff0d0878cfe0cb1d84a860e63bc771ff4fd72ffa61b1f4f11aee18dccff7c9238c0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a646a3150980000000000000000000000000000000000000000" - } - }, - { - "uid": "0x45d5563b3d4c9e5ecc9ecfce7d67ce7d36c7d0a40fa5a9f737f54d388e54953dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072881, - "block_timestamp": 1781615004, - "tx_hash": "0xba8287dac4856aad97cb5fd38c3c2c6c17376cc05d33d75db0e1210b936564fb", - "log_index": 166, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xc83673385fc52f3bcbac6fed3225e216788f4919", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xc83673385fc52f3bcbac6fed3225e216788f4919", - "sellAmount": "3000000000000000", - "buyAmount": "2006882155", - "validTo": 4294967295, - "appData": "0x1874540551cf00f1a41fdd9b040fad35aef82b22a33c971b368af4dca9c11c81", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a676a31509d", - "app_data_hash": "0x1874540551cf00f1a41fdd9b040fad35aef82b22a33c971b368af4dca9c11c81", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":511,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000c83673385fc52f3bcbac6fed3225e216788f4919" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000c83673385fc52f3bcbac6fed3225e216788f4919000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000779e976b00000000000000000000000000000000000000000000000000000000ffffffff1874540551cf00f1a41fdd9b040fad35aef82b22a33c971b368af4dca9c11c810000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a676a31509d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x15431ff47d42cca6ee7501a570f0d73612d0ac5070b62c00b7440b9512c4e71cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072907, - "block_timestamp": 1781615316, - "tx_hash": "0x51890dec865bf35c02f7bda9ece8b8193680bef10b5b93cdc633c4de4c2e41bc", - "log_index": 150, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xe9efa5cea161220a0ed136560df741783d821ace", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xe9efa5cea161220a0ed136560df741783d821ace", - "sellAmount": "3000000000000000", - "buyAmount": "230514135456539324", - "validTo": 4294967295, - "appData": "0x02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f80", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a736a3151cd", - "app_data_hash": "0x02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f80", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":553,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000e9efa5cea161220a0ed136560df741783d821ace" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000e9efa5cea161220a0ed136560df741783d821ace000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000332f3628797e2bc00000000000000000000000000000000000000000000000000000000ffffffff02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f800000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a736a3151cd0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xab2d5a8134aa4e175f7bf81e59c62b118ace616fdc7880feb6f597be86ffcc8fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072907, - "block_timestamp": 1781615316, - "tx_hash": "0xcd40bb1cac946ed5b9fefe773e2e95206832c8bef085a8c8c1b6d69eee936f3d", - "log_index": 158, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xe9efa5cea161220a0ed136560df741783d821ace", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xe9efa5cea161220a0ed136560df741783d821ace", - "sellAmount": "3000000000000000", - "buyAmount": "2885323190", - "validTo": 4294967295, - "appData": "0xaaef87acb8c6ef79296ec6f1eeb0f6139807717f74b992b66a084f2d9667c9d2", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a726a3151d1", - "app_data_hash": "0xaaef87acb8c6ef79296ec6f1eeb0f6139807717f74b992b66a084f2d9667c9d2", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":564,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000e9efa5cea161220a0ed136560df741783d821ace" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000e9efa5cea161220a0ed136560df741783d821ace000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000abfa89b600000000000000000000000000000000000000000000000000000000ffffffffaaef87acb8c6ef79296ec6f1eeb0f6139807717f74b992b66a084f2d9667c9d20000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a726a3151d10000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3918584cfaffc3f5a561a06e6b625c1b8e33f1c3daf2209356262a53c85b793eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072915, - "block_timestamp": 1781615412, - "tx_hash": "0x3f97018516748e8456bbbac405a4017f84476a75a8d90d08043a301fcc97a9ef", - "log_index": 204, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xe9efa5cea161220a0ed136560df741783d821ace", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xe9efa5cea161220a0ed136560df741783d821ace", - "sellAmount": "3000000000000000", - "buyAmount": "1805702375", - "validTo": 4294967295, - "appData": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a796a315235", - "app_data_hash": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":526,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000e9efa5cea161220a0ed136560df741783d821ace" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000e9efa5cea161220a0ed136560df741783d821ace000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000006ba0d4e700000000000000000000000000000000000000000000000000000000fffffffff802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a796a3152350000000000000000000000000000000000000000" - } - }, - { - "uid": "0x433ded5d6fe27454cc358a92e81df6d8706d169950a0874c00aee5bb6ed83495ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072937, - "block_timestamp": 1781615676, - "tx_hash": "0xac05ddeb0612687ee5e531d2e78b71ae1969988dbc0df307a7d837d11a452f77", - "log_index": 178, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7070c60252118e238594af6b351675953732163f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x7070c60252118e238594af6b351675953732163f", - "sellAmount": "3000000000000000", - "buyAmount": "1054702801", - "validTo": 4294967295, - "appData": "0xd32917116b84b2989ba64ed7365b1433d543b6311d54f467457817c612489ca4", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a836a315332", - "app_data_hash": "0xd32917116b84b2989ba64ed7365b1433d543b6311d54f467457817c612489ca4", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":520,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007070c60252118e238594af6b351675953732163f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000007070c60252118e238594af6b351675953732163f000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000003edd7cd100000000000000000000000000000000000000000000000000000000ffffffffd32917116b84b2989ba64ed7365b1433d543b6311d54f467457817c612489ca40000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a836a3153320000000000000000000000000000000000000000" - } - }, - { - "uid": "0x38e4a8d56d59f444f58688539a896771696fa639882a5a2164b48e42d48d9604ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072938, - "block_timestamp": 1781615688, - "tx_hash": "0x55e1326ec4f70606033540e111079e3b6d87dcf6140cd1f9b6d536b5afccdb8c", - "log_index": 159, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7070c60252118e238594af6b351675953732163f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x7070c60252118e238594af6b351675953732163f", - "sellAmount": "3000000000000000", - "buyAmount": "1788664892", - "validTo": 4294967295, - "appData": "0xebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b00", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a876a315341", - "app_data_hash": "0xebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b00", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":571,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007070c60252118e238594af6b351675953732163f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000007070c60252118e238594af6b351675953732163f000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000006a9cdc3c00000000000000000000000000000000000000000000000000000000ffffffffebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b000000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a876a3153410000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2391bfae00a69a1eec3c7514405867f7e0342e4882de0bde41beee6c9cac0353ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073096, - "block_timestamp": 1781617584, - "tx_hash": "0xb43fac202fe357c43e491ce513fc1b0ddea3b66333d1150f181bf61427691d0b", - "log_index": 17, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "14682257116", - "validTo": 4294967295, - "appData": "0x1a73e3b20393ab2dd0632d510d8c2b4a80cdb64aa7bd37e44ae1052e4205e9a8", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171ada6a315aa7", - "app_data_hash": "0x1a73e3b20393ab2dd0632d510d8c2b4a80cdb64aa7bd37e44ae1052e4205e9a8", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":75,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec50000000000000000000000000000000000000000000000000000000000036b2176dc00000000000000000000000000000000000000000000000000000000ffffffff1a73e3b20393ab2dd0632d510d8c2b4a80cdb64aa7bd37e44ae1052e4205e9a80000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171ada6a315aa70000000000000000000000000000000000000000" - } - }, - { - "uid": "0xf6cd036ee01440077ddbbead27c00c75addde136aa83c69fe6023c577dced7c4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073102, - "block_timestamp": 1781617656, - "tx_hash": "0x857d9c0b382f141e2d0d5f3c302776c29ed951cd0596962d3394b93bbe8b15d3", - "log_index": 138, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xc61aa42ce3af01501792ff9b5556fff1e6276e56", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xc61aa42ce3af01501792ff9b5556fff1e6276e56", - "sellAmount": "3000000000000000", - "buyAmount": "686919870", - "validTo": 4294967295, - "appData": "0x34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd52", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171ae26a315aeb", - "app_data_hash": "0x34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd52", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":577,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000c61aa42ce3af01501792ff9b5556fff1e6276e56" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000c61aa42ce3af01501792ff9b5556fff1e6276e56000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000028f190be00000000000000000000000000000000000000000000000000000000ffffffff34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd520000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171ae26a315aeb0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x157fa6bcd877fe40fe487109c21fb45d5430043179c709d5e1ae49c4f275d6a5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073102, - "block_timestamp": 1781617656, - "tx_hash": "0x1d1f3fa611dbe00ed52e1cf5f5bea9e7e5b2a991915f9eef02809da7b1fe4576", - "log_index": 156, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xc61aa42ce3af01501792ff9b5556fff1e6276e56", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xc61aa42ce3af01501792ff9b5556fff1e6276e56", - "sellAmount": "3000000000000000", - "buyAmount": "1786114895", - "validTo": 4294967295, - "appData": "0xe7e648ef11f8b2b44a56f88d0296447a11912ea586d4d6995b6d6b983fe4dd75", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171ae56a315af8", - "app_data_hash": "0xe7e648ef11f8b2b44a56f88d0296447a11912ea586d4d6995b6d6b983fe4dd75", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":561,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000c61aa42ce3af01501792ff9b5556fff1e6276e56" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000c61aa42ce3af01501792ff9b5556fff1e6276e56000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000006a75f34f00000000000000000000000000000000000000000000000000000000ffffffffe7e648ef11f8b2b44a56f88d0296447a11912ea586d4d6995b6d6b983fe4dd750000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171ae56a315af80000000000000000000000000000000000000000" - } - }, - { - "uid": "0x70aeba19202765e0d07a449afbac383c09fe44be8b1c8c12a133253dcffc0ebeba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073107, - "block_timestamp": 1781617716, - "tx_hash": "0x9f55651c935e26c0c11ce8f52c4c69e19de50bb42f4ea4e7c1897e236c7f1c80", - "log_index": 59, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "62580370391", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171aef6a315b29", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000e9214abd700000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171aef6a315b290000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8d4ca0b6f37a815fbe67c9d7fb1e61dbe269de358b619244057f6c519eda0aa5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073145, - "block_timestamp": 1781618172, - "tx_hash": "0xf8dde98f0dc48f8ee5b394b7587d5a6918e478ba16c25a818e4604ce0e9bf0a9", - "log_index": 144, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5ea2b6636fe1394513e76cc04f0355668785a00b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x5ea2b6636fe1394513e76cc04f0355668785a00b", - "sellAmount": "3000000000000000", - "buyAmount": "694500747", - "validTo": 4294967295, - "appData": "0xb46844b7a39d6e8495500a4a4f741c54cbdc406f49a0cb5f8768d715d307c547", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b186a315cf9", - "app_data_hash": "0xb46844b7a39d6e8495500a4a4f741c54cbdc406f49a0cb5f8768d715d307c547", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":474,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005ea2b6636fe1394513e76cc04f0355668785a00b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000005ea2b6636fe1394513e76cc04f0355668785a00b000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000029653d8b00000000000000000000000000000000000000000000000000000000ffffffffb46844b7a39d6e8495500a4a4f741c54cbdc406f49a0cb5f8768d715d307c5470000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b186a315cf90000000000000000000000000000000000000000" - } - }, - { - "uid": "0x45902e3ec7c9085a3da0cb55cb8b6dec3984374bd932f6c8b3f944b3176c4e84ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073145, - "block_timestamp": 1781618172, - "tx_hash": "0x77c93eec4c8033450dcec4b48461ed9709aa1eae7f4fda9ab3c515dc215ad211", - "log_index": 152, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5ea2b6636fe1394513e76cc04f0355668785a00b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x5ea2b6636fe1394513e76cc04f0355668785a00b", - "sellAmount": "3000000000000000", - "buyAmount": "1593757918", - "validTo": 4294967295, - "appData": "0xb46844b7a39d6e8495500a4a4f741c54cbdc406f49a0cb5f8768d715d307c547", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b1b6a315cfc", - "app_data_hash": "0xb46844b7a39d6e8495500a4a4f741c54cbdc406f49a0cb5f8768d715d307c547", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":474,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005ea2b6636fe1394513e76cc04f0355668785a00b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000005ea2b6636fe1394513e76cc04f0355668785a00b000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000005efed0de00000000000000000000000000000000000000000000000000000000ffffffffb46844b7a39d6e8495500a4a4f741c54cbdc406f49a0cb5f8768d715d307c5470000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b1b6a315cfc0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x19d6b26a5bedf930daed07eb01fc701a27422cd0395242627c86669b099a1a75ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073178, - "block_timestamp": 1781618568, - "tx_hash": "0x55e96eec17c21c5948e01661f36574f76e619d569229250ba7572b1c9cc803b1", - "log_index": 699, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8f7bc29dea8d59f8d42fcf5089230e15e84eedc0", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x8f7bc29dea8d59f8d42fcf5089230e15e84eedc0", - "sellAmount": "100000000000000000", - "buyAmount": "21015361334", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b4f6a315e78", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008f7bc29dea8d59f8d42fcf5089230e15e84eedc0" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000008f7bc29dea8d59f8d42fcf5089230e15e84eedc0000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000004e49cf73600000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b4f6a315e780000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8ecd3588048db716f4306de3fd22cf27ce9004fc4b2403b749785a8f5867a3f8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073179, - "block_timestamp": 1781618580, - "tx_hash": "0x830525807f75e6520f2a8da4265174b725dd46248973780fc514a5515e90a357", - "log_index": 148, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7918399ce6ef12c8c422d4d52a56ef02b9c0cab9", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x7918399ce6ef12c8c422d4d52a56ef02b9c0cab9", - "sellAmount": "4000000000000000", - "buyAmount": "1011594396", - "validTo": 4294967295, - "appData": "0xca5142f1728cd4f20825330f3d86dbf935f3b2fb8743f04614f3b919110eafc5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b546a315e8b", - "app_data_hash": "0xca5142f1728cd4f20825330f3d86dbf935f3b2fb8743f04614f3b919110eafc5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":408,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007918399ce6ef12c8c422d4d52a56ef02b9c0cab9" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000007918399ce6ef12c8c422d4d52a56ef02b9c0cab9000000000000000000000000000000000000000000000000000e35fa931a0000000000000000000000000000000000000000000000000000000000003c4bb49c00000000000000000000000000000000000000000000000000000000ffffffffca5142f1728cd4f20825330f3d86dbf935f3b2fb8743f04614f3b919110eafc50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b546a315e8b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2baee5d91be5143757f83a52708a428a3440afe7e2d87d1465818f6efcf510c5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073185, - "block_timestamp": 1781618652, - "tx_hash": "0xb84ccd16027867aa988bd349df2b9af3d88b59e7a4662e9008709edc17f67a62", - "log_index": 347, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7918399ce6ef12c8c422d4d52a56ef02b9c0cab9", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x7918399ce6ef12c8c422d4d52a56ef02b9c0cab9", - "sellAmount": "3000000000000000", - "buyAmount": "2910018946", - "validTo": 4294967295, - "appData": "0x6afa204df74e6e5f755bc405262584b2f471acb833756043540156dbedf9e6e6", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b666a315ed8", - "app_data_hash": "0x6afa204df74e6e5f755bc405262584b2f471acb833756043540156dbedf9e6e6", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":568,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007918399ce6ef12c8c422d4d52a56ef02b9c0cab9" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000007918399ce6ef12c8c422d4d52a56ef02b9c0cab9000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000ad735d8200000000000000000000000000000000000000000000000000000000ffffffff6afa204df74e6e5f755bc405262584b2f471acb833756043540156dbedf9e6e60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b666a315ed80000000000000000000000000000000000000000" - } - }, - { - "uid": "0x0a684eb75ffc7ead51b5d0df5e59a452746f4550602050fe0bfa449f43ed1706ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073186, - "block_timestamp": 1781618664, - "tx_hash": "0x81806348218070d5f3fe7516088d390a3c37dd2f4ea11e25126db2471058bf8d", - "log_index": 357, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8f7bc29dea8d59f8d42fcf5089230e15e84eedc0", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x8f7bc29dea8d59f8d42fcf5089230e15e84eedc0", - "sellAmount": "1000000000000000000", - "buyAmount": "88599873389744932852", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b656a315ee7", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008f7bc29dea8d59f8d42fcf5089230e15e84eedc0" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000008f7bc29dea8d59f8d42fcf5089230e15e84eedc00000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000004cd91fb6cfc54c7f400000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b656a315ee70000000000000000000000000000000000000000" - } - }, - { - "uid": "0xccf652d3d6b0b5c7b05cdf2f6b0a864c2edbeddf18f61f9b791e842976167000ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073220, - "block_timestamp": 1781619072, - "tx_hash": "0x08b769677be1402769b3e5a76dc0cf6a0be36e4bd774e2f28bb13fe82fa66aca", - "log_index": 215, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xe5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xe5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a", - "sellAmount": "3000000000000000", - "buyAmount": "619113046", - "validTo": 4294967295, - "appData": "0x6d5310f10496a960dcc1346999a4b1069b0aa86913fffbf43bc05fdd9e045de7", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b986a316072", - "app_data_hash": "0x6d5310f10496a960dcc1346999a4b1069b0aa86913fffbf43bc05fdd9e045de7", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":558,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000e5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000e5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000024e6ea5600000000000000000000000000000000000000000000000000000000ffffffff6d5310f10496a960dcc1346999a4b1069b0aa86913fffbf43bc05fdd9e045de70000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b986a3160720000000000000000000000000000000000000000" - } - }, - { - "uid": "0x51bd84d817e6f56748d0fd39d1aa340d75b38326cdb92c0b8ce7c7fad587b308ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073220, - "block_timestamp": 1781619072, - "tx_hash": "0xb1a81b5705fa1c54224e838156e2e5fdc1d7a0154f77b0da840c88332b2ace13", - "log_index": 258, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xe5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xe5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a", - "sellAmount": "3000000000000000", - "buyAmount": "2289564261", - "validTo": 4294967295, - "appData": "0x33704e0fd722698ca109301285107e0c368723a5eef29ecc64275822c1ab1575", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b9a6a316078", - "app_data_hash": "0x33704e0fd722698ca109301285107e0c368723a5eef29ecc64275822c1ab1575", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":531,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000e5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000e5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000008877fa6500000000000000000000000000000000000000000000000000000000ffffffff33704e0fd722698ca109301285107e0c368723a5eef29ecc64275822c1ab15750000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b9a6a3160780000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3f2f050fc8d390b6b9c12f4ba62288e91c22bf6543b5dbdc3beba36a2ddc82f8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073251, - "block_timestamp": 1781619444, - "tx_hash": "0x8b040427c88d3a95163a665fad80617889de0c617e2f45901bc48c0ff5ae897b", - "log_index": 222, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x3982ceb2f7d53b11c6bc484926af26b7e765b7cc", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x3982ceb2f7d53b11c6bc484926af26b7e765b7cc", - "sellAmount": "3000000000000000", - "buyAmount": "541790631", - "validTo": 4294967295, - "appData": "0x05fafe32e4d71be1dcd68af804027d7acd76d5049a3dfc68ee255a7e0b5f9113", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171bd96a3161ea", - "app_data_hash": "0x05fafe32e4d71be1dcd68af804027d7acd76d5049a3dfc68ee255a7e0b5f9113", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":524,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000003982ceb2f7d53b11c6bc484926af26b7e765b7cc" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000003982ceb2f7d53b11c6bc484926af26b7e765b7cc000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000204b11a700000000000000000000000000000000000000000000000000000000ffffffff05fafe32e4d71be1dcd68af804027d7acd76d5049a3dfc68ee255a7e0b5f91130000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171bd96a3161ea0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x9b53383bfa026929eb6cda36da0bd4e4cbad109dcbf9417dd99395cca265c53cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073251, - "block_timestamp": 1781619444, - "tx_hash": "0x3814dbdb4ab267e920b38f80071d176f6990ff551767cd79639486a8d2d20c74", - "log_index": 224, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x3982ceb2f7d53b11c6bc484926af26b7e765b7cc", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x3982ceb2f7d53b11c6bc484926af26b7e765b7cc", - "sellAmount": "3000000000000000", - "buyAmount": "2973790710", - "validTo": 4294967295, - "appData": "0x22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171bdd6a3161ec", - "app_data_hash": "0x22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":585,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000003982ceb2f7d53b11c6bc484926af26b7e765b7cc" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000003982ceb2f7d53b11c6bc484926af26b7e765b7cc000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000b14071f600000000000000000000000000000000000000000000000000000000ffffffff22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171bdd6a3161ec0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd55d2e7ed4f86b6caa210f3ff7ee7d900dc951277031281c993beefbb69ef2bcba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073259, - "block_timestamp": 1781619540, - "tx_hash": "0xdd663860708fb57ff6dfc265457f8bdbb4d129bdb9307a6ad05663ba8326e544", - "log_index": 229, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x3982ceb2f7d53b11c6bc484926af26b7e765b7cc", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x3982ceb2f7d53b11c6bc484926af26b7e765b7cc", - "sellAmount": "3000000000000000", - "buyAmount": "484371166", - "validTo": 4294967295, - "appData": "0x531c0e30d6f26adc9a153a50f49a9d9bcbf34369ceeb89c926257327749b6861", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171be86a316255", - "app_data_hash": "0x531c0e30d6f26adc9a153a50f49a9d9bcbf34369ceeb89c926257327749b6861", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":545,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000003982ceb2f7d53b11c6bc484926af26b7e765b7cc" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000003982ceb2f7d53b11c6bc484926af26b7e765b7cc000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000001cdeeade00000000000000000000000000000000000000000000000000000000ffffffff531c0e30d6f26adc9a153a50f49a9d9bcbf34369ceeb89c926257327749b68610000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171be86a3162550000000000000000000000000000000000000000" - } - }, - { - "uid": "0x355e6c2e302cefd0ada31df38c686d8f38b7bd032adc266084dcc10ade0eb609ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073304, - "block_timestamp": 1781620080, - "tx_hash": "0xd958908a6f6a378f0c012eacfb9de658d5c4898b440ead38ab0311f2b5e0b096", - "log_index": 409, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "sellAmount": "200000000000000000", - "buyAmount": "23095671606", - "validTo": 4294967295, - "appData": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c086a31645f", - "app_data_hash": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":56,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c00000000000000000000000000000000000000000000000002c68af0bb14000000000000000000000000000000000000000000000000000000000005609bfb3600000000000000000000000000000000000000000000000000000000fffffffff3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c086a31645f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb75a1e65b9ba881279d9a5b1ca3326142d8b5ced928c4907f2f07d8b44a65be6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073309, - "block_timestamp": 1781620140, - "tx_hash": "0x734d84de93efb3579a2cc102c1b3b0def3e2733385f510935ca48ad9793dea98", - "log_index": 403, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "sellAmount": "200000000000000000", - "buyAmount": "194420597291", - "validTo": 4294967295, - "appData": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c0d6a31649d", - "app_data_hash": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":56,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c00000000000000000000000000000000000000000000000002c68af0bb1400000000000000000000000000000000000000000000000000000000002d445ee22b00000000000000000000000000000000000000000000000000000000fffffffff3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c0d6a31649d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x812f257ae548aa1b5b889e7aaee8efd95a659d04cc4d1d3a04172671f501c085ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073316, - "block_timestamp": 1781620224, - "tx_hash": "0x5c2e710f41053a5019bf97680649c2274a969d4844099d4c874e10cc3c338aa6", - "log_index": 203, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5c47952bc0e6b41688635449a30317c7e885d4f1", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x5c47952bc0e6b41688635449a30317c7e885d4f1", - "sellAmount": "200000000000000000", - "buyAmount": "192429522577", - "validTo": 4294967295, - "appData": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c176a3164da", - "app_data_hash": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":56,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005c47952bc0e6b41688635449a30317c7e885d4f1" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000005c47952bc0e6b41688635449a30317c7e885d4f100000000000000000000000000000000000000000000000002c68af0bb1400000000000000000000000000000000000000000000000000000000002ccdb17e9100000000000000000000000000000000000000000000000000000000fffffffff3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c176a3164da0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x25efa78a27f938c4e2b8e63eaffb88c699fe50cbb45208bf43e2599e4869d04dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073319, - "block_timestamp": 1781620260, - "tx_hash": "0x43a69de0db6b04095d7fc14eed4a140d429195ba21c7aea7228d0e5803a55783", - "log_index": 85, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5c47952bc0e6b41688635449a30317c7e885d4f1", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x5c47952bc0e6b41688635449a30317c7e885d4f1", - "sellAmount": "300000000000000000", - "buyAmount": "267776716581", - "validTo": 4294967295, - "appData": "0x9cb1341bb7179aabe49761f7302e04843953058e67a02e6b6166325de14dce16", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c196a3164f8", - "app_data_hash": "0x9cb1341bb7179aabe49761f7302e04843953058e67a02e6b6166325de14dce16", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":54,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005c47952bc0e6b41688635449a30317c7e885d4f1" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000005c47952bc0e6b41688635449a30317c7e885d4f10000000000000000000000000000000000000000000000000429d069189e00000000000000000000000000000000000000000000000000000000003e58bc6f2500000000000000000000000000000000000000000000000000000000ffffffff9cb1341bb7179aabe49761f7302e04843953058e67a02e6b6166325de14dce160000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c196a3164f80000000000000000000000000000000000000000" - } - }, - { - "uid": "0x72aee1ae1c433703176cde121bec85139a44458535b3bcd36968ed55df3948e4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073340, - "block_timestamp": 1781620512, - "tx_hash": "0x027f9c1142c666432f96ab6567fde58a18a972b0ae1576cbd6cc9abc75a9ec93", - "log_index": 105, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "sellAmount": "200000000000000000", - "buyAmount": "189997026899", - "validTo": 4294967295, - "appData": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c2e6a316608", - "app_data_hash": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":56,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c00000000000000000000000000000000000000000000000002c68af0bb1400000000000000000000000000000000000000000000000000000000002c3cb48e5300000000000000000000000000000000000000000000000000000000fffffffff3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c2e6a3166080000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe10e143e6c9d66cdbbbbf93d7a3fa23630b308ddb0d3d61827f4aacc663bea87ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073345, - "block_timestamp": 1781620572, - "tx_hash": "0x8438318f21a950eaf032774557ac00be217590a753d17759308e5620f68e198f", - "log_index": 213, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "sellAmount": "200000000000000000", - "buyAmount": "61186347867", - "validTo": 4294967295, - "appData": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c376a316654", - "app_data_hash": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":56,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c00000000000000000000000000000000000000000000000002c68af0bb1400000000000000000000000000000000000000000000000000000000000e3efd935b00000000000000000000000000000000000000000000000000000000fffffffff3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c376a3166540000000000000000000000000000000000000000" - } - }, - { - "uid": "0x1cb540d1a57080ddb38eb4b129e0317b601c2e28b5ef08d8c33f3d435ce12536ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073377, - "block_timestamp": 1781620956, - "tx_hash": "0xc2e54e0b13d9e691e19fd9aa9d1db77ce1c438402a4a9522d477d1352896a79c", - "log_index": 187, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5c47952bc0e6b41688635449a30317c7e885d4f1", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x5c47952bc0e6b41688635449a30317c7e885d4f1", - "sellAmount": "1000000000000000000", - "buyAmount": "580008099158", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c4b6a3167cb", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005c47952bc0e6b41688635449a30317c7e885d4f1" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000005c47952bc0e6b41688635449a30317c7e885d4f10000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000870b2d3d5600000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c4b6a3167cb0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x4a27b3b7a1a975a22d75c855995cca9113d823a11ff6c3b1a3e413a370838115ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073446, - "block_timestamp": 1781621784, - "tx_hash": "0x2c0211b8cea8caec994007ed82d3070fb1dd8e481bdec702331b1f695653f79d", - "log_index": 2790, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x6085a14ae513d765d5157e2d24fa4380ed92412e", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x6085a14ae513d765d5157e2d24fa4380ed92412e", - "sellAmount": "3000000000000000000", - "buyAmount": "1370106414465977223159", - "validTo": 4294967295, - "appData": "0x3c0b3ab24f873a4919868710a5c5790a77284691f530562748fae381debe19d1", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c6c6a316b0d", - "app_data_hash": "0x3c0b3ab24f873a4919868710a5c5790a77284691f530562748fae381debe19d1", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":50,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000006085a14ae513d765d5157e2d24fa4380ed92412e" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000006085a14ae513d765d5157e2d24fa4380ed92412e00000000000000000000000000000000000000000000000029a2241af62c000000000000000000000000000000000000000000000000004a460bccd268b107f700000000000000000000000000000000000000000000000000000000ffffffff3c0b3ab24f873a4919868710a5c5790a77284691f530562748fae381debe19d10000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c6c6a316b0d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6f60c9b78933f488946333ab27e5c1f3c215f43cce0a747be9a826bc3050264cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073450, - "block_timestamp": 1781621832, - "tx_hash": "0xbd0c6b586a4f19ae2108f9a120430fe5c79c244692a57d694e124085001c614d", - "log_index": 359, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x6085a14ae513d765d5157e2d24fa4380ed92412e", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x58eb19ef91e8a6327fed391b51ae1887b833cc91", - "receiver": "0x6085a14ae513d765d5157e2d24fa4380ed92412e", - "sellAmount": "1500000000000000000", - "buyAmount": "699127275", - "validTo": 4294967295, - "appData": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c736a316b3c", - "app_data_hash": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000006085a14ae513d765d5157e2d24fa4380ed92412e" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000058eb19ef91e8a6327fed391b51ae1887b833cc910000000000000000000000006085a14ae513d765d5157e2d24fa4380ed92412e00000000000000000000000000000000000000000000000014d1120d7b1600000000000000000000000000000000000000000000000000000000000029abd5eb00000000000000000000000000000000000000000000000000000000ffffffff24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c736a316b3c0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2afac9af61ab99e267d1de96a6ca82c4d33e4647927e7596a59b3e416d459bc7ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073618, - "block_timestamp": 1781623884, - "tx_hash": "0x1312cec7fef9a9a5e0abbf4ec73dba23233c0609e234bbaf948d0d418c9c9e9e", - "log_index": 218, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0c231dc9f63d19646097e20304bf192aafd6a191", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x0c231dc9f63d19646097e20304bf192aafd6a191", - "sellAmount": "5000000000000000", - "buyAmount": "833074990", - "validTo": 4294967295, - "appData": "0x122dd7fc123267a29234f97dfe8c89658ef81527a38b73f9c93983a5949c36d6", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171ccf6a31732b", - "app_data_hash": "0x122dd7fc123267a29234f97dfe8c89658ef81527a38b73f9c93983a5949c36d6", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":311,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000c231dc9f63d19646097e20304bf192aafd6a191" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000000c231dc9f63d19646097e20304bf192aafd6a1910000000000000000000000000000000000000000000000000011c37937e080000000000000000000000000000000000000000000000000000000000031a7b72e00000000000000000000000000000000000000000000000000000000ffffffff122dd7fc123267a29234f97dfe8c89658ef81527a38b73f9c93983a5949c36d60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171ccf6a31732b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x09eed0819f2b14d64402166002ea97bbfe3055e35e13e4e403e05dc564df93a7ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073639, - "block_timestamp": 1781624136, - "tx_hash": "0xc45711228524d1c10c288e93be4ecb68965d945ccf8f52945dff8e57f2afb6b6", - "log_index": 201, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xeab41fb7d5bc649782c4accde66722058faf79ce", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xeab41fb7d5bc649782c4accde66722058faf79ce", - "sellAmount": "3000000000000000", - "buyAmount": "453116641", - "validTo": 4294967295, - "appData": "0x05fafe32e4d71be1dcd68af804027d7acd76d5049a3dfc68ee255a7e0b5f9113", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171ce26a317440", - "app_data_hash": "0x05fafe32e4d71be1dcd68af804027d7acd76d5049a3dfc68ee255a7e0b5f9113", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":524,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000eab41fb7d5bc649782c4accde66722058faf79ce" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000eab41fb7d5bc649782c4accde66722058faf79ce000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000001b0202e100000000000000000000000000000000000000000000000000000000ffffffff05fafe32e4d71be1dcd68af804027d7acd76d5049a3dfc68ee255a7e0b5f91130000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171ce26a3174400000000000000000000000000000000000000000" - } - }, - { - "uid": "0x0f04118e0cba57091b0b0d2a2f764e70e0e2374368340211e1e7c994f9081cf9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073639, - "block_timestamp": 1781624136, - "tx_hash": "0xc590b9e369ba16d3b8ab2d5f3fd4f7ff5910284608afe032620c43b260ec1d33", - "log_index": 208, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xeab41fb7d5bc649782c4accde66722058faf79ce", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xeab41fb7d5bc649782c4accde66722058faf79ce", - "sellAmount": "3000000000000000", - "buyAmount": "11588302619", - "validTo": 4294967295, - "appData": "0x22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171ce56a317440", - "app_data_hash": "0x22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":585,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000eab41fb7d5bc649782c4accde66722058faf79ce" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000eab41fb7d5bc649782c4accde66722058faf79ce000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000002b2b7771b00000000000000000000000000000000000000000000000000000000ffffffff22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171ce56a3174400000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb1445f4662e90380c4ce16df62586e761c0c02ce108fe27b02b0da7480e68c47ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073661, - "block_timestamp": 1781624400, - "tx_hash": "0x0b1f2b9a8091f80e673a52ca58cf17e3705b172dd348809046c5aacbc8a0051f", - "log_index": 173, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x5a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95", - "sellAmount": "3000000000000000", - "buyAmount": "438953318", - "validTo": 4294967295, - "appData": "0x5d6969410256527653fa247b30fcaabf2dd6bc590e6739ebe92c8b6bedbf220b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171cf46a31754f", - "app_data_hash": "0x5d6969410256527653fa247b30fcaabf2dd6bc590e6739ebe92c8b6bedbf220b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":542,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000005a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000001a29e56600000000000000000000000000000000000000000000000000000000ffffffff5d6969410256527653fa247b30fcaabf2dd6bc590e6739ebe92c8b6bedbf220b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171cf46a31754f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x1b9f5ae8f056774d43acb0af838b27fc37f35058038cabcf3acd2790952f40f4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073662, - "block_timestamp": 1781624412, - "tx_hash": "0x8cec9fd3998b86d41d8632fbf824458ed8f2ba2fe1b94a9d598c1491bf660028", - "log_index": 185, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x5a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95", - "sellAmount": "3000000000000000", - "buyAmount": "11689102009", - "validTo": 4294967295, - "appData": "0x5d157f166954f6168aeaad579a1980912509a3af16577f2bb47406b3ef63e636", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171cf66a317554", - "app_data_hash": "0x5d157f166954f6168aeaad579a1980912509a3af16577f2bb47406b3ef63e636", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":517,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000005a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000002b8b98ab900000000000000000000000000000000000000000000000000000000ffffffff5d157f166954f6168aeaad579a1980912509a3af16577f2bb47406b3ef63e6360000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171cf66a3175540000000000000000000000000000000000000000" - } - }, - { - "uid": "0xbf218ce6f1ba3c450b82ab060f4ac481ba61399e5aca8848fed7713652ac41c3ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073684, - "block_timestamp": 1781624676, - "tx_hash": "0x8470fe076cb2466863f68027ca42ed1079015256d95e5ddbf278ce472ae70773", - "log_index": 185, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xabaab35331caca64c9d1bb37a76e8adaccb0c310", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xabaab35331caca64c9d1bb37a76e8adaccb0c310", - "sellAmount": "3000000000000000", - "buyAmount": "11461108175", - "validTo": 4294967295, - "appData": "0x7dc348e6a9a5ce20f0d3578e926dc1f7245e95749461663dcabbab293f287253", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d076a317659", - "app_data_hash": "0x7dc348e6a9a5ce20f0d3578e926dc1f7245e95749461663dcabbab293f287253", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":547,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000abaab35331caca64c9d1bb37a76e8adaccb0c310" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000abaab35331caca64c9d1bb37a76e8adaccb0c310000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000002ab22a1cf00000000000000000000000000000000000000000000000000000000ffffffff7dc348e6a9a5ce20f0d3578e926dc1f7245e95749461663dcabbab293f2872530000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d076a3176590000000000000000000000000000000000000000" - } - }, - { - "uid": "0xcd000d8e026b97a045f038b0ccb1321203ce8d3fdc9d5d4bb6ac936703672a66ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073684, - "block_timestamp": 1781624676, - "tx_hash": "0x2869062f73730ff857e7cfaa18fe4a810a68be844b5c2215a9d8a1d088ed79da", - "log_index": 186, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xabaab35331caca64c9d1bb37a76e8adaccb0c310", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xabaab35331caca64c9d1bb37a76e8adaccb0c310", - "sellAmount": "3000000000000000", - "buyAmount": "425970083", - "validTo": 4294967295, - "appData": "0x9ef7b0abe5794687f99254ea6cd6071d258daa2248aec3baa1cd0db60913abc1", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d046a317658", - "app_data_hash": "0x9ef7b0abe5794687f99254ea6cd6071d258daa2248aec3baa1cd0db60913abc1", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":555,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000abaab35331caca64c9d1bb37a76e8adaccb0c310" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000abaab35331caca64c9d1bb37a76e8adaccb0c310000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000001963c9a300000000000000000000000000000000000000000000000000000000ffffffff9ef7b0abe5794687f99254ea6cd6071d258daa2248aec3baa1cd0db60913abc10000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d046a3176580000000000000000000000000000000000000000" - } - }, - { - "uid": "0x41e05a801c0e465cb7ffcdbd8d0a34aadc38044342961f344fdd79fc017053b8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073706, - "block_timestamp": 1781624940, - "tx_hash": "0x71161a569717d9210988d71576a178e210a94869bbf783fa1b05c54481c9852f", - "log_index": 96, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x4277446d349a403bfd42f607505ef460d80b2de7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x4277446d349a403bfd42f607505ef460d80b2de7", - "sellAmount": "3000000000000000", - "buyAmount": "11437554800", - "validTo": 4294967295, - "appData": "0x15e6ad044637c621a9610df83bef56845d38da755a4c4a1fe6a56c4e74b37adf", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d156a317765", - "app_data_hash": "0x15e6ad044637c621a9610df83bef56845d38da755a4c4a1fe6a56c4e74b37adf", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":516,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000004277446d349a403bfd42f607505ef460d80b2de7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000004277446d349a403bfd42f607505ef460d80b2de7000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000002a9bb3c7000000000000000000000000000000000000000000000000000000000ffffffff15e6ad044637c621a9610df83bef56845d38da755a4c4a1fe6a56c4e74b37adf0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d156a3177650000000000000000000000000000000000000000" - } - }, - { - "uid": "0x46728c280bfaeaa5f0a65000af2da2ebd603a85bcb9507a1bdd2bc987e0568daba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073706, - "block_timestamp": 1781624940, - "tx_hash": "0xda884f4f063fb6525034e9be66e869f6c3204a3bbd5c61069218b9f060b603f5", - "log_index": 109, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x4277446d349a403bfd42f607505ef460d80b2de7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x4277446d349a403bfd42f607505ef460d80b2de7", - "sellAmount": "3000000000000000", - "buyAmount": "417610655", - "validTo": 4294967295, - "appData": "0xe25d4a89173e7a5a67d22da187bee972261540bbd3320b98e741b1ffa1b2a398", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d146a317768", - "app_data_hash": "0xe25d4a89173e7a5a67d22da187bee972261540bbd3320b98e741b1ffa1b2a398", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":534,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000004277446d349a403bfd42f607505ef460d80b2de7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000004277446d349a403bfd42f607505ef460d80b2de7000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000018e43b9f00000000000000000000000000000000000000000000000000000000ffffffffe25d4a89173e7a5a67d22da187bee972261540bbd3320b98e741b1ffa1b2a3980000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d146a3177680000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7d517f6befad866f8b47f789b1d89b88c7c500de8727624412c55d55e037cfd6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073716, - "block_timestamp": 1781625060, - "tx_hash": "0xbfac0479a4477bf549c0867ee904cd68ebddb9d3e924ca4b35bfdd9373c902e9", - "log_index": 107, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x4277446d349a403bfd42f607505ef460d80b2de7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x4277446d349a403bfd42f607505ef460d80b2de7", - "sellAmount": "3000000000000000", - "buyAmount": "9500929904", - "validTo": 4294967295, - "appData": "0x35b445ce36cc78696cdce35d54ca082162061890b4faefddebe7ef3efa5f9246", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d1b6a3177df", - "app_data_hash": "0x35b445ce36cc78696cdce35d54ca082162061890b4faefddebe7ef3efa5f9246", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":521,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000004277446d349a403bfd42f607505ef460d80b2de7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000004277446d349a403bfd42f607505ef460d80b2de7000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000002364caf7000000000000000000000000000000000000000000000000000000000ffffffff35b445ce36cc78696cdce35d54ca082162061890b4faefddebe7ef3efa5f92460000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d1b6a3177df0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xcd0993d3d31b1d299b5e8a86bbbbd81cfa0d1c3183e1d49acf49b5182da382b6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073738, - "block_timestamp": 1781625324, - "tx_hash": "0xe6d83d4112d9d841cc901676e704ea537d1c82aa288e6b2ba1532dcad5602d5f", - "log_index": 95, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9132748505f3439f60002e28d56cc74baf7a9114", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x9132748505f3439f60002e28d56cc74baf7a9114", - "sellAmount": "3000000000000000", - "buyAmount": "410618017", - "validTo": 4294967295, - "appData": "0xcf3114251a1e949e931cc99c5691787388439b9c1a2de56217352420c5283ed8", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d286a3178e0", - "app_data_hash": "0xcf3114251a1e949e931cc99c5691787388439b9c1a2de56217352420c5283ed8", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":519,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009132748505f3439f60002e28d56cc74baf7a9114" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000009132748505f3439f60002e28d56cc74baf7a9114000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000187988a100000000000000000000000000000000000000000000000000000000ffffffffcf3114251a1e949e931cc99c5691787388439b9c1a2de56217352420c5283ed80000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d286a3178e00000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd41c9e0bca8c1e02c11816f4446a5a54b56f1927fd15180ddd2cbaec9e12a5a9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073738, - "block_timestamp": 1781625324, - "tx_hash": "0x28d7ad9009cf447e9a18050edb2fe6522c922a75d6e921d6bd13b3d1b0538820", - "log_index": 107, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9132748505f3439f60002e28d56cc74baf7a9114", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x9132748505f3439f60002e28d56cc74baf7a9114", - "sellAmount": "3000000000000000", - "buyAmount": "9256282275", - "validTo": 4294967295, - "appData": "0x34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd52", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d2a6a3178e8", - "app_data_hash": "0x34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd52", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":577,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009132748505f3439f60002e28d56cc74baf7a9114" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000009132748505f3439f60002e28d56cc74baf7a9114000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000227b7a8a300000000000000000000000000000000000000000000000000000000ffffffff34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd520000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d2a6a3178e80000000000000000000000000000000000000000" - } - }, - { - "uid": "0x728367f6b832283d0e0ceaaedef9420ae5d7790a6c877e2e0dcc47bd5617e01cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073789, - "block_timestamp": 1781625936, - "tx_hash": "0x5e5842f238c3b58630e4ab74eca2d1a8882415674e711a700c478bb848512683", - "log_index": 305, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "sellAmount": "30100000000000000", - "buyAmount": "1201007962241800920", - "validTo": 4294967295, - "appData": "0x6a224f81444b2326efff172daa624325f38551f9a42b44d9cfd458ac488658b1", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d3e6a317b44", - "app_data_hash": "0x6a224f81444b2326efff172daa624325f38551f9a42b44d9cfd458ac488658b1", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":94,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec000000000000000000000000000000000000000000000000006aefca5fbd400000000000000000000000000000000000000000000000000010aad660e1d69ad800000000000000000000000000000000000000000000000000000000ffffffff6a224f81444b2326efff172daa624325f38551f9a42b44d9cfd458ac488658b10000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d3e6a317b440000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa78cabeb3b2758df956e580b224d896ea4a14e332b6643e82f4190d3f2ecbd4bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074351, - "block_timestamp": 1781632692, - "tx_hash": "0x3fa10e4c2a5affc56856c70af4604824a7313541691a0caefe7a095706f917ac", - "log_index": 36, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x85851fcb5d7403d77949432b4cd63790b962e92b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x85851fcb5d7403d77949432b4cd63790b962e92b", - "sellAmount": "3000000000000000", - "buyAmount": "402068215", - "validTo": 4294967295, - "appData": "0x34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd52", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e216a3195b0", - "app_data_hash": "0x34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd52", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":577,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000085851fcb5d7403d77949432b4cd63790b962e92b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000085851fcb5d7403d77949432b4cd63790b962e92b000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000017f712f700000000000000000000000000000000000000000000000000000000ffffffff34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd520000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e216a3195b00000000000000000000000000000000000000000" - } - }, - { - "uid": "0x211dd498ba00c935fb075c33f3a9429225d12ac9e54d34845d0f24de98bdcc7bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074351, - "block_timestamp": 1781632692, - "tx_hash": "0xd03f52426f6691ca1d86b4ae3177202cae16e43caebf5f1d37c5260e2c976d98", - "log_index": 39, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x85851fcb5d7403d77949432b4cd63790b962e92b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x85851fcb5d7403d77949432b4cd63790b962e92b", - "sellAmount": "3000000000000000", - "buyAmount": "4689867573", - "validTo": 4294967295, - "appData": "0x031c2b63075bdf33cf10ea93a20c96f964f3ba47df98ca5eb971acf7c0797255", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e236a3195b6", - "app_data_hash": "0x031c2b63075bdf33cf10ea93a20c96f964f3ba47df98ca5eb971acf7c0797255", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":550,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000085851fcb5d7403d77949432b4cd63790b962e92b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000085851fcb5d7403d77949432b4cd63790b962e92b000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000011789b33500000000000000000000000000000000000000000000000000000000ffffffff031c2b63075bdf33cf10ea93a20c96f964f3ba47df98ca5eb971acf7c07972550000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e236a3195b60000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5f686fb710c1ff7581299a6e3b431d11a23eafe85fa2117019f5cf10be012df5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074387, - "block_timestamp": 1781633124, - "tx_hash": "0x8337252f83605d22fe12006a512b7c741d7b88da2c4178ebeae6990a793945f9", - "log_index": 51, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x3058b17394d11fd8b847e6522a0e6302cddf1bdf", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x3058b17394d11fd8b847e6522a0e6302cddf1bdf", - "sellAmount": "3000000000000000", - "buyAmount": "406673606", - "validTo": 4294967295, - "appData": "0xa1301781465d1f008067fc72dfcd0b3114fe8b2642a0c92f530eea6e414738a9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e316a31975d", - "app_data_hash": "0xa1301781465d1f008067fc72dfcd0b3114fe8b2642a0c92f530eea6e414738a9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":513,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000003058b17394d11fd8b847e6522a0e6302cddf1bdf" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000003058b17394d11fd8b847e6522a0e6302cddf1bdf000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000183d58c600000000000000000000000000000000000000000000000000000000ffffffffa1301781465d1f008067fc72dfcd0b3114fe8b2642a0c92f530eea6e414738a90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e316a31975d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3b7b5aaa7a5d2ac99f87d9d5c1c569136cf88a68bcee8215a4dc065bd29a187cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074387, - "block_timestamp": 1781633124, - "tx_hash": "0x1eb992352b0faee9e356adc922b1c9da194dc542f6fc1449f3483800e1c8b400", - "log_index": 63, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x3058b17394d11fd8b847e6522a0e6302cddf1bdf", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x3058b17394d11fd8b847e6522a0e6302cddf1bdf", - "sellAmount": "3000000000000000", - "buyAmount": "4255981134", - "validTo": 4294967295, - "appData": "0x9c6c43fadf31b1986d9f427804c8d1793ab065d718ff44241a0ebd26f7da454e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e326a319762", - "app_data_hash": "0x9c6c43fadf31b1986d9f427804c8d1793ab065d718ff44241a0ebd26f7da454e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":559,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000003058b17394d11fd8b847e6522a0e6302cddf1bdf" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000003058b17394d11fd8b847e6522a0e6302cddf1bdf000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000fdad1e4e00000000000000000000000000000000000000000000000000000000ffffffff9c6c43fadf31b1986d9f427804c8d1793ab065d718ff44241a0ebd26f7da454e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e326a3197620000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8cb5b94f996cf5f75a36bb40dd21497f592dd7ae2688c265b9cd6a7bb35f1060ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074421, - "block_timestamp": 1781633532, - "tx_hash": "0x3248302fb48b3e5853fbf23fe241beb55de259c6028fb8ba6b9e8c8f4c7fcccb", - "log_index": 48, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x01accec4273dee321b96274493fbf1d0fc14da07", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x01accec4273dee321b96274493fbf1d0fc14da07", - "sellAmount": "3000000000000000", - "buyAmount": "4285687523", - "validTo": 4294967295, - "appData": "0x8efbe1cde218af879799a3bf34c165c1e512e8efa730d9865f5d66bae271e184", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e376a3198f4", - "app_data_hash": "0x8efbe1cde218af879799a3bf34c165c1e512e8efa730d9865f5d66bae271e184", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":512,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000001accec4273dee321b96274493fbf1d0fc14da07" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000001accec4273dee321b96274493fbf1d0fc14da07000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000ff7266e300000000000000000000000000000000000000000000000000000000ffffffff8efbe1cde218af879799a3bf34c165c1e512e8efa730d9865f5d66bae271e1840000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e376a3198f40000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7545eda04f10f9f6e60e70ee2c695a24b64319b35cccbbe24f2243afaba009a2ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074421, - "block_timestamp": 1781633532, - "tx_hash": "0xc0213bc7504493b448bd4102492358c230f9462d9c279eaa6589a72891bc1fe0", - "log_index": 62, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x01accec4273dee321b96274493fbf1d0fc14da07", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x01accec4273dee321b96274493fbf1d0fc14da07", - "sellAmount": "3000000000000000", - "buyAmount": "394377751", - "validTo": 4294967295, - "appData": "0x7dcb23e48c4c53fb3ac1be3337ed9889d7a94debecede637a00437449b99b1e6", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e386a3198f9", - "app_data_hash": "0x7dcb23e48c4c53fb3ac1be3337ed9889d7a94debecede637a00437449b99b1e6", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":570,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000001accec4273dee321b96274493fbf1d0fc14da07" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000001accec4273dee321b96274493fbf1d0fc14da07000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000001781ba1700000000000000000000000000000000000000000000000000000000ffffffff7dcb23e48c4c53fb3ac1be3337ed9889d7a94debecede637a00437449b99b1e60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e386a3198f90000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6c4472c1f1ec896f9670c1f2c5cad4b8db05f0cf94e1fd9c909c2dab1443049bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074463, - "block_timestamp": 1781634048, - "tx_hash": "0xa01715da5ec992eeb156871e38681e99bb9f0f98e59bf0c27669e0f797ae1805", - "log_index": 131, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x1cb9d55f7741f6bf142235e81058361026951a47", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x1cb9d55f7741f6bf142235e81058361026951a47", - "sellAmount": "3000000000000000", - "buyAmount": "399420481", - "validTo": 4294967295, - "appData": "0x8efbe1cde218af879799a3bf34c165c1e512e8efa730d9865f5d66bae271e184", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e406a319b00", - "app_data_hash": "0x8efbe1cde218af879799a3bf34c165c1e512e8efa730d9865f5d66bae271e184", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":512,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000001cb9d55f7741f6bf142235e81058361026951a47" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000001cb9d55f7741f6bf142235e81058361026951a47000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000017ceac4100000000000000000000000000000000000000000000000000000000ffffffff8efbe1cde218af879799a3bf34c165c1e512e8efa730d9865f5d66bae271e1840000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e406a319b000000000000000000000000000000000000000000" - } - }, - { - "uid": "0x134a3f327191093700c55bb8fd4224724f13aa82dc4842345a46ead829937206ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074464, - "block_timestamp": 1781634060, - "tx_hash": "0xeacde6ac89c7b5ec41c598e97b521e647ac37769d35bf6f2808118c8ca51169a", - "log_index": 24, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x1cb9d55f7741f6bf142235e81058361026951a47", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x1cb9d55f7741f6bf142235e81058361026951a47", - "sellAmount": "3000000000000000", - "buyAmount": "4185399405", - "validTo": 4294967295, - "appData": "0xebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b00", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e446a319b06", - "app_data_hash": "0xebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b00", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":571,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000001cb9d55f7741f6bf142235e81058361026951a47" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000001cb9d55f7741f6bf142235e81058361026951a47000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000f978206d00000000000000000000000000000000000000000000000000000000ffffffffebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b000000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e446a319b060000000000000000000000000000000000000000" - } - }, - { - "uid": "0x625b15a3aca46f2a554c5193a562b2b9e9ddb5034797a1d6d57927f97e54caf4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074482, - "block_timestamp": 1781634276, - "tx_hash": "0x553916439aa28c7725dc72a17e18175461c2a493ca9ae5a20a2aaf225ca6d5b1", - "log_index": 111, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "sellAmount": "100000000000000000", - "buyAmount": "145438939356", - "validTo": 4294967295, - "appData": "0x64786fd7cb86927db36a84de66ec04845f109e9a59df63c9b99e8e6d5d96b665", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e506a319bd0", - "app_data_hash": "0x64786fd7cb86927db36a84de66ec04845f109e9a59df63c9b99e8e6d5d96b665", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000021dcd618dc00000000000000000000000000000000000000000000000000000000ffffffff64786fd7cb86927db36a84de66ec04845f109e9a59df63c9b99e8e6d5d96b6650000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e506a319bd00000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8b981bae262938ffc08acc78770763ba5051597e80bb06b9fc049de7ce0c827dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074491, - "block_timestamp": 1781634408, - "tx_hash": "0xc63d88e3044c6684a485a0bf33b667f1695fdfa3666b8a8a8fa243dded2ed2b4", - "log_index": 23, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xd9a63dc2dd297af4b071f31ba8e0db0a668956af", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xd9a63dc2dd297af4b071f31ba8e0db0a668956af", - "sellAmount": "3000000000000000", - "buyAmount": "3781621823", - "validTo": 4294967295, - "appData": "0xe75988e9d5e0673d8f27a4e51935bc07909377466fbce3f95e9d6cd4437725cf", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e5a6a319c60", - "app_data_hash": "0xe75988e9d5e0673d8f27a4e51935bc07909377466fbce3f95e9d6cd4437725cf", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":546,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000d9a63dc2dd297af4b071f31ba8e0db0a668956af" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000d9a63dc2dd297af4b071f31ba8e0db0a668956af000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000e166f83f00000000000000000000000000000000000000000000000000000000ffffffffe75988e9d5e0673d8f27a4e51935bc07909377466fbce3f95e9d6cd4437725cf0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e5a6a319c600000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2316cffb82684d528238d930c87a44191ddaf412204a6d86a538d1558a853e12ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074491, - "block_timestamp": 1781634408, - "tx_hash": "0xb247df7b9a1a73d30dec3b07496daf6e2c33800cbafbd43600d865d7c56cfb11", - "log_index": 28, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xd9a63dc2dd297af4b071f31ba8e0db0a668956af", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xd9a63dc2dd297af4b071f31ba8e0db0a668956af", - "sellAmount": "3000000000000000", - "buyAmount": "398531375", - "validTo": 4294967295, - "appData": "0x93d8794612b14738b8235e26b806907f0c3890784e626fcad340d5c92acdfb86", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e5c6a319c69", - "app_data_hash": "0x93d8794612b14738b8235e26b806907f0c3890784e626fcad340d5c92acdfb86", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":484,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000d9a63dc2dd297af4b071f31ba8e0db0a668956af" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000d9a63dc2dd297af4b071f31ba8e0db0a668956af000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000017c11b2f00000000000000000000000000000000000000000000000000000000ffffffff93d8794612b14738b8235e26b806907f0c3890784e626fcad340d5c92acdfb860000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e5c6a319c690000000000000000000000000000000000000000" - } - }, - { - "uid": "0x019e8adfe24e656a3c875647bfce3eb1ea39cb9c7b473655b3c962934f4a05c4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11076610, - "block_timestamp": 1781659896, - "tx_hash": "0xca1c1c9b478a5f63d324b34f243fbd1c86decddd704c99694d34a0a221ece0d9", - "log_index": 320, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "4075671767417315423", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721106a31ffe5", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000388fb1e0edff605f00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721106a31ffe50000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe0ccf0ed207a9ec7056975d40c6a32bbc0fb41367409174a285f8329f64adb97ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11076616, - "block_timestamp": 1781659968, - "tx_hash": "0xd9b9622663d0ed38de4617dbfb2a8010526f819ff0196f54c38df7f907f7ff87", - "log_index": 379, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "10000000000000000", - "buyAmount": "13666536297", - "validTo": 4294967295, - "appData": "0x33c67766aa455557799f7599f128b6af57de98de5b41b7bb91662928b414734c", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721186a32003a", - "app_data_hash": "0x33c67766aa455557799f7599f128b6af57de98de5b41b7bb91662928b414734c", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":185,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000002386f26fc10000000000000000000000000000000000000000000000000000000000032e96cb6900000000000000000000000000000000000000000000000000000000ffffffff33c67766aa455557799f7599f128b6af57de98de5b41b7bb91662928b414734c0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721186a32003a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfcfd0fa60f0adaad95cde4f0f06bde80753af7ae3782b1bdf5d5ca52b79d1ebbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11076620, - "block_timestamp": 1781660016, - "tx_hash": "0x5ca964ec1c27d8eb6fc47e92c0a0b8c9184143def28dc00d50d455eada7de804", - "log_index": 545, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "40264034856", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017211a6a320066", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000095fec6a2800000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017211a6a3200660000000000000000000000000000000000000000" - } - }, - { - "uid": "0x9fabbf086cf89b990f836f12bb3045204480fb15e679e82b3b93529bde3f608cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11077214, - "block_timestamp": 1781667204, - "tx_hash": "0x5d27e4c6b1aaa3b3ab28a398bb99f062cef0a1527f2ac368d49f2396555315ba", - "log_index": 87, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "57760306069", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017213c6a321c74", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000d72c8539500000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017213c6a321c740000000000000000000000000000000000000000" - } - }, - { - "uid": "0x84fd93424f3bb11e6fa5c0cfb45dfc171fc2b91888139af480507ec3a22e30c9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11077272, - "block_timestamp": 1781667900, - "tx_hash": "0x0e12337ffc385d2c5ca063e4a562104734b6f2012e3ceb6b624f412cc64725df", - "log_index": 342, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x2df787aee880af0be17f2932057cca2ad6dd8478", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xea1fed52a9b161f9ebfe58892699896cb9d0cd44", - "receiver": "0x2df787aee880af0be17f2932057cca2ad6dd8478", - "sellAmount": "30000000000000000", - "buyAmount": "9812058475546634260", - "validTo": 4294967295, - "appData": "0xea1df97da9521d0a0f14fcec6d1d943e3f1115c3fc859ccda948b81d8407d82c", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721506a321f3b", - "app_data_hash": "0xea1df97da9521d0a0f14fcec6d1d943e3f1115c3fc859ccda948b81d8407d82c", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":96,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000002df787aee880af0be17f2932057cca2ad6dd8478" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000ea1fed52a9b161f9ebfe58892699896cb9d0cd440000000000000000000000002df787aee880af0be17f2932057cca2ad6dd8478000000000000000000000000000000000000000000000000006a94d74f430000000000000000000000000000000000000000000000000000882b6f326e51681400000000000000000000000000000000000000000000000000000000ffffffffea1df97da9521d0a0f14fcec6d1d943e3f1115c3fc859ccda948b81d8407d82c0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721506a321f3b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa1b4b41b8c1eb2a6ad6e9e92f3ba02b9f82230cebb8847eba84ec7745866af23ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078078, - "block_timestamp": 1781677608, - "tx_hash": "0x2e8952212c643719fba43eacb29c337e4d1b9ce2f5ba8aaf5fb2cdcf2354b587", - "log_index": 74, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xbbc940b8d3dd0977826162a2fccfdc4a227a0a5d", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xbbc940b8d3dd0977826162a2fccfdc4a227a0a5d", - "sellAmount": "3000000000000000", - "buyAmount": "1973590925", - "validTo": 4294967295, - "appData": "0x428c17d596b03fb06511fd6fb1b55cb23ed53df5dddba2f923f1ee7b6fc831fe", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721da6a324520", - "app_data_hash": "0x428c17d596b03fb06511fd6fb1b55cb23ed53df5dddba2f923f1ee7b6fc831fe", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":594,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000bbc940b8d3dd0977826162a2fccfdc4a227a0a5d" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000bbc940b8d3dd0977826162a2fccfdc4a227a0a5d000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000075a29b8d00000000000000000000000000000000000000000000000000000000ffffffff428c17d596b03fb06511fd6fb1b55cb23ed53df5dddba2f923f1ee7b6fc831fe0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721da6a3245200000000000000000000000000000000000000000" - } - }, - { - "uid": "0x9e4ceb196fa1eda2e7b8e25e352c1362b56db63bf51b302ea0b277d2348f78c1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078078, - "block_timestamp": 1781677608, - "tx_hash": "0x62ba57495464e98e89103793a4a9777810d379f4ecb4065afce66e7227c7e9ad", - "log_index": 91, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xbbc940b8d3dd0977826162a2fccfdc4a227a0a5d", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xbbc940b8d3dd0977826162a2fccfdc4a227a0a5d", - "sellAmount": "3000000000000000", - "buyAmount": "2880361707", - "validTo": 4294967295, - "appData": "0xbc67ff39d75c38fe641f5941776987ec1564e3ba04e6266f1cfb6093521247ff", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721dc6a324525", - "app_data_hash": "0xbc67ff39d75c38fe641f5941776987ec1564e3ba04e6266f1cfb6093521247ff", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":580,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000bbc940b8d3dd0977826162a2fccfdc4a227a0a5d" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000bbc940b8d3dd0977826162a2fccfdc4a227a0a5d000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000abaed4eb00000000000000000000000000000000000000000000000000000000ffffffffbc67ff39d75c38fe641f5941776987ec1564e3ba04e6266f1cfb6093521247ff0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721dc6a3245250000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd6eaa1a916db337bb31afab19c2e5c0453e184fe3dbc58911aa70f6d9074cb1bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078093, - "block_timestamp": 1781677788, - "tx_hash": "0xb8a71cf1e0cb8ad941b9fad9a68a6e2b91f75cb554475e2e69b8b0d435d09c1e", - "log_index": 204, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x286aa21ff4b93e33c13bc84ea6b9de4e16bece96", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x286aa21ff4b93e33c13bc84ea6b9de4e16bece96", - "sellAmount": "3000000000000000", - "buyAmount": "1937029268", - "validTo": 4294967295, - "appData": "0x6a67eef03a9bcc727fa1b71890242c08e5baceb53c18e0c766491741458cbc2d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721de6a3245cd", - "app_data_hash": "0x6a67eef03a9bcc727fa1b71890242c08e5baceb53c18e0c766491741458cbc2d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":549,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000286aa21ff4b93e33c13bc84ea6b9de4e16bece96" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000286aa21ff4b93e33c13bc84ea6b9de4e16bece96000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000007374b89400000000000000000000000000000000000000000000000000000000ffffffff6a67eef03a9bcc727fa1b71890242c08e5baceb53c18e0c766491741458cbc2d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721de6a3245cd0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x819ff1ecc26eaffc98c594c1d38562def85683e76880e71f213489fb9098f645ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078093, - "block_timestamp": 1781677788, - "tx_hash": "0xa9f630ad3530584fd6f6343f7d606a9dea6500232b96dbd4ddb1ccb81096da5c", - "log_index": 223, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x286aa21ff4b93e33c13bc84ea6b9de4e16bece96", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x286aa21ff4b93e33c13bc84ea6b9de4e16bece96", - "sellAmount": "3000000000000000", - "buyAmount": "2904285905", - "validTo": 4294967295, - "appData": "0x968a7cae388675d1f5f150e0c33f138a376cf2297c0e164ca4c2fe28fd6aafd8", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721e06a3245d3", - "app_data_hash": "0x968a7cae388675d1f5f150e0c33f138a376cf2297c0e164ca4c2fe28fd6aafd8", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":532,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000286aa21ff4b93e33c13bc84ea6b9de4e16bece96" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000286aa21ff4b93e33c13bc84ea6b9de4e16bece96000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000ad1be2d100000000000000000000000000000000000000000000000000000000ffffffff968a7cae388675d1f5f150e0c33f138a376cf2297c0e164ca4c2fe28fd6aafd80000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721e06a3245d30000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa06aafbb034441dd197d8f108b44b36d79f497b48fff636d35c2f6f80c379e13ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078113, - "block_timestamp": 1781678028, - "tx_hash": "0x8ccb0869e7cb4cd6b95658cbb940b7a2de69d8315ccaed608a72aeb7f59de716", - "log_index": 122, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xb3d192970a8c51730ae5d44f531b9a20b1d728ee", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xb3d192970a8c51730ae5d44f531b9a20b1d728ee", - "sellAmount": "3000000000000000", - "buyAmount": "1861609585", - "validTo": 4294967295, - "appData": "0x12042ecdcc200a4f5c5c27fe083d247b7e8fa2d7466fb7efebcb8ca38c4d7c0d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721e16a3246cd", - "app_data_hash": "0x12042ecdcc200a4f5c5c27fe083d247b7e8fa2d7466fb7efebcb8ca38c4d7c0d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":579,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000b3d192970a8c51730ae5d44f531b9a20b1d728ee" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000b3d192970a8c51730ae5d44f531b9a20b1d728ee000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000006ef5e87100000000000000000000000000000000000000000000000000000000ffffffff12042ecdcc200a4f5c5c27fe083d247b7e8fa2d7466fb7efebcb8ca38c4d7c0d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721e16a3246cd0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd6f7b83bd69ab27363b100cb8aafe9e67dc9a61d10b8f8958920462a6a8d37beba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078114, - "block_timestamp": 1781678040, - "tx_hash": "0xee8091df6a252e533f7cb3925d210d96b3c0a53d36978df1c9bab698cb432bd9", - "log_index": 127, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xb3d192970a8c51730ae5d44f531b9a20b1d728ee", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xb3d192970a8c51730ae5d44f531b9a20b1d728ee", - "sellAmount": "3000000000000000", - "buyAmount": "2846117696", - "validTo": 4294967295, - "appData": "0xb22ff493417778724e9ff06a60a79c083ca0f66f940f21b5ef81b678602b5cdb", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721e46a3246d1", - "app_data_hash": "0xb22ff493417778724e9ff06a60a79c083ca0f66f940f21b5ef81b678602b5cdb", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":584,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000b3d192970a8c51730ae5d44f531b9a20b1d728ee" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000b3d192970a8c51730ae5d44f531b9a20b1d728ee000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000a9a44f4000000000000000000000000000000000000000000000000000000000ffffffffb22ff493417778724e9ff06a60a79c083ca0f66f940f21b5ef81b678602b5cdb0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721e46a3246d10000000000000000000000000000000000000000" - } - }, - { - "uid": "0x1ac7b66129618244e058e56e4ca1b6a3b01a6d379578de9666bbf2f6b4c511c6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078214, - "block_timestamp": 1781679240, - "tx_hash": "0x76a9ad1b9ef807ef129d98f36cc92c14a72b8455cba25b25ad7db379753f4b2e", - "log_index": 329, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8793a879cf1837f4fbcce3b425ae43770675c12a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x8793a879cf1837f4fbcce3b425ae43770675c12a", - "sellAmount": "3000000000000000", - "buyAmount": "1806672021", - "validTo": 4294967295, - "appData": "0x4471fb789d7020b95b47335493fc5875eaab582010c67ad768ac54aa5335ef3d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722036a324b88", - "app_data_hash": "0x4471fb789d7020b95b47335493fc5875eaab582010c67ad768ac54aa5335ef3d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":576,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008793a879cf1837f4fbcce3b425ae43770675c12a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000008793a879cf1837f4fbcce3b425ae43770675c12a000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000006bafa09500000000000000000000000000000000000000000000000000000000ffffffff4471fb789d7020b95b47335493fc5875eaab582010c67ad768ac54aa5335ef3d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722036a324b880000000000000000000000000000000000000000" - } - }, - { - "uid": "0x9fc72d42b02063d69f315e0c9c91fee8d419c48d9e517000ba2c2fe0bd24984eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078215, - "block_timestamp": 1781679252, - "tx_hash": "0x9e3d8da305b02537732c69307c9c12d9d8283e13e563dfb12cded4732d2886de", - "log_index": 95, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8793a879cf1837f4fbcce3b425ae43770675c12a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x8793a879cf1837f4fbcce3b425ae43770675c12a", - "sellAmount": "3000000000000000", - "buyAmount": "1802015172", - "validTo": 4294967295, - "appData": "0x22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722066a324b8c", - "app_data_hash": "0x22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":585,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008793a879cf1837f4fbcce3b425ae43770675c12a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000008793a879cf1837f4fbcce3b425ae43770675c12a000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000006b6891c400000000000000000000000000000000000000000000000000000000ffffffff22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722066a324b8c0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x642b789047e9b58bd9ad98954b7b61d417f02570133aadc7bfa118078c822006ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078225, - "block_timestamp": 1781679372, - "tx_hash": "0x90c34437e7bf6aa710b0c65f5d84017cd65f88f096e71b9a361d9deb7cf1c987", - "log_index": 286, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8793a879cf1837f4fbcce3b425ae43770675c12a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x8793a879cf1837f4fbcce3b425ae43770675c12a", - "sellAmount": "3000000000000000", - "buyAmount": "1713858257", - "validTo": 4294967295, - "appData": "0x66f44ac5c45ea652896294c758fe91dae7bf0be80ec9e9f88d94a0f622a803d7", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017220c6a324c0a", - "app_data_hash": "0x66f44ac5c45ea652896294c758fe91dae7bf0be80ec9e9f88d94a0f622a803d7", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":604,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008793a879cf1837f4fbcce3b425ae43770675c12a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000008793a879cf1837f4fbcce3b425ae43770675c12a000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000662766d100000000000000000000000000000000000000000000000000000000ffffffff66f44ac5c45ea652896294c758fe91dae7bf0be80ec9e9f88d94a0f622a803d70000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017220c6a324c0a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7cf68a7b25d6919a491646853e179998e367906ba931e575eb9bf6389b812b40ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078246, - "block_timestamp": 1781679624, - "tx_hash": "0x54ca0b8ff9dd19bacf3f1e6fda073b1c08e11469731ec10a37ad04b572cc02b2", - "log_index": 220, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9b76327a91233c2bfb0db80ed7c3d1e5d686fd4f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x9b76327a91233c2bfb0db80ed7c3d1e5d686fd4f", - "sellAmount": "3000000000000000", - "buyAmount": "1677560640", - "validTo": 4294967295, - "appData": "0x9e292b2dda2ec1021300ef3a65fad2a4390949f5c50663601ad1950524a4231e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722156a324d07", - "app_data_hash": "0x9e292b2dda2ec1021300ef3a65fad2a4390949f5c50663601ad1950524a4231e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":591,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009b76327a91233c2bfb0db80ed7c3d1e5d686fd4f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000009b76327a91233c2bfb0db80ed7c3d1e5d686fd4f000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000063fd8b4000000000000000000000000000000000000000000000000000000000ffffffff9e292b2dda2ec1021300ef3a65fad2a4390949f5c50663601ad1950524a4231e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722156a324d070000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb6e107519acfd3f9d355ad94bc1da521458151b63b736a66200e8423a07d8823ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078248, - "block_timestamp": 1781679648, - "tx_hash": "0x19dfe5925fc9edd56df2cafbbe016d2d08b16e3c94fdbb424f4cd3e086a48bb4", - "log_index": 119, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9b76327a91233c2bfb0db80ed7c3d1e5d686fd4f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x9b76327a91233c2bfb0db80ed7c3d1e5d686fd4f", - "sellAmount": "3000000000000000", - "buyAmount": "1665090544", - "validTo": 4294967295, - "appData": "0x89c3981b1a4b6fbe1c23150f59402fe5d03c26d463794d5050b35adcd88875fb", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722176a324d16", - "app_data_hash": "0x89c3981b1a4b6fbe1c23150f59402fe5d03c26d463794d5050b35adcd88875fb", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":582,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009b76327a91233c2bfb0db80ed7c3d1e5d686fd4f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000009b76327a91233c2bfb0db80ed7c3d1e5d686fd4f000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000633f43f000000000000000000000000000000000000000000000000000000000ffffffff89c3981b1a4b6fbe1c23150f59402fe5d03c26d463794d5050b35adcd88875fb0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722176a324d160000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd3c38d90f5bfda4ecc0706bb3d5ec58bd83dc8c1183e1f8d3b785fc1c4b1f28fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078475, - "block_timestamp": 1781682372, - "tx_hash": "0xc544c004b5e9cc924a8777a036d8180860990259be24a0eefb66e80484200eab", - "log_index": 178, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7318c432e852e54f556a3c24d573d877347a91e2", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x7318c432e852e54f556a3c24d573d877347a91e2", - "sellAmount": "3000000000000000", - "buyAmount": "1629903612", - "validTo": 4294967295, - "appData": "0x69f7347567bdbbf825055baac8c17899e0a446a9c80d8aab01c9a6342ee306e9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722726a3257b9", - "app_data_hash": "0x69f7347567bdbbf825055baac8c17899e0a446a9c80d8aab01c9a6342ee306e9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":590,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007318c432e852e54f556a3c24d573d877347a91e2" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000007318c432e852e54f556a3c24d573d877347a91e2000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000061265afc00000000000000000000000000000000000000000000000000000000ffffffff69f7347567bdbbf825055baac8c17899e0a446a9c80d8aab01c9a6342ee306e90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722726a3257b90000000000000000000000000000000000000000" - } - }, - { - "uid": "0x22e93a3a93880467b6ba74a041673b43573bbd19febaf9ad26946058b09a7f60ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078475, - "block_timestamp": 1781682372, - "tx_hash": "0x7c9618335193d4141c583516bf1caf3156b9cc39018aee1a3dc029091f74fccb", - "log_index": 205, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7318c432e852e54f556a3c24d573d877347a91e2", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x7318c432e852e54f556a3c24d573d877347a91e2", - "sellAmount": "3000000000000000", - "buyAmount": "3224087935", - "validTo": 4294967295, - "appData": "0xb22ff493417778724e9ff06a60a79c083ca0f66f940f21b5ef81b678602b5cdb", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722756a3257bf", - "app_data_hash": "0xb22ff493417778724e9ff06a60a79c083ca0f66f940f21b5ef81b678602b5cdb", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":584,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007318c432e852e54f556a3c24d573d877347a91e2" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000007318c432e852e54f556a3c24d573d877347a91e2000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000c02bad7f00000000000000000000000000000000000000000000000000000000ffffffffb22ff493417778724e9ff06a60a79c083ca0f66f940f21b5ef81b678602b5cdb0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722756a3257bf0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5e60eb4ec8c8634eae23207d887a490cd99f7daafac55ef87e677135eee4feffba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078500, - "block_timestamp": 1781682672, - "tx_hash": "0x52763b60a0243c7d57e95fb13fa51e7aeab8df8d75846175d980aa59b67c1c86", - "log_index": 310, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x69d8c2cd892c06eb5409a331e3770fcdeca1c643", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x69d8c2cd892c06eb5409a331e3770fcdeca1c643", - "sellAmount": "3000000000000000", - "buyAmount": "1589860466", - "validTo": 4294967295, - "appData": "0x4471fb789d7020b95b47335493fc5875eaab582010c67ad768ac54aa5335ef3d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722836a3258ef", - "app_data_hash": "0x4471fb789d7020b95b47335493fc5875eaab582010c67ad768ac54aa5335ef3d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":576,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000069d8c2cd892c06eb5409a331e3770fcdeca1c643" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000069d8c2cd892c06eb5409a331e3770fcdeca1c643000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000005ec3587200000000000000000000000000000000000000000000000000000000ffffffff4471fb789d7020b95b47335493fc5875eaab582010c67ad768ac54aa5335ef3d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722836a3258ef0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xaf138bc274dcd24ebb344cc26bfca6ed4e21e560d4b1d2ef7d3116b8b2dca484ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078501, - "block_timestamp": 1781682684, - "tx_hash": "0xf358d26d04adb90d4337e62a891695b7633e677606af07fbed4b4dbdde546c3a", - "log_index": 74, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x69d8c2cd892c06eb5409a331e3770fcdeca1c643", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x69d8c2cd892c06eb5409a331e3770fcdeca1c643", - "sellAmount": "3000000000000000", - "buyAmount": "3274149785", - "validTo": 4294967295, - "appData": "0x0d0878cfe0cb1d84a860e63bc771ff4fd72ffa61b1f4f11aee18dccff7c9238c", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722866a3258f5", - "app_data_hash": "0x0d0878cfe0cb1d84a860e63bc771ff4fd72ffa61b1f4f11aee18dccff7c9238c", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":510,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000069d8c2cd892c06eb5409a331e3770fcdeca1c643" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000069d8c2cd892c06eb5409a331e3770fcdeca1c643000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000c3278f9900000000000000000000000000000000000000000000000000000000ffffffff0d0878cfe0cb1d84a860e63bc771ff4fd72ffa61b1f4f11aee18dccff7c9238c0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722866a3258f50000000000000000000000000000000000000000" - } - }, - { - "uid": "0xcbb6226fc692ce61536d4a8b476507cc5e599af79e47d5ff1c36f3c949d6d01aba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078504, - "block_timestamp": 1781682720, - "tx_hash": "0x2f7a6a91b452e55c85e00f06dd34d9f28ddba5e89f7f006320d222c5c15132e0", - "log_index": 155, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "41246159593", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017228a6a325918", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000099a7672e900000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017228a6a3259180000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7bc92f460730baa66148ad9fc664a83a2643a1a78e1d24ca0fe33e6fcf83f673ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078513, - "block_timestamp": 1781682828, - "tx_hash": "0x2475e04a4c60f874a0487d8ee105019e240928b0446758f40deef2dfded111bb", - "log_index": 111, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "59005507112", - "validTo": 4294967295, - "appData": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722946a32597e", - "app_data_hash": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":76,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000000dbd00962800000000000000000000000000000000000000000000000000000000ffffffff4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722946a32597e0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7bf72b311bf26ecccef2e2774197b37e9db11c213e96eaccf9b6027b2fe73436ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078520, - "block_timestamp": 1781682912, - "tx_hash": "0xb0f58210cc6e64b4f8adb77dc71c488514a0b8410e863b0a3d7ff464ad3760e3", - "log_index": 160, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xa4985ecaeeac7ce1699134866e4a2b50e2f12685", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xa4985ecaeeac7ce1699134866e4a2b50e2f12685", - "sellAmount": "3000000000000000", - "buyAmount": "725974732", - "validTo": 4294967295, - "appData": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017229a6a3259da", - "app_data_hash": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":526,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000a4985ecaeeac7ce1699134866e4a2b50e2f12685" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000a4985ecaeeac7ce1699134866e4a2b50e2f12685000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000002b457ecc00000000000000000000000000000000000000000000000000000000fffffffff802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017229a6a3259da0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x9dd59d5b617a0f85e92c8e5b5255f8e6286c3894a6eff9d8a2bc5f29cfd2e727ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078520, - "block_timestamp": 1781682912, - "tx_hash": "0x712e4a950e65805e5f850c808e02972fe6e7e298628319e8bc042a7e5563b439", - "log_index": 190, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xa4985ecaeeac7ce1699134866e4a2b50e2f12685", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xa4985ecaeeac7ce1699134866e4a2b50e2f12685", - "sellAmount": "3000000000000000", - "buyAmount": "3083963260", - "validTo": 4294967295, - "appData": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017229d6a3259de", - "app_data_hash": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":526,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000a4985ecaeeac7ce1699134866e4a2b50e2f12685" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000a4985ecaeeac7ce1699134866e4a2b50e2f12685000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000b7d18b7c00000000000000000000000000000000000000000000000000000000fffffffff802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017229d6a3259de0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8f8bed5037a46ac5e39d697bba6711a1846ccd99699dead3837c2f81723ffb8eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078539, - "block_timestamp": 1781683140, - "tx_hash": "0x83db9ce3e57ec98fb6470d0cbd89ac9ffa01e546768bbdcc1d11cbc532e49762", - "log_index": 333, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xafe4ea682110dff6ee48201de034a8b7fdd169b0", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xafe4ea682110dff6ee48201de034a8b7fdd169b0", - "sellAmount": "3000000000000000", - "buyAmount": "3024882930", - "validTo": 4294967295, - "appData": "0x807a98eb13c80d8fb87232d02869d3379317bb3b2cd5705cdce021e772c50174", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722a86a325ab9", - "app_data_hash": "0x807a98eb13c80d8fb87232d02869d3379317bb3b2cd5705cdce021e772c50174", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":574,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000afe4ea682110dff6ee48201de034a8b7fdd169b0" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000afe4ea682110dff6ee48201de034a8b7fdd169b0000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000b44c0cf200000000000000000000000000000000000000000000000000000000ffffffff807a98eb13c80d8fb87232d02869d3379317bb3b2cd5705cdce021e772c501740000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722a86a325ab90000000000000000000000000000000000000000" - } - }, - { - "uid": "0x4e04244d9d111f42f03662ecb92c2e96412b2f1e20dc1c87ff603648dbd0adafba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078539, - "block_timestamp": 1781683140, - "tx_hash": "0xc0168df1c0cd691053c93282a4c78d08d0d3c3c664f23eedeb7ea75dc87ca3df", - "log_index": 334, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xafe4ea682110dff6ee48201de034a8b7fdd169b0", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xafe4ea682110dff6ee48201de034a8b7fdd169b0", - "sellAmount": "3000000000000000", - "buyAmount": "701122877", - "validTo": 4294967295, - "appData": "0x988d20d00f6b54fde4e733511691a245013e9a2f280d89db797e1f7c1bcc84e9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722a66a325abd", - "app_data_hash": "0x988d20d00f6b54fde4e733511691a245013e9a2f280d89db797e1f7c1bcc84e9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":581,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000afe4ea682110dff6ee48201de034a8b7fdd169b0" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000afe4ea682110dff6ee48201de034a8b7fdd169b0000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000029ca493d00000000000000000000000000000000000000000000000000000000ffffffff988d20d00f6b54fde4e733511691a245013e9a2f280d89db797e1f7c1bcc84e90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722a66a325abd0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe16973fad0fdf99f45b98aa36216c7e05429ae0cd004714c578faee349f6e8a1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078557, - "block_timestamp": 1781683356, - "tx_hash": "0x15708d8fab4d1c10d5105554b626543780770aa4abec801a3a7916a4d42be37c", - "log_index": 169, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x3991dcb23bb280199d6e8a4c9213dbc53ef2eec8", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x3991dcb23bb280199d6e8a4c9213dbc53ef2eec8", - "sellAmount": "3000000000000000", - "buyAmount": "688532675", - "validTo": 4294967295, - "appData": "0x8ecf1d25087f7c3e9f3ecf8d6775685423e4d97c011de685ac548625c3304054", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722b26a325b98", - "app_data_hash": "0x8ecf1d25087f7c3e9f3ecf8d6775685423e4d97c011de685ac548625c3304054", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":578,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000003991dcb23bb280199d6e8a4c9213dbc53ef2eec8" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000003991dcb23bb280199d6e8a4c9213dbc53ef2eec8000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000290a2cc300000000000000000000000000000000000000000000000000000000ffffffff8ecf1d25087f7c3e9f3ecf8d6775685423e4d97c011de685ac548625c33040540000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722b26a325b980000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfb362d5897ea57c89a0e5f951eea05d07f79007bb61c866413e50a98b2fdb81cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078558, - "block_timestamp": 1781683368, - "tx_hash": "0x55e2a87d668cc6fd0b111214045b9179a22e3b750c2c911ee3b8dda0f56ffd13", - "log_index": 149, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x3991dcb23bb280199d6e8a4c9213dbc53ef2eec8", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x3991dcb23bb280199d6e8a4c9213dbc53ef2eec8", - "sellAmount": "3000000000000000", - "buyAmount": "3007883750", - "validTo": 4294967295, - "appData": "0x807a98eb13c80d8fb87232d02869d3379317bb3b2cd5705cdce021e772c50174", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722b46a325ba2", - "app_data_hash": "0x807a98eb13c80d8fb87232d02869d3379317bb3b2cd5705cdce021e772c50174", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":574,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000003991dcb23bb280199d6e8a4c9213dbc53ef2eec8" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000003991dcb23bb280199d6e8a4c9213dbc53ef2eec8000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000b348a9e600000000000000000000000000000000000000000000000000000000ffffffff807a98eb13c80d8fb87232d02869d3379317bb3b2cd5705cdce021e772c501740000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722b46a325ba20000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6068b9d9b98f94a5a2bd03cb537a33da151f458bcb52d36145cbb08ccdffb629ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11080029, - "block_timestamp": 1781701044, - "tx_hash": "0xd565905883d55116a8ef30e46991af0d454f3843b1a0fe39d1632db71e2c48a6", - "log_index": 30, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xd4759b15b0ec5eee3a01a0dfd3e8b70504adb868", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xd4759b15b0ec5eee3a01a0dfd3e8b70504adb868", - "sellAmount": "50000000000000000", - "buyAmount": "22121513287251100262", - "validTo": 4294967295, - "appData": "0xf2f62ebcae67d9c6dc72a69a6e5f5cf72f8555bd28bca1235d2da45a79fbd08c", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001725b06a32a0a1", - "app_data_hash": "0xf2f62ebcae67d9c6dc72a69a6e5f5cf72f8555bd28bca1235d2da45a79fbd08c", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"staging\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":76,\"smartSlippage\":true},\"referrer\":{\"code\":\"MOO-MOO\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000d4759b15b0ec5eee3a01a0dfd3e8b70504adb868" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000d4759b15b0ec5eee3a01a0dfd3e8b70504adb86800000000000000000000000000000000000000000000000000b1a2bc2ec5000000000000000000000000000000000000000000000000000132ff672144af826600000000000000000000000000000000000000000000000000000000fffffffff2f62ebcae67d9c6dc72a69a6e5f5cf72f8555bd28bca1235d2da45a79fbd08c0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001725b06a32a0a10000000000000000000000000000000000000000" - } - }, - { - "uid": "0x299fe6889920b4869c0fa99684b15a8c8c8bf657c92fe0743b7bec5ed45e956fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11080365, - "block_timestamp": 1781705076, - "tx_hash": "0x800c92d50f62883551f497d89a24c3ade95ede801b70102baf4888f06c94bf5a", - "log_index": 46, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x541e08e9533938e545677443987adecba2f4cf1c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x541e08e9533938e545677443987adecba2f4cf1c", - "sellAmount": "10000000000000000", - "buyAmount": "4279758090521596071", - "validTo": 4294967295, - "appData": "0x62107cf706bdba60dcd54b74b55984b42df43217a7b67e6dc2766c905ac4549f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001726506a32b060", - "app_data_hash": "0x62107cf706bdba60dcd54b74b55984b42df43217a7b67e6dc2766c905ac4549f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":187,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000541e08e9533938e545677443987adecba2f4cf1c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000541e08e9533938e545677443987adecba2f4cf1c000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000003b64c14ee624d0a700000000000000000000000000000000000000000000000000000000ffffffff62107cf706bdba60dcd54b74b55984b42df43217a7b67e6dc2766c905ac4549f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001726506a32b0600000000000000000000000000000000000000000" - } - }, - { - "uid": "0xbdbb9773fa98d64748954d7da6fed4b17a9fb880f6a430716e949994e8b1e341ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11082360, - "block_timestamp": 1781729064, - "tx_hash": "0x257de5b5711c867fbcd11923669aac4b4f01399e31ce618ddab22ce65d42dd9a", - "log_index": 173, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xc621c5a6c8f2ee120c11f1bd016029adbddf54cf", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xc621c5a6c8f2ee120c11f1bd016029adbddf54cf", - "sellAmount": "8000000000000000", - "buyAmount": "3386366020898494818", - "validTo": 4294967295, - "appData": "0xbae8167f069d0dacca9214d001978e606da297fb428cdd914a35c6a28b6574d7", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017288e6a330e27", - "app_data_hash": "0xbae8167f069d0dacca9214d001978e606da297fb428cdd914a35c6a28b6574d7", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":221,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000c621c5a6c8f2ee120c11f1bd016029adbddf54cf" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000c621c5a6c8f2ee120c11f1bd016029adbddf54cf000000000000000000000000000000000000000000000000001c6bf5263400000000000000000000000000000000000000000000000000002efec9f44b1b996200000000000000000000000000000000000000000000000000000000ffffffffbae8167f069d0dacca9214d001978e606da297fb428cdd914a35c6a28b6574d70000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017288e6a330e270000000000000000000000000000000000000000" - } - }, - { - "uid": "0xbaedfe1cf121202595305d8153b7851cb21239c4c0af9f0c6cb796599702f742ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11083644, - "block_timestamp": 1781744496, - "tx_hash": "0xd1f370c2b0e76bf01e07b59ab6fffe15bb97175a4b2e343126bf54aa58344c3e", - "log_index": 341, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xe8b4765048c65da747f5262527b5864c98496527", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0xe8b4765048c65da747f5262527b5864c98496527", - "sellAmount": "60000000000000000", - "buyAmount": "2430985366936195654", - "validTo": 4294967295, - "appData": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729416a334a64", - "app_data_hash": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":74,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000e8b4765048c65da747f5262527b5864c98496527" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000e8b4765048c65da747f5262527b5864c9849652700000000000000000000000000000000000000000000000000d529ae9e86000000000000000000000000000000000000000000000000000021bc984fb267924600000000000000000000000000000000000000000000000000000000ffffffff8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d40000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729416a334a640000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8194545de1ed8e4bea8e13ec108ac10958e49d138675ae53f19b94e30442f3f9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11083764, - "block_timestamp": 1781745936, - "tx_hash": "0x4a9aec4729167c4eb8d5c125a689978d8af1bbfabbe66ba225d69b917c3b6f96", - "log_index": 567, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "sellAmount": "1000000000000000000", - "buyAmount": "59808168179", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729626a335001", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000decd838f300000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729626a3350010000000000000000000000000000000000000000" - } - }, - { - "uid": "0x9c92f5f35cff463b928341d3f96745bfc434e921acd078c32715e99a46c44bfcba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11083770, - "block_timestamp": 1781746008, - "tx_hash": "0x096319cec0abf649c7d4bc708b4b929f42617a6ea5ce2c87ce7832a03207ccd8", - "log_index": 553, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "sellAmount": "1000000000000000000", - "buyAmount": "482693023980", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729646a33504c", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000007062bf08ec00000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729646a33504c0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x98906b976da3cee661495836c7d362cbb406c90384fc606ae854192ede25fc5aba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11083812, - "block_timestamp": 1781746512, - "tx_hash": "0xa3a701f2fa97a3a1ac7f0d1a4b73fc1f8f21eef66def8db7c86572b8952e24e2", - "log_index": 618, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "10000000000000000", - "buyAmount": "392870645898924217", - "validTo": 4294967295, - "appData": "0x1b34a38083107c63bff5fc8fb6e02d487b9ef1e82d3f562866b6969f1bf22434", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017296f6a33523e", - "app_data_hash": "0x1b34a38083107c63bff5fc8fb6e02d487b9ef1e82d3f562866b6969f1bf22434", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":179,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000573c1c55b7bd0b900000000000000000000000000000000000000000000000000000000ffffffff1b34a38083107c63bff5fc8fb6e02d487b9ef1e82d3f562866b6969f1bf224340000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017296f6a33523e0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x627afacc9f40e3a7143c66693a6280e464f04829aacdad154583ac24dc42b7d5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11083859, - "block_timestamp": 1781747076, - "tx_hash": "0xbb06194793467873c855276424876939056dd463c7e7f5ea66c303a9a2a3f798", - "log_index": 237, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "600000000000000000", - "buyAmount": "27097688068", - "validTo": 4294967295, - "appData": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729906a33546a", - "app_data_hash": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":52,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da55329700000000000000000000000000000000000000000000000000853a0d2313c0000000000000000000000000000000000000000000000000000000000064f25e80400000000000000000000000000000000000000000000000000000000ffffffffacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce5190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729906a33546a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x240bcbdecd532fc60cc12e01d6d72321b5de48823fed5a48dd9708e6286fd6b7ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11083866, - "block_timestamp": 1781747160, - "tx_hash": "0x579e3d9fdac624f3716df8ce30714ac606b237370cc939d3b409bbf7a8acdbf8", - "log_index": 534, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "600000000000000000", - "buyAmount": "266488304834628180915", - "validTo": 4294967295, - "appData": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729916a3354c5", - "app_data_hash": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":52,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da55329700000000000000000000000000000000000000000000000000853a0d2313c000000000000000000000000000000000000000000000000000e7244a554e0065bb300000000000000000000000000000000000000000000000000000000ffffffffacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce5190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729916a3354c50000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2559867e882308cc78ef30ba8e42ffd9dcd59b0b0466fe56baae8d1745169982ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11084049, - "block_timestamp": 1781749380, - "tx_hash": "0xf9870f02e64238b8c76b8433a5a2391646c443f29c4049f0822bc518ee39c7af", - "log_index": 455, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xa634030a2603a0d70b7cddf6cc9ad90d93f6db50", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xa634030a2603a0d70b7cddf6cc9ad90d93f6db50", - "sellAmount": "100000000000000000", - "buyAmount": "1407457410", - "validTo": 4294967295, - "appData": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729c06a335d7e", - "app_data_hash": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":64,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000a634030a2603a0d70b7cddf6cc9ad90d93f6db50" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000a634030a2603a0d70b7cddf6cc9ad90d93f6db50000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000053e4188200000000000000000000000000000000000000000000000000000000ffffffff4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729c06a335d7e0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7ce8d855b0977b0120d6adcbdd368bd25ec2bbd015cbf37081bd579f3617c63cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11084079, - "block_timestamp": 1781749740, - "tx_hash": "0x8add8e612d0cc934b909be70c0a585368ba2bb1d32ad9ffcad151dc3e5e7459b", - "log_index": 629, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xeffd3447e89e0e09ddc1a786c2b5466dd8eec96d", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xa9e354e04c305a5de11e036025dbe63451dae888", - "receiver": "0xeffd3447e89e0e09ddc1a786c2b5466dd8eec96d", - "sellAmount": "10000000000000000", - "buyAmount": "47251907151317040570", - "validTo": 4294967295, - "appData": "0x86415e2f21c581ae60cccdebb3967d50809cfcbd7d5cc69cc86eea2bcf083861", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729ca6a335ed1", - "app_data_hash": "0x86415e2f21c581ae60cccdebb3967d50809cfcbd7d5cc69cc86eea2bcf083861", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":217,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000effd3447e89e0e09ddc1a786c2b5466dd8eec96d" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000a9e354e04c305a5de11e036025dbe63451dae888000000000000000000000000effd3447e89e0e09ddc1a786c2b5466dd8eec96d000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000028fc07f33e9fdfdba00000000000000000000000000000000000000000000000000000000ffffffff86415e2f21c581ae60cccdebb3967d50809cfcbd7d5cc69cc86eea2bcf0838610000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729ca6a335ed10000000000000000000000000000000000000000" - } - }, - { - "uid": "0xffc3686ea9b81671b021c95efd3883ee45b5591902a26286ffeda26465d53d10ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11084150, - "block_timestamp": 1781750592, - "tx_hash": "0xed2779bd0360ff62f903eef90440516e4a6a2d90e39d2c0da81a841e16bfc35f", - "log_index": 534, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xeffd3447e89e0e09ddc1a786c2b5466dd8eec96d", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xa9e354e04c305a5de11e036025dbe63451dae888", - "receiver": "0xeffd3447e89e0e09ddc1a786c2b5466dd8eec96d", - "sellAmount": "2000000000000000", - "buyAmount": "3665129510555176458", - "validTo": 4294967295, - "appData": "0xc753562ad35bbffa0d998cd8825cbddb03ce77db31dcecc81907d31e6e8ada51", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729cc6a33622f", - "app_data_hash": "0xc753562ad35bbffa0d998cd8825cbddb03ce77db31dcecc81907d31e6e8ada51", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":767,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000effd3447e89e0e09ddc1a786c2b5466dd8eec96d" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000a9e354e04c305a5de11e036025dbe63451dae888000000000000000000000000effd3447e89e0e09ddc1a786c2b5466dd8eec96d00000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000032dd27df04714e0a00000000000000000000000000000000000000000000000000000000ffffffffc753562ad35bbffa0d998cd8825cbddb03ce77db31dcecc81907d31e6e8ada510000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729cc6a33622f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7b51bb4aba053b20441a6c0df1c266d0cc6fc16e281d583fc07ba4488ce7ef26ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11084744, - "block_timestamp": 1781757792, - "tx_hash": "0xc4288a16179dcb342c70532983bd0c0a06788dc01d64ef0f31968792a87f506d", - "log_index": 197, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0a003f60bd9eef0590de7f8b1d988e29a5e6acb7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x58eb19ef91e8a6327fed391b51ae1887b833cc91", - "receiver": "0x0a003f60bd9eef0590de7f8b1d988e29a5e6acb7", - "sellAmount": "1000000000000000000", - "buyAmount": "463552362", - "validTo": 4294967295, - "appData": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172a6e6a337e55", - "app_data_hash": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000a003f60bd9eef0590de7f8b1d988e29a5e6acb7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000058eb19ef91e8a6327fed391b51ae1887b833cc910000000000000000000000000a003f60bd9eef0590de7f8b1d988e29a5e6acb70000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000001ba13f6a00000000000000000000000000000000000000000000000000000000ffffffff24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172a6e6a337e550000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8b4e73ec67bd653783118a3cb66da57c4766cf68b2fb4fdd59d90f10e1c183b1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085093, - "block_timestamp": 1781762016, - "tx_hash": "0xb5d57bd43f153038d3263c8acdfe2aef1a8bd764c3becbb4e3810d32a369af00", - "log_index": 267, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xeaea6c3c9219bd5951291f6958f163224df843b1", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xeaea6c3c9219bd5951291f6958f163224df843b1", - "sellAmount": "3000000000000000", - "buyAmount": "188423008", - "validTo": 4294967295, - "appData": "0x02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f80", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172ac76a338ed7", - "app_data_hash": "0x02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f80", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":553,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000eaea6c3c9219bd5951291f6958f163224df843b1" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000eaea6c3c9219bd5951291f6958f163224df843b1000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000000b3b1b6000000000000000000000000000000000000000000000000000000000ffffffff02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f800000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172ac76a338ed70000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8bd2dbf842699a6bbacb51988628dadad29835bfecdb1196013570fef4140c00ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085093, - "block_timestamp": 1781762016, - "tx_hash": "0x264acc600ad37482dc42513e807a1b56c522ed712ed852f4a180815af7c865d9", - "log_index": 321, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xeaea6c3c9219bd5951291f6958f163224df843b1", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xeaea6c3c9219bd5951291f6958f163224df843b1", - "sellAmount": "3000000000000000", - "buyAmount": "2467848255", - "validTo": 4294967295, - "appData": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172ac96a338edb", - "app_data_hash": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":526,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000eaea6c3c9219bd5951291f6958f163224df843b1" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000eaea6c3c9219bd5951291f6958f163224df843b1000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000009318603f00000000000000000000000000000000000000000000000000000000fffffffff802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172ac96a338edb0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd3d9da383cdae81043b8c5c945de8a9e18d1372e2f55539312c8ee6033c70cfdba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085123, - "block_timestamp": 1781762376, - "tx_hash": "0xf90eae57885077399b2cf6247fe4fe921f8ed6603e90583496bdcf550c2dc5e3", - "log_index": 132, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8af78ae6c980068a95774467607227d80e3bcd05", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x8af78ae6c980068a95774467607227d80e3bcd05", - "sellAmount": "3000000000000000", - "buyAmount": "185412694", - "validTo": 4294967295, - "appData": "0x6a67eef03a9bcc727fa1b71890242c08e5baceb53c18e0c766491741458cbc2d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172acb6a33903f", - "app_data_hash": "0x6a67eef03a9bcc727fa1b71890242c08e5baceb53c18e0c766491741458cbc2d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":549,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008af78ae6c980068a95774467607227d80e3bcd05" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000008af78ae6c980068a95774467607227d80e3bcd05000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000000b0d2c5600000000000000000000000000000000000000000000000000000000ffffffff6a67eef03a9bcc727fa1b71890242c08e5baceb53c18e0c766491741458cbc2d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172acb6a33903f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x518e19aab296a2af714964d0c3ded7cd3126799a0b3c91937b05770bfa16cb1dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085123, - "block_timestamp": 1781762376, - "tx_hash": "0xe3fce48fe496b631c910d3f4bfb2da0cc65b6d1d049670465b714d6dfc9bba8f", - "log_index": 144, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8af78ae6c980068a95774467607227d80e3bcd05", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x8af78ae6c980068a95774467607227d80e3bcd05", - "sellAmount": "3000000000000000", - "buyAmount": "2445460781", - "validTo": 4294967295, - "appData": "0xfea258abd16c663f1cde91a390227589575d5d15075927d88c4b69a3b7e90e9b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172acd6a339044", - "app_data_hash": "0xfea258abd16c663f1cde91a390227589575d5d15075927d88c4b69a3b7e90e9b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":541,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008af78ae6c980068a95774467607227d80e3bcd05" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000008af78ae6c980068a95774467607227d80e3bcd05000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000091c2c52d00000000000000000000000000000000000000000000000000000000fffffffffea258abd16c663f1cde91a390227589575d5d15075927d88c4b69a3b7e90e9b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172acd6a3390440000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8bb95de20b2bc4f529fb2fb6e7032aec1fe058433207c270c061c9448e11b5f4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085229, - "block_timestamp": 1781763648, - "tx_hash": "0x81222420097522888c7b3ca92dc8cd7c4307d5589e43c3dd5c6d28eb9f42ca20", - "log_index": 392, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8525834218582b2deb9ba8788cfa60cfdbc51392", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x8525834218582b2deb9ba8788cfa60cfdbc51392", - "sellAmount": "3000000000000000", - "buyAmount": "186824823", - "validTo": 4294967295, - "appData": "0x4f3f50db26938436aca012cd2004cf2589cff4407a2c1bd32d4b9a10c359a9e0", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172ad66a339539", - "app_data_hash": "0x4f3f50db26938436aca012cd2004cf2589cff4407a2c1bd32d4b9a10c359a9e0", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":464,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008525834218582b2deb9ba8788cfa60cfdbc51392" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000008525834218582b2deb9ba8788cfa60cfdbc51392000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000000b22b87700000000000000000000000000000000000000000000000000000000ffffffff4f3f50db26938436aca012cd2004cf2589cff4407a2c1bd32d4b9a10c359a9e00000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172ad66a3395390000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3f80482c1fcf7cd0f250b12f303207f91c999dc5f0e7a8d3df2b2d3131f876f1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085229, - "block_timestamp": 1781763648, - "tx_hash": "0xb425873743f51b9b99163372affbdb9b0dcf4d03877ac532ca38c60125ad3f37", - "log_index": 402, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8525834218582b2deb9ba8788cfa60cfdbc51392", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x8525834218582b2deb9ba8788cfa60cfdbc51392", - "sellAmount": "3000000000000000", - "buyAmount": "2574273094", - "validTo": 4294967295, - "appData": "0x7698380032e1721311a61bfbd177763a063fc5c36876276af90c0cd91a2bc73e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172ad46a33953f", - "app_data_hash": "0x7698380032e1721311a61bfbd177763a063fc5c36876276af90c0cd91a2bc73e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":465,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008525834218582b2deb9ba8788cfa60cfdbc51392" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000008525834218582b2deb9ba8788cfa60cfdbc51392000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000099704a4600000000000000000000000000000000000000000000000000000000ffffffff7698380032e1721311a61bfbd177763a063fc5c36876276af90c0cd91a2bc73e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172ad46a33953f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6217df15ccc4990a9d22378aa748359fdb6deb87d6098e93a97a67eac03f6d41ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085285, - "block_timestamp": 1781764320, - "tx_hash": "0x8cd8bce925bd86f5b338d9acbce2666c2fb345b6217249424659b6db018b9d89", - "log_index": 179, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "146914557110", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172ae66a3397d9", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000002234ca3cb600000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172ae66a3397d90000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb8b7945e5e64c71ab7430fa9cd0e8523dfbf7801f991d6366516932dd6bf9a46ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085290, - "block_timestamp": 1781764380, - "tx_hash": "0xba927758ac45e8f8fc7e3de259ab12d7bebdd05b4a1a48a497b570df7c25fb91", - "log_index": 155, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "137230706698", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172ae86a33980e", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000001ff396680a00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172ae86a33980e0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xbeaa3ed381dfd58db4c683a4810f08491a4c553fcba29aafbbf3e072ef15af63ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085296, - "block_timestamp": 1781764452, - "tx_hash": "0x115fb8b7326d1587bfdc19106ed69e388cc2284b32b550ce5836828e4ac33241", - "log_index": 51, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "136092590449", - "validTo": 4294967295, - "appData": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172aed6a339862", - "app_data_hash": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":64,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000001fafc0217100000000000000000000000000000000000000000000000000000000ffffffff4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172aed6a3398620000000000000000000000000000000000000000" - } - }, - { - "uid": "0xac9baa9a79531b642444ec6f2ea410b60d587377abdea089ce98e543c4ef0263ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085311, - "block_timestamp": 1781764632, - "tx_hash": "0x4da8ad0364d3ba06cd3405e467239ec21a01778c9ef3051aec83590ee27a337b", - "log_index": 84, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "135046086001", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172aef6a339906", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000001f715fbd7100000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172aef6a3399060000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5471a5aa664706773d9f9d00764b164c4860bdb3ac684b618406bb5c49e1beb1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085316, - "block_timestamp": 1781764692, - "tx_hash": "0x160a9b2ee69a0cc66303e5b261321ca258dd2db5f31f42e3ee47dc5bdcdf76c0", - "log_index": 33, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "134037221750", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172af16a33993d", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000001f353db17600000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172af16a33993d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb9af72ee95b029577b23f968917e328750de30a86635acb312aefce04199b8e0ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085768, - "block_timestamp": 1781770116, - "tx_hash": "0x1d097f240a28101f26aeb1ae231e24ea3a4df662887fe16480315212b24fc330", - "log_index": 410, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "sellAmount": "30200000000000000", - "buyAmount": "1218876746382639626", - "validTo": 4294967295, - "appData": "0xbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa7885", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172b876a33ae51", - "app_data_hash": "0xbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa7885", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":90,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000006ec4672a460b414e661b3baf798071655edd1b46" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000006ec4672a460b414e661b3baf798071655edd1b46000000000000000000000000000000000000000000000000006b4abd7037800000000000000000000000000000000000000000000000000010ea51f1651f020a00000000000000000000000000000000000000000000000000000000ffffffffbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa78850000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172b876a33ae510000000000000000000000000000000000000000" - } - }, - { - "uid": "0x577b183cdac6edd32f292bd214f463f8b01bd8d85ed29b03cde2ef069f6790f7ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11086236, - "block_timestamp": 1781775732, - "tx_hash": "0x2a880ef51956e54628126f9862de4094ab1b676617119146248ebb06eba1a09e", - "log_index": 96, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "53447319456", - "validTo": 4294967295, - "appData": "0x4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172bab6a33c46c", - "app_data_hash": "0x4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":77,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000000c71b55fa000000000000000000000000000000000000000000000000000000000ffffffff4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172bab6a33c46c0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xafd52f06e6fd522f768dc3b879c31f7818d8d748e79c5683612e955d104c3266ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11086284, - "block_timestamp": 1781776308, - "tx_hash": "0x0d436cdbac81764bf7818e49f0ee8c52d392a35c9f30cd96f66fe41a41133355", - "log_index": 98, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "94689739878", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172bb46a33c6a4", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000160bf2c46600000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172bb46a33c6a40000000000000000000000000000000000000000" - } - }, - { - "uid": "0x4ee0044363f660d01bd2a6e2033620e41c786b67b10f0544f2eb28cd8144208cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11086290, - "block_timestamp": 1781776380, - "tx_hash": "0xbf1c0e8393d62e4606ef3fae5a712011f7cb25ba4932545985b9eb8399560afb", - "log_index": 248, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "93567326162", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172bb76a33c6eb", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000015c90c17d200000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172bb76a33c6eb0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6442782816eab3c907715d472a2db9f9b7c75ed5236617a4a155fc9e54d9d760ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11086493, - "block_timestamp": 1781778816, - "tx_hash": "0x9dadabf967ade57ced8e5670eac6b5b94df6d69ec881e5557479cdc73362e4c8", - "log_index": 45, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "111006445247", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172bf16a33d06f", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000019d87feebf00000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172bf16a33d06f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x31cce049ef32cdc59b48323c67a8faeafcd6e44167080b957f6f660134711111ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11087438, - "block_timestamp": 1781790168, - "tx_hash": "0x01ca9d3ed386600c8b3e8afc3a7f54dd144b6f87710be90b370fc31153234ef4", - "log_index": 180, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "22146154752", - "validTo": 4294967295, - "appData": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172caa6a33fcc5", - "app_data_hash": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":76,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec50000000000000000000000000000000000000000000000000000000000052803810000000000000000000000000000000000000000000000000000000000ffffffff4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172caa6a33fcc50000000000000000000000000000000000000000" - } - }, - { - "uid": "0x927561c19ae564fca0c7e350a61c7d241a878353f0b3f1e7c3770a844345b868ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11087442, - "block_timestamp": 1781790216, - "tx_hash": "0x11364a161b10f3bf41f545d633cbcc9b38602a42ab84f27972509a64412c1a61", - "log_index": 42, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "56578236075", - "validTo": 4294967295, - "appData": "0x1a73e3b20393ab2dd0632d510d8c2b4a80cdb64aa7bd37e44ae1052e4205e9a8", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172cac6a33fd00", - "app_data_hash": "0x1a73e3b20393ab2dd0632d510d8c2b4a80cdb64aa7bd37e44ae1052e4205e9a8", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":75,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000000d2c535eab00000000000000000000000000000000000000000000000000000000ffffffff1a73e3b20393ab2dd0632d510d8c2b4a80cdb64aa7bd37e44ae1052e4205e9a80000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172cac6a33fd000000000000000000000000000000000000000000" - } - }, - { - "uid": "0x33e40575e17f11d9b80ab80c3c6f231c0bfd60db2e85a1f52c210c1686038944ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11087462, - "block_timestamp": 1781790456, - "tx_hash": "0x5e2ba04d6d26e4a2f5eb64e4be6af21474ab01d6331e1917a8eda18f58e5ee13", - "log_index": 42, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "51804002339", - "validTo": 4294967295, - "appData": "0x4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172cb36a33fde8", - "app_data_hash": "0x4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":77,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000000c0fc2582300000000000000000000000000000000000000000000000000000000ffffffff4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172cb36a33fde80000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5fe03b4ee871751804eb7b021e66c2d0ddfafb279b2218aa8aac3ce8cb3c96d6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11087468, - "block_timestamp": 1781790528, - "tx_hash": "0xecbac027a9eacb1783a0a3a2688aec80c3bc9001bd833313d3d91b64c3259185", - "log_index": 85, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "49586414578", - "validTo": 4294967295, - "appData": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172cb96a33fe35", - "app_data_hash": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":74,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000000b8b94a3f200000000000000000000000000000000000000000000000000000000ffffffff8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d40000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172cb96a33fe350000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5e7f3fe1498999246b82983af707929c8a4b1831c7663a7d31de37b93d9b1487ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11087473, - "block_timestamp": 1781790588, - "tx_hash": "0xe567bc67609d07890b11edf98924c78af490bfedc253a4d8ce1df693b625667a", - "log_index": 35, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "94306883092", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172cbc6a33fe69", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000015f520d61400000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172cbc6a33fe690000000000000000000000000000000000000000" - } - }, - { - "uid": "0x0cfa57204e97b9c91f2e685ada7847c21847deacc30519734635816fd471223fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11087748, - "block_timestamp": 1781793888, - "tx_hash": "0xb0455b692097fefb5a7850698a6c3ff831517442f77d38d81daf56172301dd29", - "log_index": 137, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x91335e2f56cdb6744fff7da70969d90638cce19e", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x91335e2f56cdb6744fff7da70969d90638cce19e", - "sellAmount": "3000000000000000", - "buyAmount": "1467227059", - "validTo": 4294967295, - "appData": "0x968a7cae388675d1f5f150e0c33f138a376cf2297c0e164ca4c2fe28fd6aafd8", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172d166a340b60", - "app_data_hash": "0x968a7cae388675d1f5f150e0c33f138a376cf2297c0e164ca4c2fe28fd6aafd8", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":532,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000091335e2f56cdb6744fff7da70969d90638cce19e" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000091335e2f56cdb6744fff7da70969d90638cce19e000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000057741bb300000000000000000000000000000000000000000000000000000000ffffffff968a7cae388675d1f5f150e0c33f138a376cf2297c0e164ca4c2fe28fd6aafd80000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172d166a340b600000000000000000000000000000000000000000" - } - }, - { - "uid": "0x88e25f261f0c934dfe9fd1190017eeefbdcabcb3bb2f40d05c32fe3b878ab5afba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11087749, - "block_timestamp": 1781793900, - "tx_hash": "0x77502c64b31d237c6a5406208571da2044d7da4480e65c1ccd1c4e463a558c3a", - "log_index": 136, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x91335e2f56cdb6744fff7da70969d90638cce19e", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x91335e2f56cdb6744fff7da70969d90638cce19e", - "sellAmount": "3000000000000000", - "buyAmount": "2272596748", - "validTo": 4294967295, - "appData": "0x387164afa7d6ef1febb500d0c66cc903869fe223a99f8259866a9ba2a99857a1", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172d186a340b65", - "app_data_hash": "0x387164afa7d6ef1febb500d0c66cc903869fe223a99f8259866a9ba2a99857a1", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":518,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000091335e2f56cdb6744fff7da70969d90638cce19e" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000091335e2f56cdb6744fff7da70969d90638cce19e000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000008775130c00000000000000000000000000000000000000000000000000000000ffffffff387164afa7d6ef1febb500d0c66cc903869fe223a99f8259866a9ba2a99857a10000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172d186a340b650000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3d47b55bb7ebb4b046b0dcb0c152c4990805062c4768a2bd0907ea7fd3d772e5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11089218, - "block_timestamp": 1781811528, - "tx_hash": "0x74613648cfe829e1fab12f23ef21469a0ed230bf36d3d993b155de9b116b0346", - "log_index": 101, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x145b714ad53921242d5e4c185343772f8a4e4cb7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x145b714ad53921242d5e4c185343772f8a4e4cb7", - "sellAmount": "40031896826542000", - "buyAmount": "17527546292499914395", - "validTo": 4294967295, - "appData": "0x77d2f2e3c1b1482fea439e656a5cf63ea32afefbaeaa006280942f0c1eb471cd", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172e286a345034", - "app_data_hash": "0x77d2f2e3c1b1482fea439e656a5cf63ea32afefbaeaa006280942f0c1eb471cd", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":81,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000145b714ad53921242d5e4c185343772f8a4e4cb7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000145b714ad53921242d5e4c185343772f8a4e4cb7000000000000000000000000000000000000000000000000008e38cc4e07f7b0000000000000000000000000000000000000000000000000f33e5a7cf4ac1e9b00000000000000000000000000000000000000000000000000000000ffffffff77d2f2e3c1b1482fea439e656a5cf63ea32afefbaeaa006280942f0c1eb471cd0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172e286a3450340000000000000000000000000000000000000000" - } - }, - { - "uid": "0x91b7bb9802c77fecf69051cb58010bf4eaf3511fdc272d9b89cdf20a701d4afbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11089257, - "block_timestamp": 1781811996, - "tx_hash": "0x6a8bf05ac5d9a96c7f94e706d86066a79d831d6af0651d15838971293afe2756", - "log_index": 139, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x145b714ad53921242d5e4c185343772f8a4e4cb7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x145b714ad53921242d5e4c185343772f8a4e4cb7", - "sellAmount": "1000000000000000", - "buyAmount": "477798941", - "validTo": 4294967295, - "appData": "0xc10c79345e8b27e8021467437ffe359d3d046cc90dfcdb6482e48ef832913b15", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172e336a345216", - "app_data_hash": "0xc10c79345e8b27e8021467437ffe359d3d046cc90dfcdb6482e48ef832913b15", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":2011,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000145b714ad53921242d5e4c185343772f8a4e4cb7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000145b714ad53921242d5e4c185343772f8a4e4cb700000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000000000000000000000000000000000001c7aa21d00000000000000000000000000000000000000000000000000000000ffffffffc10c79345e8b27e8021467437ffe359d3d046cc90dfcdb6482e48ef832913b150000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172e336a3452160000000000000000000000000000000000000000" - } - }, - { - "uid": "0x479315784e8f9e9e254405234fc9a4d2391b16a7cc7dc0fcf86093419b41c3aaba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11089274, - "block_timestamp": 1781812200, - "tx_hash": "0x9a5ca07d7276dd8a764faef10b9ddd60da1d4b95fb7174a76be8cee00a49c6e0", - "log_index": 71, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x145b714ad53921242d5e4c185343772f8a4e4cb7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x145b714ad53921242d5e4c185343772f8a4e4cb7", - "sellAmount": "37446325452113514", - "buyAmount": "29815248673", - "validTo": 4294967295, - "appData": "0xb58c21b442ec398926797d19ac2ac5e35b7f136d862454b0d4ad564e3efd9ce4", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172e3c6a3452e1", - "app_data_hash": "0xb58c21b442ec398926797d19ac2ac5e35b7f136d862454b0d4ad564e3efd9ce4", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":86,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000145b714ad53921242d5e4c185343772f8a4e4cb7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000145b714ad53921242d5e4c185343772f8a4e4cb70000000000000000000000000000000000000000000000000085093c0eb7866a00000000000000000000000000000000000000000000000000000006f120972100000000000000000000000000000000000000000000000000000000ffffffffb58c21b442ec398926797d19ac2ac5e35b7f136d862454b0d4ad564e3efd9ce40000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172e3c6a3452e10000000000000000000000000000000000000000" - } - }, - { - "uid": "0x006b940d37b891afad2e0bf729b38ed68ae902e67450c171b7fd7901c3922f56ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11089296, - "block_timestamp": 1781812464, - "tx_hash": "0x087ad71566fccd7c5b451ad828bbbaeba8e93003c62c6e93bb78fac22c0193fe", - "log_index": 208, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7b8f0b8e09ca522ad3418fb89b9176f1bc74644c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x7b8f0b8e09ca522ad3418fb89b9176f1bc74644c", - "sellAmount": "120000000000000000", - "buyAmount": "52832056816179325249", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172e486a3453ea", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007b8f0b8e09ca522ad3418fb89b9176f1bc74644c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000007b8f0b8e09ca522ad3418fb89b9176f1bc74644c00000000000000000000000000000000000000000000000001aa535d3d0c0000000000000000000000000000000000000000000000000002dd312bc2119fa94100000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172e486a3453ea0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x104f25a0d633f9f39840723fc7e72a87d327829c9bc541a08ad9c8a62b9ecc9eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11089394, - "block_timestamp": 1781813640, - "tx_hash": "0x622375d89119df6419324ad4e5603688261fb01a4d47d717d686b6dd426b5731", - "log_index": 367, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7bf140727d27ea64b607e042f1225680b40eca6a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x7bf140727d27ea64b607e042f1225680b40eca6a", - "sellAmount": "4714466422212269", - "buyAmount": "193421397728197901", - "validTo": 4294967295, - "appData": "0xb48d38f93eaa084033fc5970bf96e559c33c4cdc07d889ab00b4d63f9590739d", - "feeAmount": "285533577787731", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172e686a345881", - "app_data_hash": "0xb48d38f93eaa084033fc5970bf96e559c33c4cdc07d889ab00b4d63f9590739d", - "app_data_resolved": { - "fullAppData": "{}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007bf140727d27ea64b607e042f1225680b40eca6a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000007bf140727d27ea64b607e042f1225680b40eca6a0000000000000000000000000000000000000000000000000010bfc84066c6ad00000000000000000000000000000000000000000000000002af2bbc878c7d0d00000000000000000000000000000000000000000000000000000000ffffffffb48d38f93eaa084033fc5970bf96e559c33c4cdc07d889ab00b4d63f9590739d000000000000000000000000000000000000000000000000000103b0f779b953f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172e686a3458810000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6d296984c1ce92ad816194112193e44ea322f3a6d671c6fc2d1929806622ccd8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11089725, - "block_timestamp": 1781817624, - "tx_hash": "0x82da5ceda6e28337625a991d4fc7db6b82a1695012b58a6b660ec92b8a88b878", - "log_index": 155, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7bf140727d27ea64b607e042f1225680b40eca6a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x7bf140727d27ea64b607e042f1225680b40eca6a", - "sellAmount": "2000000000000000", - "buyAmount": "698594959890775127", - "validTo": 4294967295, - "appData": "0xe46e7d0cc02ede7c7e143b47f589549ad67b271a1809c9cffe7e7dc60329c86d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": true, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172f6a6a346812", - "app_data_hash": "0xe46e7d0cc02ede7c7e143b47f589549ad67b271a1809c9cffe7e7dc60329c86d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":857,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007bf140727d27ea64b607e042f1225680b40eca6a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000007bf140727d27ea64b607e042f1225680b40eca6a00000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000009b1e86a2a2afc5700000000000000000000000000000000000000000000000000000000ffffffffe46e7d0cc02ede7c7e143b47f589549ad67b271a1809c9cffe7e7dc60329c86d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000015a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172f6a6a3468120000000000000000000000000000000000000000" - } - }, - { - "uid": "0xf5788a8ba6d8a7ff763299311fa02f6e87777e49ec287e2aadc54a8c964deffbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11089920, - "block_timestamp": 1781819964, - "tx_hash": "0xae6ff21563e64f3f2fdbfabf6b362e8bf771f699a195ffe090333264c0db42a4", - "log_index": 84, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x28977f072c3f9e5899708bca9c327369924486dd", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x28977f072c3f9e5899708bca9c327369924486dd", - "sellAmount": "10000000000000000", - "buyAmount": "4312757926303371962", - "validTo": 4294967295, - "appData": "0xcff26c46e7166c1dab98491d174d6d666147e5562b535818262541391bbec62d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172f846a347109", - "app_data_hash": "0xcff26c46e7166c1dab98491d174d6d666147e5562b535818262541391bbec62d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"cow-sdk-wasm-swap-demo\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":50},\"utm\":{\"utmCampaign\":\"developer-cohort\",\"utmContent\":\"wasm\",\"utmMedium\":\"cow-rs@0.1.0-alpha.5\",\"utmSource\":\"cow-sdk\",\"utmTerm\":\"rs\"}},\"version\":\"1.15.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000028977f072c3f9e5899708bca9c327369924486dd" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb00000000000000000000000028977f072c3f9e5899708bca9c327369924486dd000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000003bd9fe7be79012ba00000000000000000000000000000000000000000000000000000000ffffffffcff26c46e7166c1dab98491d174d6d666147e5562b535818262541391bbec62d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172f846a3471090000000000000000000000000000000000000000" - } - }, - { - "uid": "0xdd4a43c4585339ded372309162806dcee34afcb3411c10a2d6538e4967e60503ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11090323, - "block_timestamp": 1781824800, - "tx_hash": "0x82de62b7e601e1fc4cfe66a8bc93e596fd2fc75405d61facfc3c80138d702b6c", - "log_index": 252, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x4e16893cd1fff4455c970a788c868671ab93d8b1", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x9d2efa1cda4cc7a3258af126566a5bcd8a24f7cc", - "receiver": "0x4e16893cd1fff4455c970a788c868671ab93d8b1", - "sellAmount": "10000000000000000", - "buyAmount": "710160124267367018", - "validTo": 4294967295, - "appData": "0x76c89e0625cbd9dd0abe9903cf173de8442d3c746a9a0d4201fb4cd30cdfc96d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172fbc6a348400", - "app_data_hash": "0x76c89e0625cbd9dd0abe9903cf173de8442d3c746a9a0d4201fb4cd30cdfc96d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":208,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000004e16893cd1fff4455c970a788c868671ab93d8b1" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000009d2efa1cda4cc7a3258af126566a5bcd8a24f7cc0000000000000000000000004e16893cd1fff4455c970a788c868671ab93d8b1000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000009dafeded49a8a6a00000000000000000000000000000000000000000000000000000000ffffffff76c89e0625cbd9dd0abe9903cf173de8442d3c746a9a0d4201fb4cd30cdfc96d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172fbc6a3484000000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3d1098b807f138f04f8dbf3bd7ae5de53da1562345f311ccccb13b0e139c0191ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11091082, - "block_timestamp": 1781833920, - "tx_hash": "0x8fef26deeffc007a9bb1da0a4d9e4eb87a83405d053853de59164a6cb4a1a549", - "log_index": 518, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "55055341111", - "validTo": 4294967295, - "appData": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017301e6a34a7bb", - "app_data_hash": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":64,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000cd18dd63700000000000000000000000000000000000000000000000000000000ffffffff4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017301e6a34a7bb0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe11c2022eab06f614f3466aefee62e0935020cacf107f3f2c41d53515708aa1dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11091089, - "block_timestamp": 1781834004, - "tx_hash": "0x53f3a5b098c3b9d72e48999adabf9a61f6bfd8863213d8d4424d18b049f5451a", - "log_index": 466, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "10000000000000000", - "buyAmount": "4897709189", - "validTo": 4294967295, - "appData": "0x848949b0be53fe96ee43b77844377a12e06f2e4a129bcc14c5b4bd46936d05a5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001730256a34a80c", - "app_data_hash": "0x848949b0be53fe96ee43b77844377a12e06f2e4a129bcc14c5b4bd46936d05a5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":186,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000000000123ed1c8500000000000000000000000000000000000000000000000000000000ffffffff848949b0be53fe96ee43b77844377a12e06f2e4a129bcc14c5b4bd46936d05a50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001730256a34a80c0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x1b7ecce8e8da1c4c24ba0064196951a4bc1cf1397900c5edcf2ea37eff266255ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11091095, - "block_timestamp": 1781834076, - "tx_hash": "0x80aa3665a242ac419f775d9fb87fffd3cf17bff6c7f5bf8995dd77d778e0762f", - "log_index": 645, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "4064924478581430370", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017302a6a34a851", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000038698346c0a2d86200000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017302a6a34a8510000000000000000000000000000000000000000" - } - }, - { - "uid": "0x17413c36f762b4f043539e465918f3acbe8d92503dc32d31e5f654d8add31724ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11091102, - "block_timestamp": 1781834160, - "tx_hash": "0x6e9257e80c4897dc98677ba4373be81fa7b78741520d8dc934ae78153888f0d7", - "log_index": 600, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "1300000000000000", - "buyAmount": "414280907641317243", - "validTo": 4294967295, - "appData": "0xf9bcf3c895ec686159f1a5b3ea570ad7191c4b2ae70454cc2059d888f7f00bba", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001730316a34a8a4", - "app_data_hash": "0xf9bcf3c895ec686159f1a5b3ea570ad7191c4b2ae70454cc2059d888f7f00bba", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1186,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b00000000000000000000000000000000000000000000000000049e57d635400000000000000000000000000000000000000000000000000005bfd24a612fe77b00000000000000000000000000000000000000000000000000000000fffffffff9bcf3c895ec686159f1a5b3ea570ad7191c4b2ae70454cc2059d888f7f00bba0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001730316a34a8a40000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe18203b84cb8368358e8d375c94d8c738b3bf479c042abd881a9a6a8c17dfa44ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11091111, - "block_timestamp": 1781834268, - "tx_hash": "0x30ce2bef8e319cfac8b370880f04a189df6887e0cd8cf754b6d4810285f9ea35", - "log_index": 271, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "573833000000000000", - "buyAmount": "252672845129172466337", - "validTo": 4294967295, - "appData": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001730376a34a910", - "app_data_hash": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":52,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da553297000000000000000000000000000000000000000000000000007f6aa12bd65900000000000000000000000000000000000000000000000000db28a45ed479d3aa100000000000000000000000000000000000000000000000000000000ffffffffacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce5190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001730376a34a9100000000000000000000000000000000000000000" - } - }, - { - "uid": "0x362dcbfb47d683caa387338420dc664fd212fd4cad4628e42f1dcd98737f3ffbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11091361, - "block_timestamp": 1781837316, - "tx_hash": "0x90d9f4ca8bd41a037ceef335e168fcb8cfd9805ee6be598c63aad5c4c35f51e3", - "log_index": 573, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x2df787aee880af0be17f2932057cca2ad6dd8478", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xd57af64ed597007990abe48434fcca685ee134ce", - "receiver": "0x2df787aee880af0be17f2932057cca2ad6dd8478", - "sellAmount": "3000000000000000", - "buyAmount": "4461280859271727529", - "validTo": 4294967295, - "appData": "0x81936e388f71f15f0eb6675e4b2a68f5458983eb460c0652cefc81a4dace8077", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017307d6a34b4fe", - "app_data_hash": "0x81936e388f71f15f0eb6675e4b2a68f5458983eb460c0652cefc81a4dace8077", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":537,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000002df787aee880af0be17f2932057cca2ad6dd8478" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000d57af64ed597007990abe48434fcca685ee134ce0000000000000000000000002df787aee880af0be17f2932057cca2ad6dd8478000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000003de9a74dfc2409a900000000000000000000000000000000000000000000000000000000ffffffff81936e388f71f15f0eb6675e4b2a68f5458983eb460c0652cefc81a4dace80770000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017307d6a34b4fe0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7fe88a513bcb126fb7156d18b31a23bbb03aa33bf5f3876c3405c65828bc9592ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11093487, - "block_timestamp": 1781863032, - "tx_hash": "0x28978a1f5d23a82aec06459b4a989f2aeeff1138c7b53ab0ead5dedef030d677", - "log_index": 218, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8c94184bc1ebbfe53bcc36d6395899f0a923a31e", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x8c94184bc1ebbfe53bcc36d6395899f0a923a31e", - "sellAmount": "1000000000000000000", - "buyAmount": "388406859321", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001732796a351954", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008c94184bc1ebbfe53bcc36d6395899f0a923a31e" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000008c94184bc1ebbfe53bcc36d6395899f0a923a31e0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000005a6eda563900000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001732796a3519540000000000000000000000000000000000000000" - } - }, - { - "uid": "0x0c37aa675a84a8f94dfc6fb8e27dea1f34435102ab1b3fe43640e4858d92adb0ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11094315, - "block_timestamp": 1781872980, - "tx_hash": "0x76260763b966d5c4ad530c7c7d32a84f98cac44030407d82146979bf2e2e9a0a", - "log_index": 88, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9fa3c00a92ec5f96b1ad2527ab41b3932efeda58", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x9fa3c00a92ec5f96b1ad2527ab41b3932efeda58", - "sellAmount": "1000000000000000000", - "buyAmount": "438120010025215221933", - "validTo": 4294967295, - "appData": "0xa1ba74eb08d80bec4a9f5a3deac838fd4b0a18384800e94b88a0cde16c2acfd4", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001733446a354049", - "app_data_hash": "0xa1ba74eb08d80bec4a9f5a3deac838fd4b0a18384800e94b88a0cde16c2acfd4", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"referrer\":{\"code\":\"MOOOO\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009fa3c00a92ec5f96b1ad2527ab41b3932efeda58" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000009fa3c00a92ec5f96b1ad2527ab41b3932efeda580000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000017c022f3dbcf8860ad00000000000000000000000000000000000000000000000000000000ffffffffa1ba74eb08d80bec4a9f5a3deac838fd4b0a18384800e94b88a0cde16c2acfd40000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001733446a3540490000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2170ca89003f76f3439751daf74f58a5f8c61adb900144ab5665fba4659cda55ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11094609, - "block_timestamp": 1781876508, - "tx_hash": "0xfc3dc80cd1f541de9e6621b6aac8bb9be27082d27eb4ab29fe92f902e8a0a10e", - "log_index": 37, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0064241fab45a6fbf5e90ff9e4b9650216c48641", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x0064241fab45a6fbf5e90ff9e4b9650216c48641", - "sellAmount": "50000000000000000", - "buyAmount": "21710356739052631925", - "validTo": 4294967295, - "appData": "0x82275873027496793547b4fac1740dd28c09817b107791e5fdb7686ae00d409b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001733946a354de3", - "app_data_hash": "0x82275873027496793547b4fac1740dd28c09817b107791e5fdb7686ae00d409b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"cow-swap-wasm\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":50},\"utm\":{\"utmCampaign\":\"developer-cohort\",\"utmContent\":\"wasm\",\"utmMedium\":\"cow-rs@0.1.0-alpha.6\",\"utmSource\":\"cow-sdk\",\"utmTerm\":\"rs\"}},\"version\":\"1.15.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000064241fab45a6fbf5e90ff9e4b9650216c48641" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000064241fab45a6fbf5e90ff9e4b9650216c4864100000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000012d4aae6d823d777500000000000000000000000000000000000000000000000000000000ffffffff82275873027496793547b4fac1740dd28c09817b107791e5fdb7686ae00d409b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001733946a354de30000000000000000000000000000000000000000" - } - }, - { - "uid": "0xcc734808b617ea1b56b02b6c1e5af209f821b122fd28bc5b1dc6f047a74d63cbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11094664, - "block_timestamp": 1781877168, - "tx_hash": "0x16c3c2cc8b6fe4da8ec6037fc3d8e59cace07ae059ae6b8d5b59512d6ac73f6d", - "log_index": 352, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x05bb5df1913283db49870acfac065926aac902d2", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x5d25751d6f70e2c6d8f496e65d6ad0941b759560", - "receiver": "0x05bb5df1913283db49870acfac065926aac902d2", - "sellAmount": "10000000000000000", - "buyAmount": "12747565496824406115", - "validTo": 4294967295, - "appData": "0x31fd51aeedc825fe5399216b3cf8082a67ef90b4c66a8692fe82a200b399b412", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001733a56a35508b", - "app_data_hash": "0x31fd51aeedc825fe5399216b3cf8082a67ef90b4c66a8692fe82a200b399b412", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":178,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000005bb5df1913283db49870acfac065926aac902d2" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000005d25751d6f70e2c6d8f496e65d6ad0941b75956000000000000000000000000005bb5df1913283db49870acfac065926aac902d2000000000000000000000000000000000000000000000000002386f26fc10000000000000000000000000000000000000000000000000000b0e87347a53ea06300000000000000000000000000000000000000000000000000000000ffffffff31fd51aeedc825fe5399216b3cf8082a67ef90b4c66a8692fe82a200b399b4120000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001733a56a35508b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x30fbd13655ea67870e66306d4064de6534e72b1fcae6eefcc9eab0f5dee4208fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11095162, - "block_timestamp": 1781883144, - "tx_hash": "0xf2b59832f3c0a9c93767a71c68fc0b8107615e3fbf84530c73760774b8dee225", - "log_index": 830, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "sellAmount": "30300000000000000", - "buyAmount": "1206300178407213600", - "validTo": 4294967295, - "appData": "0xfac38e759aa01ea7940cb7b564713ca0d7e9459bd3f5d63adc9a77ba2c395d5f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001733df6a3567f9", - "app_data_hash": "0xfac38e759aa01ea7940cb7b564713ca0d7e9459bd3f5d63adc9a77ba2c395d5f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":95,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec000000000000000000000000000000000000000000000000006ba5b080b1c00000000000000000000000000000000000000000000000000010bda39efa73ca2000000000000000000000000000000000000000000000000000000000fffffffffac38e759aa01ea7940cb7b564713ca0d7e9459bd3f5d63adc9a77ba2c395d5f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001733df6a3567f90000000000000000000000000000000000000000" - } - }, - { - "uid": "0x35e2b54d4ac995838fdc863b6fcbf69299387bdd26ee20249c8a57daae0151f3ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11095647, - "block_timestamp": 1781888964, - "tx_hash": "0x619543e70cf3076ea4fcab18ee658cc7caf8bc8971631bc29bcb71f1793263e0", - "log_index": 257, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x4b32a32399045dfe93cb376b3eaae8d9186a7881", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x4b32a32399045dfe93cb376b3eaae8d9186a7881", - "sellAmount": "200000000000000000", - "buyAmount": "118695850426", - "validTo": 4294967295, - "appData": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001734806a357ea3", - "app_data_hash": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":56,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000004b32a32399045dfe93cb376b3eaae8d9186a7881" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000004b32a32399045dfe93cb376b3eaae8d9186a788100000000000000000000000000000000000000000000000002c68af0bb1400000000000000000000000000000000000000000000000000000000001ba2d2f1ba00000000000000000000000000000000000000000000000000000000fffffffff3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001734806a357ea30000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2300a9f60d9d01267af425333c7864de38c613c779f9124cd533cff210288a05ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11096098, - "block_timestamp": 1781894376, - "tx_hash": "0x7d226e1f18e03e923b27a9f8082f68d5f4970ed848e1d2e72e409b58e3f61434", - "log_index": 40, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xf3f128aa2e7904924bcd2220e8573a1fc68bc6a7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xf3f128aa2e7904924bcd2220e8573a1fc68bc6a7", - "sellAmount": "10000000000000000", - "buyAmount": "4492834272429127369", - "validTo": 4294967295, - "appData": "0x82275873027496793547b4fac1740dd28c09817b107791e5fdb7686ae00d409b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017359e6a3593df", - "app_data_hash": "0x82275873027496793547b4fac1740dd28c09817b107791e5fdb7686ae00d409b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"cow-swap-wasm\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":50},\"utm\":{\"utmCampaign\":\"developer-cohort\",\"utmContent\":\"wasm\",\"utmMedium\":\"cow-rs@0.1.0-alpha.6\",\"utmSource\":\"cow-sdk\",\"utmTerm\":\"rs\"}},\"version\":\"1.15.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000f3f128aa2e7904924bcd2220e8573a1fc68bc6a7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f3f128aa2e7904924bcd2220e8573a1fc68bc6a7000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000003e59c0f77ad6b6c900000000000000000000000000000000000000000000000000000000ffffffff82275873027496793547b4fac1740dd28c09817b107791e5fdb7686ae00d409b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017359e6a3593df0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe2e97306cbe93c81d5472f15b42a23ca37c87b7d151aa129763c84ef0aa42f8bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11097901, - "block_timestamp": 1781916048, - "tx_hash": "0x9c3691ed07216339d1e38a6e7c16a5f29e011404a7fed37b8f9455ecd708b990", - "log_index": 363, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xf56d0f35f4b7ab02cac579035220bcce23bf18ed", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbbbe8905aeb65f5114f9c5f52b8a1130db33513b", - "receiver": "0xf56d0f35f4b7ab02cac579035220bcce23bf18ed", - "sellAmount": "30000000000000000", - "buyAmount": "836208744757351966", - "validTo": 4294967295, - "appData": "0x7123077d45c4ca51916e2859def84f77446b584315797d07a4cfd8fa2eddf9fa", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001738a66a35e876", - "app_data_hash": "0x7123077d45c4ca51916e2859def84f77446b584315797d07a4cfd8fa2eddf9fa", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":114,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000f56d0f35f4b7ab02cac579035220bcce23bf18ed" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000bbbe8905aeb65f5114f9c5f52b8a1130db33513b000000000000000000000000f56d0f35f4b7ab02cac579035220bcce23bf18ed000000000000000000000000000000000000000000000000006a94d74f4300000000000000000000000000000000000000000000000000000b9acf6c4556561e00000000000000000000000000000000000000000000000000000000ffffffff7123077d45c4ca51916e2859def84f77446b584315797d07a4cfd8fa2eddf9fa0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001738a66a35e8760000000000000000000000000000000000000000" - } - }, - { - "uid": "0x15d6d916a96f41d9d130f4ec3fecc66a67b2953c7242bc85d1f57886856a2f8bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11098198, - "block_timestamp": 1781919612, - "tx_hash": "0x0b36fe3f1e6554b48d3073f40cbac0ecd2ba36967597f1ddeedf6f5950845faa", - "log_index": 113, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "65561298781", - "validTo": 4294967295, - "appData": "0xb52f968c2364b75d91ef44f77cb77b55507b3088daf03b17488d1a01684ea34c", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001738b26a35f66a", - "app_data_hash": "0xb52f968c2364b75d91ef44f77cb77b55507b3088daf03b17488d1a01684ea34c", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":70,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000f43c2075d00000000000000000000000000000000000000000000000000000000ffffffffb52f968c2364b75d91ef44f77cb77b55507b3088daf03b17488d1a01684ea34c0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001738b26a35f66a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd0186dc05c85c872eecff1a51e7abd23976b11f0d23ba4f1b79b1c4f4ef03489ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11098367, - "block_timestamp": 1781921652, - "tx_hash": "0x6f782626aa8f5987d3c216aa49be32de2a71c33be8ebdec66549a85de693b2bb", - "log_index": 467, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "20000000000000000", - "buyAmount": "786330874799083854", - "validTo": 4294967295, - "appData": "0xd27786c2611afd1676af2aa3421b184c2ab99b82d85173b1f4762a20c0a9c720", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001738d96a35fe68", - "app_data_hash": "0xd27786c2611afd1676af2aa3421b184c2ab99b82d85173b1f4762a20c0a9c720", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":130,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b00000000000000000000000000000000000000000000000000470de4df8200000000000000000000000000000000000000000000000000000ae99bc3b452514e00000000000000000000000000000000000000000000000000000000ffffffffd27786c2611afd1676af2aa3421b184c2ab99b82d85173b1f4762a20c0a9c7200000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001738d96a35fe680000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe188b861712f3b3e0137ed5e16ebdc014655149d315132a013f33c43d7a284d6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11098372, - "block_timestamp": 1781921712, - "tx_hash": "0xfe7483e36dd3014739cf47e42f65952a1c1642ae0d953b59fccdbc954868b41f", - "log_index": 456, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "60000000000000000", - "buyAmount": "54741962398", - "validTo": 4294967295, - "appData": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001738db6a35fea7", - "app_data_hash": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":74,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b00000000000000000000000000000000000000000000000000d529ae9e8600000000000000000000000000000000000000000000000000000000000cbee00e9e00000000000000000000000000000000000000000000000000000000ffffffff8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d40000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001738db6a35fea70000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd13cad5b99607561527d31a2a4e792a840fe14802c375bfd39780e30b377e72dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11098766, - "block_timestamp": 1781926452, - "tx_hash": "0xa7bfec99b054d2a10a7c5f7f0d4bc0e3c782d336a5aaaa6a6468c7ec4549a411", - "log_index": 480, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "sellAmount": "30300000000000000", - "buyAmount": "1204404047601920333", - "validTo": 4294967295, - "appData": "0x3590a66c701fcdb35a11937f48367bbd156122d02b8f42ab2f8ca05e25ebbbde", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001739016a361123", - "app_data_hash": "0x3590a66c701fcdb35a11937f48367bbd156122d02b8f42ab2f8ca05e25ebbbde", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":93,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000006ec4672a460b414e661b3baf798071655edd1b46" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000006ec4672a460b414e661b3baf798071655edd1b46000000000000000000000000000000000000000000000000006ba5b080b1c00000000000000000000000000000000000000000000000000010b6e7199f5ae94d00000000000000000000000000000000000000000000000000000000ffffffff3590a66c701fcdb35a11937f48367bbd156122d02b8f42ab2f8ca05e25ebbbde0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001739016a3611230000000000000000000000000000000000000000" - } - }, - { - "uid": "0xbd8cc1614ee8fd124674679ddfe19aba28c193468d16007cacdc9514d7b37cb1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11099348, - "block_timestamp": 1781933460, - "tx_hash": "0xf378a0e66b05a4de4ce835d29fc353fb77848cf1dca1375633a79784becf0a49", - "log_index": 57, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "17309183751", - "validTo": 4294967295, - "appData": "0x181ab88b7ce3da71c65f8efaf01ce6be57a5fe04dfaca19cb20db530d724f751", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001739166a362c85", - "app_data_hash": "0x181ab88b7ce3da71c65f8efaf01ce6be57a5fe04dfaca19cb20db530d724f751", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":79,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000000407b52f0700000000000000000000000000000000000000000000000000000000ffffffff181ab88b7ce3da71c65f8efaf01ce6be57a5fe04dfaca19cb20db530d724f7510000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001739166a362c850000000000000000000000000000000000000000" - } - }, - { - "uid": "0x77446407735fe5b0ae0bd2d625607228b69457e4a3a0f60fa07db9fb552c14f5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11099353, - "block_timestamp": 1781933520, - "tx_hash": "0xdeda94d0c6fdcd804ffdf34e19feb8c1308863563345493a2cb219eb5052d409", - "log_index": 90, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "40503544582", - "validTo": 4294967295, - "appData": "0x2a968f27811fefb1db663c47e518110ddf0e85e5febfb36b778d114aa049a7ee", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001739196a362cc2", - "app_data_hash": "0x2a968f27811fefb1db663c47e518110ddf0e85e5febfb36b778d114aa049a7ee", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":80,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec50000000000000000000000000000000000000000000000000000000000096e330b0600000000000000000000000000000000000000000000000000000000ffffffff2a968f27811fefb1db663c47e518110ddf0e85e5febfb36b778d114aa049a7ee0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001739196a362cc20000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfec45a4275eecd8750852772f324a97655a43078d739e78b9b56dc7d6fd3be29ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11100012, - "block_timestamp": 1781941428, - "tx_hash": "0x3a96aafe78b6cbb771163812460843dbc6af7d8cb74fc5ecfdb2557e9c562102", - "log_index": 265, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "sellAmount": "480000000000000000", - "buyAmount": "404947959169", - "validTo": 4294967295, - "appData": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001739266a364ba3", - "app_data_hash": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":56,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c00000000000000000000000000000000000000000000000006a94d74f43000000000000000000000000000000000000000000000000000000000005e48c77d8100000000000000000000000000000000000000000000000000000000fffffffff3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001739266a364ba30000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd97a9fa007f04c621c6f05f4f6d5fd1411ec010cbe69c23833a85a80141091dbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11100016, - "block_timestamp": 1781941476, - "tx_hash": "0xd639e3705406b7041a7ff91b3b921f71aa66dfdcd53000a5e49201e8679b2362", - "log_index": 262, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "sellAmount": "480000000000000000", - "buyAmount": "54963695446", - "validTo": 4294967295, - "appData": "0x8221f9a297f6094090a3c1e3e697a7dbaa686e220f9f031e2d470bab58fc8e02", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001739296a364bd8", - "app_data_hash": "0x8221f9a297f6094090a3c1e3e697a7dbaa686e220f9f031e2d470bab58fc8e02", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":55,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c00000000000000000000000000000000000000000000000006a94d74f43000000000000000000000000000000000000000000000000000000000000ccc176f5600000000000000000000000000000000000000000000000000000000ffffffff8221f9a297f6094090a3c1e3e697a7dbaa686e220f9f031e2d470bab58fc8e020000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001739296a364bd80000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb1c28fdb942f26df6c51fabbe7fc993010d76a3a664e51f1e9d8f63f42f6944eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11102012, - "block_timestamp": 1781965476, - "tx_hash": "0x33f54e3dd7eac79c47006d737deabbc25e66dacdded0cccb66a792da80b0d765", - "log_index": 320, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "sellAmount": "31000000000000000", - "buyAmount": "1234033423748101677", - "validTo": 4294967295, - "appData": "0xbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa7885", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173a036a36a99a", - "app_data_hash": "0xbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa7885", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":90,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec000000000000000000000000000000000000000000000000006e2255f409800000000000000000000000000000000000000000000000000011202adc5776f62d00000000000000000000000000000000000000000000000000000000ffffffffbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa78850000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173a036a36a99a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x62ac84898792e53d0442baa16ac0400ad6919e2410849f6c93717e4ff95447cdba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11102181, - "block_timestamp": 1781967528, - "tx_hash": "0xd55f94b34a0e12a212afd7cfb35d1fed8cfba0588cea8b754eb8822eecbf2d08", - "log_index": 316, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xff602766ca1e4a861f2fddf3b5c20dc3b8b9131f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x58eb19ef91e8a6327fed391b51ae1887b833cc91", - "receiver": "0xff602766ca1e4a861f2fddf3b5c20dc3b8b9131f", - "sellAmount": "300000000000000000", - "buyAmount": "139111134", - "validTo": 4294967295, - "appData": "0xc6c7de5fee96b78890f2acef3e54a6e66fd39aabd7365a805332001ef9383684", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173a1a6a36b197", - "app_data_hash": "0xc6c7de5fee96b78890f2acef3e54a6e66fd39aabd7365a805332001ef9383684", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":55,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000ff602766ca1e4a861f2fddf3b5c20dc3b8b9131f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000058eb19ef91e8a6327fed391b51ae1887b833cc91000000000000000000000000ff602766ca1e4a861f2fddf3b5c20dc3b8b9131f0000000000000000000000000000000000000000000000000429d069189e000000000000000000000000000000000000000000000000000000000000084aaade00000000000000000000000000000000000000000000000000000000ffffffffc6c7de5fee96b78890f2acef3e54a6e66fd39aabd7365a805332001ef93836840000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173a1a6a36b1970000000000000000000000000000000000000000" - } - }, - { - "uid": "0x64fc616d2891e449f20b8581a97eb2f8177a3a643a54ad6e61160865301527fdba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11103512, - "block_timestamp": 1781983536, - "tx_hash": "0x518b57406fea01e928fdf1b04bf4aa3f1185ca16b6610e9cb1cb5eec3f422243", - "log_index": 176, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xa013a0b118eaaf9625db57faee049c993a8946d0", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xa013a0b118eaaf9625db57faee049c993a8946d0", - "sellAmount": "10000000000000000", - "buyAmount": "4398038534883464380", - "validTo": 4294967295, - "appData": "0xd285bda848c630af9355567cea7decda1776d2b18820cb23f9f5f866d1b14182", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173a3b6a36f02f", - "app_data_hash": "0xd285bda848c630af9355567cea7decda1776d2b18820cb23f9f5f866d1b14182", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":201,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000a013a0b118eaaf9625db57faee049c993a8946d0" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000a013a0b118eaaf9625db57faee049c993a8946d0000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000003d08f8bee43554bc00000000000000000000000000000000000000000000000000000000ffffffffd285bda848c630af9355567cea7decda1776d2b18820cb23f9f5f866d1b141820000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173a3b6a36f02f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x809b0200bf1559986965f26648093bb178a5e8d93a871479e293507967804586ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105542, - "block_timestamp": 1782007956, - "tx_hash": "0x3f0f3159d02feec68e8f364cd52d8f02086994fa4aae0c90cba929ef99481a79", - "log_index": 134, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "95017239796", - "validTo": 4294967295, - "appData": "0xbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa7885", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173aca6a374f8d", - "app_data_hash": "0xbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa7885", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":90,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000161f7804f400000000000000000000000000000000000000000000000000000000ffffffffbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa78850000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173aca6a374f8d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2efe5755ae5af528a0ea9c6abbb29004b54fc8aba78b211d84c9faa1a47c0255ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105545, - "block_timestamp": 1782008004, - "tx_hash": "0xfa33f1b3a8d45960aa6b9931f27d73a1a0d46cc4ad29d9253a37fc5c7e72af6c", - "log_index": 455, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "3981607377106476577", - "validTo": 4294967295, - "appData": "0x9ea3e68b82abb021c4f385fc144aec6bd527e0d87fecf5a9ce0d83582e2d080f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173ace6a374fb6", - "app_data_hash": "0x9ea3e68b82abb021c4f385fc144aec6bd527e0d87fecf5a9ce0d83582e2d080f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":88,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000374182d063819e2100000000000000000000000000000000000000000000000000000000ffffffff9ea3e68b82abb021c4f385fc144aec6bd527e0d87fecf5a9ce0d83582e2d080f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173ace6a374fb60000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa801952a7c389530f0a15d086c1413d7ae4a3a11797bb8b88eec493f21281d75ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105551, - "block_timestamp": 1782008076, - "tx_hash": "0x96c145763af1cd640c045c43c4d583fdde28711910764050bc7f8c253f9b9e92", - "log_index": 534, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "60000000000000000", - "buyAmount": "42300751995", - "validTo": 4294967295, - "appData": "0x576f12b7fac72155763d85432f15e2c21f395741a49fa596088c55b31a405395", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173ad46a375002", - "app_data_hash": "0x576f12b7fac72155763d85432f15e2c21f395741a49fa596088c55b31a405395", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":115,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b00000000000000000000000000000000000000000000000000d529ae9e86000000000000000000000000000000000000000000000000000000000009d952407b00000000000000000000000000000000000000000000000000000000ffffffff576f12b7fac72155763d85432f15e2c21f395741a49fa596088c55b31a4053950000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173ad46a3750020000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe51fcd2e7df1557304698271509113f9f2a3c9e3a5364cf626fdedb44452ae9eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105566, - "block_timestamp": 1782008256, - "tx_hash": "0x81d5a4301432dccb640a67e20beaeb61970fdaf542d6a4f3d31ff3e660271e9f", - "log_index": 568, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "50000000000000000", - "buyAmount": "44602720021", - "validTo": 4294967295, - "appData": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173ae46a3750ae", - "app_data_hash": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1000,\"smartSlippage\":false},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b00000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000000a62877f1500000000000000000000000000000000000000000000000000000000ffffffff11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173ae46a3750ae0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x87e7ed809508f4a9d5e22a7cd72a87cf2e2f7f6834f598124f27434e5858762dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105602, - "block_timestamp": 1782008688, - "tx_hash": "0xf48bb0f258acae8a7665bcccb42b045700236e96aee5c37794cd4ac45102314f", - "log_index": 424, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "60000000000000000", - "buyAmount": "36154471328", - "validTo": 4294967295, - "appData": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173b0b6a375263", - "app_data_hash": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1000,\"smartSlippage\":false},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b00000000000000000000000000000000000000000000000000d529ae9e860000000000000000000000000000000000000000000000000000000000086af973a000000000000000000000000000000000000000000000000000000000ffffffff11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173b0b6a3752630000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa249ff22a2aea55379a4ca3bff16b2fc84f8ac783dfb294058d1454dacc25734ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105613, - "block_timestamp": 1782008820, - "tx_hash": "0x5f875b42346bda28ccec431b211695cd2daf9003b6e2f23a596b9650ca4726ac", - "log_index": 45, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "22658593985", - "validTo": 4294967295, - "appData": "0x4c0e83f00de6ed4c930e407faa62dc1c98e5d2ee3dcd0b52a19618b44a240d92", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173b226a3752e9", - "app_data_hash": "0x4c0e83f00de6ed4c930e407faa62dc1c98e5d2ee3dcd0b52a19618b44a240d92", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":102,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec5000000000000000000000000000000000000000000000000000000000005468eb4c100000000000000000000000000000000000000000000000000000000ffffffff4c0e83f00de6ed4c930e407faa62dc1c98e5d2ee3dcd0b52a19618b44a240d920000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173b226a3752e90000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfbe5e123060e6ec9a05a90ffe41c37681b3ebd3db229b2f86b02498148b39033ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105618, - "block_timestamp": 1782008880, - "tx_hash": "0xf07091a7af051dd539aadcac6ac98bdbb066d9261dd32eca9395c96f1d3c14fb", - "log_index": 19, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "36753223075", - "validTo": 4294967295, - "appData": "0x1af14db8cf5e96e08af7db03bba51a03c50c655ae45339e2c87fee6263e6c3af", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173b2d6a37531c", - "app_data_hash": "0x1af14db8cf5e96e08af7db03bba51a03c50c655ae45339e2c87fee6263e6c3af", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":104,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec50000000000000000000000000000000000000000000000000000000000088ea9ada300000000000000000000000000000000000000000000000000000000ffffffff1af14db8cf5e96e08af7db03bba51a03c50c655ae45339e2c87fee6263e6c3af0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173b2d6a37531c0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x72bf47dc595715fa4afee539469f45dd53b2af9440f7d0f152240f0a6a6b2fe2ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105715, - "block_timestamp": 1782010044, - "tx_hash": "0xaf3cefb5ab0dfe553a9a7986eaf71ca23521968b48197bb5fbaa64756bf10ac0", - "log_index": 497, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "sellAmount": "30100000000000000", - "buyAmount": "1185911861215795099", - "validTo": 4294967295, - "appData": "0x4b4bfe67d3c3f5425674ae5b2eeb22a6e1b69b7a5ce8929ed70abc05576922cc", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173b406a3757b3", - "app_data_hash": "0x4b4bfe67d3c3f5425674ae5b2eeb22a6e1b69b7a5ce8929ed70abc05576922cc", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":128,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000006ec4672a460b414e661b3baf798071655edd1b46" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000006ec4672a460b414e661b3baf798071655edd1b46000000000000000000000000000000000000000000000000006aefca5fbd40000000000000000000000000000000000000000000000000001075348df6b0979b00000000000000000000000000000000000000000000000000000000ffffffff4b4bfe67d3c3f5425674ae5b2eeb22a6e1b69b7a5ce8929ed70abc05576922cc0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173b406a3757b30000000000000000000000000000000000000000" - } - }, - { - "uid": "0x1293c734a1404206c371788d9049b045f67cd0974763f13dc59741be4475d651ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11106911, - "block_timestamp": 1782024444, - "tx_hash": "0xea7c3e7e82fab71255b05a3fd1ac3b27adea9d354eba1fe313b6f97672a9b1fb", - "log_index": 295, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "130000000000000000", - "buyAmount": "163390285400", - "validTo": 4294967295, - "appData": "0x01c84758e6b385cb30af79f49ecd6aedc9c026f28f39289ae41f5017cbfe80db", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173b486a378ff0", - "app_data_hash": "0x01c84758e6b385cb30af79f49ecd6aedc9c026f28f39289ae41f5017cbfe80db", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":60,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da553297000000000000000000000000000000000000000000000000001cdda4faccd0000000000000000000000000000000000000000000000000000000000260ad1e65800000000000000000000000000000000000000000000000000000000ffffffff01c84758e6b385cb30af79f49ecd6aedc9c026f28f39289ae41f5017cbfe80db0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173b486a378ff00000000000000000000000000000000000000000" - } - }, - { - "uid": "0xeeb9580b065954ce8dc28fa542f2a032978b89b2405574b34f1c3622c5373d40ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11106932, - "block_timestamp": 1782024696, - "tx_hash": "0xd131443378834e6e9284b47ec6dfa04cabc089a7151c9e339791a8009ad22d2f", - "log_index": 138, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "800000000000000000", - "buyAmount": "2154051549465", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173b4b6a3790e3", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da55329700000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000001f5877a391900000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173b4b6a3790e30000000000000000000000000000000000000000" - } - }, - { - "uid": "0x42e1019e3235e8a095f147b2e7d2ef12d587879bd57b7bac439ebb3be4861ea2ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11107190, - "block_timestamp": 1782027792, - "tx_hash": "0xda3d66cc610d05bd714dabadb49295dce1339580a6868b2f864bb4443ec0de16", - "log_index": 122, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "700000000000000000", - "buyAmount": "454734723217", - "validTo": 4294967295, - "appData": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173b526a379d02", - "app_data_hash": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":52,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da553297000000000000000000000000000000000000000000000000009b6e64a8ec6000000000000000000000000000000000000000000000000000000000069e04d389100000000000000000000000000000000000000000000000000000000ffffffffacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce5190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173b526a379d020000000000000000000000000000000000000000" - } - }, - { - "uid": "0x863285b84360f355796171c54bb778e85c04c53d63e8860cb4ef58609d4fcacbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11108038, - "block_timestamp": 1782037968, - "tx_hash": "0x7be1597fcb954eb7775365a45b220f82a546c1059a7bd1cc1ada319f9f94ae31", - "log_index": 215, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9c1c3484ebc79a5543a3ad193573eb0250756e55", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x9c1c3484ebc79a5543a3ad193573eb0250756e55", - "sellAmount": "100000000000000000", - "buyAmount": "45796774228361504087", - "validTo": 4294967295, - "appData": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173bb06a37c4c8", - "app_data_hash": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":64,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009c1c3484ebc79a5543a3ad193573eb0250756e55" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000009c1c3484ebc79a5543a3ad193573eb0250756e55000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000027b8ed384dc40655700000000000000000000000000000000000000000000000000000000ffffffff4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173bb06a37c4c80000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3545ede8cbc6a9b3b88d9e6ad9edbd0219d3181a897143706a84bca84e4439b7ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11109421, - "block_timestamp": 1782054600, - "tx_hash": "0x8182fa21698411fdc10314c5b5e832be036ce0dc89c85edeef6e6e4b3d7c2dc8", - "log_index": 435, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "sellAmount": "30200000000000000", - "buyAmount": "1201985779697191530", - "validTo": 4294967295, - "appData": "0x3590a66c701fcdb35a11937f48367bbd156122d02b8f42ab2f8ca05e25ebbbde", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173bf46a3805c0", - "app_data_hash": "0x3590a66c701fcdb35a11937f48367bbd156122d02b8f42ab2f8ca05e25ebbbde", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":93,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec000000000000000000000000000000000000000000000000006b4abd7037800000000000000000000000000000000000000000000000000010ae4fb2bfec0a6a00000000000000000000000000000000000000000000000000000000ffffffff3590a66c701fcdb35a11937f48367bbd156122d02b8f42ab2f8ca05e25ebbbde0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173bf46a3805c00000000000000000000000000000000000000000" - } - }, - { - "uid": "0x98d90da1983292de38bab6263ced6dec408e53a1004111416da98405d84aa5caba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11112811, - "block_timestamp": 1782095304, - "tx_hash": "0xdb3d5afe4f455e19c7ee88d5de9ca61abc8fbfaddc799eb196f6f0452117101b", - "log_index": 425, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "618752569741", - "validTo": 4294967295, - "appData": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173cb06a38a4bb", - "app_data_hash": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1000,\"smartSlippage\":false},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000901086f18d00000000000000000000000000000000000000000000000000000000ffffffff11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173cb06a38a4bb0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xf60dc92a43e9f591ef82aba723a1ad64351ff2540abe477550e0ef68039272b5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11112823, - "block_timestamp": 1782095448, - "tx_hash": "0x52eba0f7e55d5bc81c57f4567ca36d5595f85dc7e22f40c0b2bcd12d6f72e838", - "log_index": 283, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "3613303531689246551", - "validTo": 4294967295, - "appData": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173cb66a38a549", - "app_data_hash": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1000,\"smartSlippage\":false},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000003225086b00007f5700000000000000000000000000000000000000000000000000000000ffffffff11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173cb66a38a5490000000000000000000000000000000000000000" - } - }, - { - "uid": "0xbad0af58df602423e8deda4247257dd0dfbb5c95290c39b2e1510d93b04da8eeba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11112886, - "block_timestamp": 1782096204, - "tx_hash": "0x3280cbaeb75b04c5bc32ea71aaae99fa60314a33473a4a4dae02adb83d5a324e", - "log_index": 464, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "sellAmount": "4000000000000000000", - "buyAmount": "342772475155", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173cb86a38a848", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f0000000000000000000000000000000000000000000000003782dace9d9000000000000000000000000000000000000000000000000000000000004fced4e51300000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173cb86a38a8480000000000000000000000000000000000000000" - } - }, - { - "uid": "0xda12aeee5dced139c3bae5ce4dfa895f98bf262b680a3040d3c2b46d91cc0e25ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11112894, - "block_timestamp": 1782096300, - "tx_hash": "0xbb19c81a8086304b41b5ab1e18a185adacfdfe7c3c5c4a96cd837ef45eb6dfcd", - "log_index": 344, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "sellAmount": "2000000000000000000", - "buyAmount": "2061946554520", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173cba6a38a8a5", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f0000000000000000000000000000000000000000000000001bc16d674ec80000000000000000000000000000000000000000000000000000000001e01597889800000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173cba6a38a8a50000000000000000000000000000000000000000" - } - }, - { - "uid": "0x271f933de098a2132649344a0dd67c23f2599ca7a5a7ea4c9a91558817b95301ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11113205, - "block_timestamp": 1782100044, - "tx_hash": "0x066f0b3dc1775240b7766368d8a4ec601ba80128bd8341292f8005e0ddc66955", - "log_index": 247, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "sellAmount": "51000000000000000", - "buyAmount": "1999916059146688219", - "validTo": 4294967295, - "appData": "0xf048416956033a926809e59ffb0674331774ef5c4d8de7586ea9df8605a76e7e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173cbc6a38b741", - "app_data_hash": "0xf048416956033a926809e59ffb0674331774ef5c4d8de7586ea9df8605a76e7e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":141,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000006ec4672a460b414e661b3baf798071655edd1b46" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000006ec4672a460b414e661b3baf798071655edd1b4600000000000000000000000000000000000000000000000000b5303ad38b80000000000000000000000000000000000000000000000000001bc1210f4e0996db00000000000000000000000000000000000000000000000000000000fffffffff048416956033a926809e59ffb0674331774ef5c4d8de7586ea9df8605a76e7e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173cbc6a38b7410000000000000000000000000000000000000000" - } - }, - { - "uid": "0x354f7970df826a66f6ea2df641c0a1abb0e71c265de80dd10ccbbc51c25c237bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11114331, - "block_timestamp": 1782113580, - "tx_hash": "0xee29cd369c7d8d9147e8f3c171208d937cbae1cffbcc0d0057f02b9b894dfff0", - "log_index": 443, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "873998305205", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d196a38ec1d", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000cb7e5bc7b500000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d196a38ec1d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x52d511d99409694546c306c9df74bb59cfc0419e2460a683ee5f8e1a351f1eacba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11114353, - "block_timestamp": 1782113844, - "tx_hash": "0xccba15ac5455e9f947705f376fe08240a1d05e89a6e95c76331f460a1b3b76cc", - "log_index": 346, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "4013221168023401979", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d286a38ed22", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000037b1d363ad1cf5fb00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d286a38ed220000000000000000000000000000000000000000" - } - }, - { - "uid": "0x39ba5342dedbd786d6d5238f993ae62c1d7498c946377818274386e5c0a52a1fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115217, - "block_timestamp": 1782124248, - "tx_hash": "0x8f47a18653c4473a1f85671b8872883b1f293d59b95dd7d12418711a1f3ad4df", - "log_index": 222, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "500000000000000000", - "buyAmount": "1519255794265", - "validTo": 4294967295, - "appData": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d666a3915d1", - "app_data_hash": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":52,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000000000161bab3b25900000000000000000000000000000000000000000000000000000000ffffffffacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce5190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d666a3915d10000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8f627309435bf115950a7dcd162c6ee385f636c5ef3ad474d487ca5e7dc0ce78ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115224, - "block_timestamp": 1782124332, - "tx_hash": "0xfed0b5ac295a5439ae0e09b4ffecd94ab8d0477eb9c47df8e81f6f33c6eec0bf", - "log_index": 97, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "500000000000000000", - "buyAmount": "5754867610445", - "validTo": 4294967295, - "appData": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d6b6a391625", - "app_data_hash": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":52,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000000000053be8d6f34d00000000000000000000000000000000000000000000000000000000ffffffffacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce5190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d6b6a3916250000000000000000000000000000000000000000" - } - }, - { - "uid": "0x4d0b40ff1543576448ce4e7d462ad9323c2e3485834488834159ab6f4bb5aa17ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115229, - "block_timestamp": 1782124392, - "tx_hash": "0x86be0587493da971fd9ea7083401fb6e55ed655a5bf6fec707f6c078eaa24831", - "log_index": 36, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "955998719306", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d6e6a391659", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000de95f6cd4a00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d6e6a3916590000000000000000000000000000000000000000" - } - }, - { - "uid": "0xdc658bc7e66649c2e40973fe0c1e694c26e9c4134a6fed714cff56a6d01beae6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115320, - "block_timestamp": 1782125508, - "tx_hash": "0xaaee2a3d3db76e20a5b1d5b76b77354894898f64550fae8620eb1856f3636e20", - "log_index": 209, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "sellAmount": "100000000000000000", - "buyAmount": "1040023733157", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d746a391aac", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b63" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b63000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000f2263ec3a500000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d746a391aac0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3ad5be4803641967af1c256ba8b40da541ba700cff392f651e241114a1bdf71cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115412, - "block_timestamp": 1782126624, - "tx_hash": "0xe9a56c5842ac6c29ed3b78917ef2591788623db90714cb62ecb4d54e6ab4fd46", - "log_index": 158, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "sellAmount": "10000000000000000", - "buyAmount": "176226363118", - "validTo": 4294967295, - "appData": "0x147eea762f679a0c14fe063e84a5727c48d889a8609139270bce02d3bdfc0a74", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d7a6a391f0d", - "app_data_hash": "0x147eea762f679a0c14fe063e84a5727c48d889a8609139270bce02d3bdfc0a74", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":173,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b63" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b63000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000000002907e8e6ee00000000000000000000000000000000000000000000000000000000ffffffff147eea762f679a0c14fe063e84a5727c48d889a8609139270bce02d3bdfc0a740000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d7a6a391f0d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa412cc0c38e2fa9c2d8dccd81b33cac5c930d504b4f500f1fb28a9db953160f8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115417, - "block_timestamp": 1782126684, - "tx_hash": "0xcb8693cfcad3c53ba712ce9d1eb4d89abb88b2cc1de5f61b02197c20c38a60c7", - "log_index": 136, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "sellAmount": "1000000000000000", - "buyAmount": "14620194769", - "validTo": 4294967295, - "appData": "0x029c6f6e7b2d7e35c3524639a75e7c8a97cfcb490cae24b80d1469d4ab89c22f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d7c6a391f51", - "app_data_hash": "0x029c6f6e7b2d7e35c3524639a75e7c8a97cfcb490cae24b80d1469d4ab89c22f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1826,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b63" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b6300000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000003676e77d100000000000000000000000000000000000000000000000000000000ffffffff029c6f6e7b2d7e35c3524639a75e7c8a97cfcb490cae24b80d1469d4ab89c22f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d7c6a391f510000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6cce9b6251c620ed8a28f41a6f21e0b76098872f8407cb44cefd7d73749d2e5eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115424, - "block_timestamp": 1782126768, - "tx_hash": "0xbf006e4c4549a2906eb4778aa2cac778c079b26e7c73b0cedd8c59bdb516c238", - "log_index": 162, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "sellAmount": "2000000000000000", - "buyAmount": "36719411249", - "validTo": 4294967295, - "appData": "0x479c8fc6ba3983d9a2fb3cbcaef70bb71e9d9802965b64327de2c835ac62677b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d7d6a391f98", - "app_data_hash": "0x479c8fc6ba3983d9a2fb3cbcaef70bb71e9d9802965b64327de2c835ac62677b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":775,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b63" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b6300000000000000000000000000000000000000000000000000071afd498d0000000000000000000000000000000000000000000000000000000000088ca5c03100000000000000000000000000000000000000000000000000000000ffffffff479c8fc6ba3983d9a2fb3cbcaef70bb71e9d9802965b64327de2c835ac62677b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d7d6a391f980000000000000000000000000000000000000000" - } - }, - { - "uid": "0x45902f9e5da88a7587b059006d050a6fca1f9679b170a08638fb58983ae43b78ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115429, - "block_timestamp": 1782126828, - "tx_hash": "0xab777e0ce49605885eaf3d907ba86b4a0853757ae0461155267a27f6955f9df7", - "log_index": 107, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "sellAmount": "1200000000000000", - "buyAmount": "17647826638", - "validTo": 4294967295, - "appData": "0x8f929d3fda5f29a4014f053e473b9dda0cbe68b663b1f17c0aef07f2c852a6f1", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d816a391fde", - "app_data_hash": "0x8f929d3fda5f29a4014f053e473b9dda0cbe68b663b1f17c0aef07f2c852a6f1", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1341,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b63" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b6300000000000000000000000000000000000000000000000000044364c5bb0000000000000000000000000000000000000000000000000000000000041be476ce00000000000000000000000000000000000000000000000000000000ffffffff8f929d3fda5f29a4014f053e473b9dda0cbe68b663b1f17c0aef07f2c852a6f10000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d816a391fde0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfac5eea4988f14149b3fda6316aa95d97e0bb0124b6851fec162e565dfbcbd1aba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115499, - "block_timestamp": 1782127668, - "tx_hash": "0xf5898a5673e51dcee6994c7d2f1270e33950d1a175bc591de2a532c1d7148266", - "log_index": 34, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "702648658085", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d876a392325", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000a3991fa8a500000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d876a3923250000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3f581643d744c168d68d0bde1794bc8a608ff9c94adc2aa2348a811fe2be80b8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115786, - "block_timestamp": 1782131112, - "tx_hash": "0x327a0c74827a827994113aa3fd8916a835eb5a346cf62e62f0a207f8cfe6514c", - "log_index": 75, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "100000000000000000", - "buyAmount": "961886171940", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d9e6a3930a0", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000dff4e2332400000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d9e6a3930a00000000000000000000000000000000000000000" - } - }, - { - "uid": "0x303a3415b13ab81f070eae814b64f2b88e607217566d51e8262c16c0e7f03a13ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115796, - "block_timestamp": 1782131232, - "tx_hash": "0xec1e95e6b9e24596d40b5951377eb11259e9e29e343e108c54f154995adf9889", - "log_index": 38, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "162165232975", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173da26a393118", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000025c1cd154f00000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173da26a3931180000000000000000000000000000000000000000" - } - }, - { - "uid": "0xc6bf93cb67fe38c1fe0ffdc948dd727d615390a5e00985d72c07fa533276bb2cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115816, - "block_timestamp": 1782131472, - "tx_hash": "0x3309a0d31bee2a4cb357f4a0e6507d884bccc784570eec993604c5118ef6c04e", - "log_index": 151, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "50000000000000000", - "buyAmount": "306331005501", - "validTo": 4294967295, - "appData": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173db56a393204", - "app_data_hash": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":76,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da553297000000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000004752c0323d00000000000000000000000000000000000000000000000000000000ffffffff4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173db56a3932040000000000000000000000000000000000000000" - } - }, - { - "uid": "0xc70930beb50bf3ff6fbc1d6b7a147e0fb3df858fba02c914ce5716bf84bb9a41ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11116414, - "block_timestamp": 1782138732, - "tx_hash": "0x22cd17ae6859c428952059348eae8de83aef966c537b5cd471a03f5112e6ea53", - "log_index": 377, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "sellAmount": "51000000000000000", - "buyAmount": "2038406969490573943", - "validTo": 4294967295, - "appData": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173e126a394e60", - "app_data_hash": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":74,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec00000000000000000000000000000000000000000000000000b5303ad38b80000000000000000000000000000000000000000000000000001c49e056bc2a8a7700000000000000000000000000000000000000000000000000000000ffffffff8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d40000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173e126a394e600000000000000000000000000000000000000000" - } - }, - { - "uid": "0xbdc6f0ae1177bf5a22f04b4346914dd434630a24589439e87e9c1da0840f51d8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11116631, - "block_timestamp": 1782141408, - "tx_hash": "0x1aa598a55578584c81af0e4ec6e975b0540d7d6ebd6561f6a990fa25d24cb518", - "log_index": 267, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "10000000000000000", - "buyAmount": "62579374031", - "validTo": 4294967295, - "appData": "0x516298ffeffb098bc0e75025ebfcafb11271e5c5a66f9fd72206ba32d8762b98", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173e176a3958cf", - "app_data_hash": "0x516298ffeffb098bc0e75025ebfcafb11271e5c5a66f9fd72206ba32d8762b98", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":181,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000000000e920577cf00000000000000000000000000000000000000000000000000000000ffffffff516298ffeffb098bc0e75025ebfcafb11271e5c5a66f9fd72206ba32d8762b980000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173e176a3958cf0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x0bab3b087f2ebff9d9bec12303e00106b11ce672254cc262b9753d4f7ccccb2aba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11116635, - "block_timestamp": 1782141456, - "tx_hash": "0xe3c1e24dc3218cada45564eb58284c1d938c562051eb882f5ea37bba5b946960", - "log_index": 230, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "486830996934", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173e196a395909", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000007159637dc600000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173e196a3959090000000000000000000000000000000000000000" - } - }, - { - "uid": "0x1916db8fb427ff2009035b790de6921d565e55c0e5e414031fad2fa1a0910cfcba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11116641, - "block_timestamp": 1782141528, - "tx_hash": "0x894f89569972d545e85d11122f6a1e2d3af186b00229a2962bb5829f8c363e91", - "log_index": 287, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "10000000000000000", - "buyAmount": "60405431878", - "validTo": 4294967295, - "appData": "0x1098c837e9e8f1cc5f74dacbc62ad06e7be2403abccf4d780f98235fd0ec0e28", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173e1c6a39594b", - "app_data_hash": "0x1098c837e9e8f1cc5f74dacbc62ad06e7be2403abccf4d780f98235fd0ec0e28", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":176,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000000000e1071be4600000000000000000000000000000000000000000000000000000000ffffffff1098c837e9e8f1cc5f74dacbc62ad06e7be2403abccf4d780f98235fd0ec0e280000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173e1c6a39594b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xda1c4056133c58c935a2d5e5db1262227cd85daef18c079b2402d795611922e3ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11116645, - "block_timestamp": 1782141576, - "tx_hash": "0xc16b9a2606101784d96a5d6458581c8cec7b3a69af3144e75b2fcf9ff1c8329b", - "log_index": 265, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "3976944831987662352", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173e1e6a39597e", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000003730f24101f33e1000000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173e1e6a39597e0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa2d2d863a63539bc183e9f426bc022ee3ee4747b044ede08b8f7d76b575bc0c2ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11116660, - "block_timestamp": 1782141756, - "tx_hash": "0x8ce7088ff1f1675c877e9bb298d05d3ebc22986856adc7bc5ce61d35f329df37", - "log_index": 314, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "20000000000000000", - "buyAmount": "124555889355", - "validTo": 4294967295, - "appData": "0xcedc1e3d136dc75c2bd17739a62a8c708d560809968f74232c1b08b0af6e39d1", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173e2a6a395a31", - "app_data_hash": "0xcedc1e3d136dc75c2bd17739a62a8c708d560809968f74232c1b08b0af6e39d1", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":125,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b00000000000000000000000000000000000000000000000000470de4df8200000000000000000000000000000000000000000000000000000000001d001c0acb00000000000000000000000000000000000000000000000000000000ffffffffcedc1e3d136dc75c2bd17739a62a8c708d560809968f74232c1b08b0af6e39d10000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173e2a6a395a310000000000000000000000000000000000000000" - } - } - ], - "twap_conditionals": [ - { - "owner": "0x8fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531", - "block_number": 11072304, - "block_timestamp": 1781608080, - "tx_hash": "0x4c6b13ffa0765d774f17c263dc179d71b7622021183b351fc40a18885d792ea3", - "log_index": 530, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed01d69c7", - "static_input": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000022ef08fc97d78971500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000013c680000000000000000000000000000000000000000000000000000000000000000053b2ce06c595d1a56022c064798b5b00cce5f1160f8b816bfcf15cee4de22f5c" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed01d69c700000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000022ef08fc97d78971500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000013c680000000000000000000000000000000000000000000000000000000000000000053b2ce06c595d1a56022c064798b5b00cce5f1160f8b816bfcf15cee4de22f5c" - } - }, - { - "owner": "0xf568a3a2dffd73c000e8e475b2d335a4a3818eba", - "block_number": 11072962, - "block_timestamp": 1781615976, - "tx_hash": "0x839f49ef34313dba42de16de12ca830f61996607c449b02761b8d494b4c67f0d", - "log_index": 239, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed096275b", - "static_input": "0x0000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f568a3a2dffd73c000e8e475b2d335a4a3818eba00000000000000000000000000000000000000000000000199650db3ca0600000000000000000000000000000000000000000000000000023b9992b2b9d55da10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000d3a8afc48166e63025b0693a4d107e03065bfce724eb84cd1f434f26fcd07d8e" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x000000000000000000000000f568a3a2dffd73c000e8e475b2d335a4a3818eba" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed096275b000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f568a3a2dffd73c000e8e475b2d335a4a3818eba00000000000000000000000000000000000000000000000199650db3ca0600000000000000000000000000000000000000000000000000023b9992b2b9d55da10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000d3a8afc48166e63025b0693a4d107e03065bfce724eb84cd1f434f26fcd07d8e" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073264, - "block_timestamp": 1781619600, - "tx_hash": "0x7e46f954c1bbec02bafd775a5069afdbf81b938caf47daa4b06127774541a513", - "log_index": 10, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0cd09dd", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000001635ed1d997914c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0cd09dd00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000001635ed1d997914c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073279, - "block_timestamp": 1781619780, - "tx_hash": "0x5dfc327e29429f03434ab982c6f70f1e343240804ebcb858c752e85e4445af11", - "log_index": 93, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0d0315f", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000001632f69d8357707000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000708000000000000000000000000000000000000000000000000000000000000000097d10c43cf244b44b6d46175d34a51815a8f55279e1c7f0d4fd381ac0afc6036" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0d0315f00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000001632f69d8357707000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000708000000000000000000000000000000000000000000000000000000000000000097d10c43cf244b44b6d46175d34a51815a8f55279e1c7f0d4fd381ac0afc6036" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073294, - "block_timestamp": 1781619960, - "tx_hash": "0x5aa4df996b0384f8e2d3fbde2821c2bb8c3a9388f057e2d296035ca7e7df34b0", - "log_index": 46, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0d2c99c", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000000163137c204f340e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0d2c99c00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000000163137c204f340e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073321, - "block_timestamp": 1781620284, - "tx_hash": "0x4794b4e2cca405505cfdf6c8f4469de8b7454a13a3389c3f54b965e1cf167d81", - "log_index": 233, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0d7e042", - "static_input": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a82500000000000000000000000000000000000000000000000001bc16d674ec80000000000000000000000000000000000000000000000000008b20032293f1f1dc0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0d7e04200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a82500000000000000000000000000000000000000000000000001bc16d674ec80000000000000000000000000000000000000000000000000008b20032293f1f1dc0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073356, - "block_timestamp": 1781620704, - "tx_hash": "0x87c715de54e2c36ab1aae02c90388b1166f6334f1734d9ebc6b54aeb0ce7c7af", - "log_index": 117, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0de3dd9", - "static_input": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a82500000000000000000000000000000000000000000000000001bc16d674ec8000000000000000000000000000000000000000000000000003278bee9bb1fb2a2c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0de3dd900000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a82500000000000000000000000000000000000000000000000001bc16d674ec8000000000000000000000000000000000000000000000000003278bee9bb1fb2a2c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073405, - "block_timestamp": 1781621292, - "tx_hash": "0xa410cfc7d8d8287786e43f5172a1871c7d061683a74b7b85176cd2ec9b23cb44", - "log_index": 126, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0e73bc3", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e80000000000000000000000000000000000000000000000000000bae23fea904871820000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0e73bc300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e80000000000000000000000000000000000000000000000000000bae23fea904871820000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073446, - "block_timestamp": 1781621784, - "tx_hash": "0x9568af5e403693595d71d787c9938d3f04d4eb9af4660c2abee0b1dcb24f4ad1", - "log_index": 43, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0eeb253", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000075ee57b4682dc5a90000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0eeb25300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000075ee57b4682dc5a90000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073496, - "block_timestamp": 1781622420, - "tx_hash": "0x32b5fd5f1aa575ae51789d02eb5461a55708b802b0968d3c9673cc41864c7f16", - "log_index": 34, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0f866e9", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000007923c0f707757f330000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0f866e900000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000007923c0f707757f330000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073515, - "block_timestamp": 1781622648, - "tx_hash": "0xeff06f6b268f1d5133afe919678737c691d84f7041ce272e133df7ac3b97adc1", - "log_index": 13, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0fb9e69", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000007a0a26e0be40e1040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0fb9e6900000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000007a0a26e0be40e1040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073530, - "block_timestamp": 1781622828, - "tx_hash": "0x1da00e00571a814a84a49a7676297e4ba036dd2f9652350350ca615de6eadffb", - "log_index": 40, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0fe9490", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000079f8685b73abff790000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0fe949000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000079f8685b73abff790000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073544, - "block_timestamp": 1781622996, - "tx_hash": "0x924e691724e69b550f096b4fa030d5611f65d0fa0329bc1cb558f4b28e959569", - "log_index": 33, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed1012832", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000079ffee55bcfdc4650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed101283200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000079ffee55bcfdc4650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073549, - "block_timestamp": 1781623056, - "tx_hash": "0x7f91012a0a6a3d4b4bca7d1b28b112a7dda601e338f41cd16f00fe0b8bc840d4", - "log_index": 86, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed1022269", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000000046b1b5e06d6b76000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000708000000000000000000000000000000000000000000000000000000000000000097d10c43cf244b44b6d46175d34a51815a8f55279e1c7f0d4fd381ac0afc6036" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed102226900000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000000046b1b5e06d6b76000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000708000000000000000000000000000000000000000000000000000000000000000097d10c43cf244b44b6d46175d34a51815a8f55279e1c7f0d4fd381ac0afc6036" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073775, - "block_timestamp": 1781625768, - "tx_hash": "0xbb04bcc55edac657ff1009e4e887c042f7d43f9f7b3c504c01804d881ced7152", - "log_index": 89, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed12b6d41", - "static_input": "0x000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b00000000000000000000000000000000000000000000000000000931649e68a4c81500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed12b6d4100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b00000000000000000000000000000000000000000000000000000931649e68a4c81500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073936, - "block_timestamp": 1781627712, - "tx_hash": "0x88ea9a74af5789332c1995d4bf472dbb302f8107271546f31626eedb343a4f7e", - "log_index": 69, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed13f711f", - "static_input": "0x000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b0000000000000000000000000000000000000000000000000000092ea31632e2c6f4e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed13f711f00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b0000000000000000000000000000000000000000000000000000092ea31632e2c6f4e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073944, - "block_timestamp": 1781627808, - "tx_hash": "0x400289179852b4c22161c739ef37a997d3020a2a06024d60e81ad60aef999e42", - "log_index": 115, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed14a9bec", - "static_input": "0x000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b00000000000000000000000000000000000000000000000000000930edb1bccc8bd560000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed14a9bec00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b00000000000000000000000000000000000000000000000000000930edb1bccc8bd560000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073956, - "block_timestamp": 1781627952, - "tx_hash": "0xc4f76e3538b294832843d14fe34dbc93f54af84bee6a4dab5357c196237b18dd", - "log_index": 21, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed14cc8bd", - "static_input": "0x000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b0000000000000000000000000000000000000000000000000000093641cdd84083dd30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed14cc8bd00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b0000000000000000000000000000000000000000000000000000093641cdd84083dd30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073964, - "block_timestamp": 1781628048, - "tx_hash": "0xcf31b328f91dd76c05a810cbc9542ecf72ed20a1ec7e26399e088f9643056265", - "log_index": 22, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed14e20e1", - "static_input": "0x000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b000000000000000000000000000000000000000000000000000009347806a5ef9104e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed14e20e100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b000000000000000000000000000000000000000000000000000009347806a5ef9104e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073970, - "block_timestamp": 1781628120, - "tx_hash": "0x35a62afad925d64ff440721c9a417301129e429f43b36514363fd93b0e293a76", - "log_index": 7, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed14f52ab", - "static_input": "0x000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b000000000000000000000000000000000000000000000000000000054f79dd2e6289c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000708000000000000000000000000000000000000000000000000000000000000000097d10c43cf244b44b6d46175d34a51815a8f55279e1c7f0d4fd381ac0afc6036" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed14f52ab00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b000000000000000000000000000000000000000000000000000000054f79dd2e6289c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000708000000000000000000000000000000000000000000000000000000000000000097d10c43cf244b44b6d46175d34a51815a8f55279e1c7f0d4fd381ac0afc6036" - } - }, - { - "owner": "0x8fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531", - "block_number": 11080666, - "block_timestamp": 1781708688, - "tx_hash": "0xc6bc316c3d858ed95725eafa2022e8475d69f26a50e3c9c58ab306354ce20bdc", - "log_index": 423, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed61c531a", - "static_input": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000c1db7d271eff8ea9d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000029400000000000000000000000000000000000000000000000000000000000000009464d1c818e1d41b4b7ee6591d5adb5099b91c6bb2a7930eb3876c22b45bccc6" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed61c531a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000c1db7d271eff8ea9d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000029400000000000000000000000000000000000000000000000000000000000000009464d1c818e1d41b4b7ee6591d5adb5099b91c6bb2a7930eb3876c22b45bccc6" - } - }, - { - "owner": "0x7bf140727d27ea64b607e042f1225680b40eca6a", - "block_number": 11089362, - "block_timestamp": 1781813256, - "tx_hash": "0xa3d8a36f8a7dd8b097635ac59249b908d3f634bf5ede87c9336619e319e4d02d", - "log_index": 380, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x000000000000000000000000000000000000000000000000000000006670f000", - "static_input": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb5900000000000000000000000014995a1118caf95833e923faf8dd155721cd53c200000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000025800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000007bf140727d27ea64b607e042f1225680b40eca6a" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000006670f00000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb5900000000000000000000000014995a1118caf95833e923faf8dd155721cd53c200000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000025800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - } - }, - { - "owner": "0x14995a1118caf95833e923faf8dd155721cd53c2", - "block_number": 11089503, - "block_timestamp": 1781814948, - "tx_hash": "0x04dd5835e26d316ca8ede3c2336d830e8db2dadc002c065acdce9b703343ea3e", - "log_index": 220, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019edc721bf0", - "static_input": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb00000000000000000000000014995a1118caf95833e923faf8dd155721cd53c2000000000000000000000000000000000000000000000000005406d7f3adf39b00000000000000000000000000000000000000000000000081addcecc64f158c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000089e1620e951d921cb524162fa1b553254f048059c313748f3ab15496bad98b6" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x00000000000000000000000014995a1118caf95833e923faf8dd155721cd53c2" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019edc721bf000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb00000000000000000000000014995a1118caf95833e923faf8dd155721cd53c2000000000000000000000000000000000000000000000000005406d7f3adf39b00000000000000000000000000000000000000000000000081addcecc64f158c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000089e1620e951d921cb524162fa1b553254f048059c313748f3ab15496bad98b6" - } - }, - { - "owner": "0x8fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531", - "block_number": 11093450, - "block_timestamp": 1781862588, - "tx_hash": "0xb2c8fbca82119ae1bff00087f298542f3b9f335e6b0efaddc8c1095f3ab4594c", - "log_index": 575, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019edf48bc65", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000c08de6fcb28b80000000000000000000000000000000000000000000000000000fbc448a07d1bfd2f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000eb4a5218a649e020f7a923ae88e634b00ea8907f3370917eaed68d517d7e5785" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019edf48bc6500000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000c08de6fcb28b80000000000000000000000000000000000000000000000000000fbc448a07d1bfd2f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000eb4a5218a649e020f7a923ae88e634b00ea8907f3370917eaed68d517d7e5785" - } - }, - { - "owner": "0x8fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531", - "block_number": 11116259, - "block_timestamp": 1782136860, - "tx_hash": "0x74a1b1eceb78c771b8086c82fc811651f32689debe1a573dfc94dc8e3681e3be", - "log_index": 121, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019eefa18f6f", - "static_input": "0x000000000000000000000000d3f3d46febcd4cdaa2b83799b7a5cdcb69d135de0000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531000000000000000000000000000000000000000000000000e4fbc69449f200000000000000000000000000000000000000000000000000014f44069598038f7700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000012c0000000000000000000000000000000000000000000000000000000000000000a139c89ba0059666dc63693ea74049365e130326583f4f512184425ce92f1ad0" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019eefa18f6f00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000d3f3d46febcd4cdaa2b83799b7a5cdcb69d135de0000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531000000000000000000000000000000000000000000000000e4fbc69449f200000000000000000000000000000000000000000000000000014f44069598038f7700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000012c0000000000000000000000000000000000000000000000000000000000000000a139c89ba0059666dc63693ea74049365e130326583f4f512184425ce92f1ad0" - } - }, - { - "owner": "0x8fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531", - "block_number": 11116489, - "block_timestamp": 1782139632, - "tx_hash": "0x612fda6a65556fbb90531f771c461bb490bc67a8f2c871e6bb5017d1d7eed7d9", - "log_index": 405, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019eefcbfc83", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000906a6d3d85e8a0000000000000000000000000000000000000000000000000000047df71ed148c4be00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000014a0000000000000000000000000000000000000000000000000000000000000000941043d9f9386d7c812dce62122f511201b5efd90e555a05afd4fc1816cd756b" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019eefcbfc8300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000906a6d3d85e8a0000000000000000000000000000000000000000000000000000047df71ed148c4be00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000014a0000000000000000000000000000000000000000000000000000000000000000941043d9f9386d7c812dce62122f511201b5efd90e555a05afd4fc1816cd756b" - } - } - ] -} \ No newline at end of file diff --git a/videre/crates/no-std-probe/Cargo.toml b/videre/crates/no-std-probe/Cargo.toml new file mode 100644 index 00000000..0a5c6279 --- /dev/null +++ b/videre/crates/no-std-probe/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "no-std-probe" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Compile-only #![no_std] probe: the IntentBody derive must expand without the consumer's std prelude." + +[lib] +# Never shipped. Living in the workspace keeps the derive's no_std +# contract under the workspace check/clippy gate. + +[lints] +workspace = true + +[dependencies] +# Source of the `IntentBody` derive and trait the probe enum implements. +videre-sdk = { path = "../videre-sdk" } diff --git a/videre/crates/no-std-probe/src/lib.rs b/videre/crates/no-std-probe/src/lib.rs new file mode 100644 index 00000000..e5bc2289 --- /dev/null +++ b/videre/crates/no-std-probe/src/lib.rs @@ -0,0 +1,44 @@ +//! `no_std` derive-hygiene probe for `#[derive(IntentBody)]`: the +//! expansion names only `::core` and the SDK's `__private` re-exports, +//! so it compiles under `#![no_std]` without an `extern crate alloc`. +//! The `tests` module round-trips the generated codec. + +#![no_std] +#![warn(missing_docs)] + +use videre_sdk::IntentBody; + +/// The probe schema: one published version over a bare byte payload. +#[derive(IntentBody, Clone, Debug, PartialEq, Eq)] +pub enum ProbeBody { + /// First published version. + V1(u8), +} + +#[cfg(test)] +mod tests { + use super::*; + use videre_sdk::BodyError; + + #[test] + fn round_trip() { + let body = ProbeBody::V1(7); + let bytes = body.to_bytes().expect("encode"); + // One-byte version tag (0) then the borsh u8 payload. + assert_eq!(bytes, [0u8, 7u8]); + assert_eq!(ProbeBody::from_bytes(&bytes).expect("decode"), body); + } + + #[test] + fn unknown_version() { + assert!(matches!( + ProbeBody::from_bytes(&[9, 7]), + Err(BodyError::UnknownVersion { version: 9 }), + )); + } + + #[test] + fn empty() { + assert!(matches!(ProbeBody::from_bytes(&[]), Err(BodyError::Empty))); + } +} diff --git a/videre/crates/videre-host/Cargo.toml b/videre/crates/videre-host/Cargo.toml new file mode 100644 index 00000000..f2fd4a08 --- /dev/null +++ b/videre/crates/videre-host/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "videre-host" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "The videre venue platform as one nexum-runtime extension: the venue-adapter provider kind, the venue registry service, the advisory egress-guard seam, and the videre:venue/client interface." + +[lints] +workspace = true + +[dependencies] +# The runtime seam this crate plugs into; the only nexum crate edge. +nexum-runtime = { path = "../../../nexum/crates/nexum-runtime" } +# Encoder for the opaque status body the host event stream carries; the +# registry lowers an adapter-reported status through it. +videre-status-body = { path = "../videre-status-body" } + +wasmtime.workspace = true +anyhow.workspace = true +thiserror.workspace = true +async-trait.workspace = true +futures.workspace = true +serde.workspace = true +tokio.workspace = true +toml.workspace = true +tracing.workspace = true + +[features] +# Test-only helpers: the direct `VenueRegistry::install_for_test` seam, so an +# out-of-crate test installs a mock adapter without the provider boot path. +# Off by default; the self dev-dependency below turns it on for this crate's +# own tests. +test-utils = [] + +[dev-dependencies] +# Self dev-dependency enabling `test-utils` for this crate's own test targets, +# so `cargo test -p videre-host` sees `install_for_test` without every +# invocation passing `--features test-utils`. +videre-host = { path = ".", features = ["test-utils"] } +nexum-runtime = { path = "../../../nexum/crates/nexum-runtime", features = ["test-utils"] } +nexum-tasks = { path = "../../../nexum/crates/nexum-tasks" } +tempfile.workspace = true diff --git a/videre/crates/videre-host/src/bindings.rs b/videre/crates/videre-host/src/bindings.rs new file mode 100644 index 00000000..df0a3625 --- /dev/null +++ b/videre/crates/videre-host/src/bindings.rs @@ -0,0 +1,257 @@ +//! WIT bindings for the venue platform, generated by +//! `wasmtime::component::bindgen!`. The shared `nexum:host` interfaces are +//! reused from the runtime bindings via `with`, and the client bindgen +//! remaps the `videre` types onto the adapter bindgen, so one Rust type +//! serves both faces. `PartialEq` is derived for polled-status comparison. + +/// The provider face: the `videre:venue/venue-adapter` world. Imports the +/// scoped transport (chain, messaging; HTTP is wasi:http) and exports the +/// `videre:venue/adapter` face plus `init`. +mod venue_adapter { + wasmtime::component::bindgen!({ + path: [ + "../../../wit/videre-value-flow", + "../../../wit/videre-types", + "../../../wit/nexum-host", + "../../../wit/videre-venue", + ], + world: "videre:venue/venue-adapter", + imports: { default: async }, + exports: { default: async }, + with: { + "nexum:host/types": nexum_runtime::bindings::nexum::host::types, + "nexum:host/chain": nexum_runtime::bindings::nexum::host::chain, + "nexum:host/messaging": nexum_runtime::bindings::nexum::host::messaging, + }, + additional_derives: [PartialEq], + }); +} + +pub use venue_adapter::VenueAdapter; + +/// The keeper-facing `videre:venue/client` import bound host-side. Reuses +/// the adapter bindings' videre types via `with`, so a module and an adapter +/// speak one `SubmitOutcome`/`VenueError`. Async: the `Host` impl awaits the +/// per-adapter mutex and the adapter's guest calls. +mod client_host { + wasmtime::component::bindgen!({ + inline: " + package videre:client-host; + world client-host { + import videre:venue/client@0.1.0; + } + ", + path: [ + "../../../wit/videre-value-flow", + "../../../wit/videre-types", + "../../../wit/nexum-host", + "../../../wit/videre-venue", + ], + imports: { default: async }, + with: { + "videre:value-flow/types": super::venue_adapter::videre::value_flow::types, + "videre:types/types": super::venue_adapter::videre::types::types, + }, + }); +} + +/// The host-bound client interface: the `Host` trait and `add_to_linker`. +pub use client_host::videre::venue::client; +/// The shared intent ontology at its plain spellings. +pub use venue_adapter::videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, +}; +/// The value-flow vocabulary the header is expressed in. +pub use venue_adapter::videre::value_flow::types as value_flow; + +/// Operator-log rendering of a wire `venue-error`, preserving the +/// rate-limit `retry-after-ms` hint. +pub(crate) fn venue_error_message(err: &VenueError) -> std::borrow::Cow<'_, str> { + use std::borrow::Cow; + match err { + VenueError::UnknownVenue => Cow::Borrowed("unknown venue"), + VenueError::InvalidBody(detail) => Cow::Owned(format!("invalid body: {detail}")), + VenueError::Unsupported => Cow::Borrowed("unsupported"), + VenueError::Denied(detail) => Cow::Owned(format!("denied: {detail}")), + VenueError::RateLimited(rate_limit) => match rate_limit.retry_after_ms { + Some(ms) => Cow::Owned(format!("rate limited, retry after {ms} ms")), + None => Cow::Borrowed("rate limited"), + }, + VenueError::Unavailable(detail) => Cow::Owned(format!("unavailable: {detail}")), + VenueError::Timeout => Cow::Borrowed("timeout"), + VenueError::InvalidReceipt => Cow::Borrowed("invalid receipt"), + VenueError::ReceiptMismatch => Cow::Borrowed("receipt mismatch"), + } +} + +#[cfg(test)] +mod display_smoke { + use super::{RateLimit, VenueError, venue_error_message}; + + #[test] + fn venue_error_message_keeps_the_rate_limit_hint() { + let hinted = VenueError::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); + assert_eq!( + venue_error_message(&hinted), + "rate limited, retry after 250 ms" + ); + let unhinted = VenueError::RateLimited(RateLimit { + retry_after_ms: None, + }); + assert_eq!(venue_error_message(&unhinted), "rate limited"); + } +} + +/// Identifier-hygiene smoke for `videre:value-flow`: naming every generated +/// type by its plain Rust spelling fails the build on a keyword collision +/// here rather than downstream. +#[cfg(test)] +mod value_flow_smoke { + wasmtime::component::bindgen!({ + inline: " + package videre:value-flow-smoke; + world smoke { + import videre:value-flow/types@0.1.0; + } + ", + path: ["../../../wit/videre-value-flow"], + }); + + #[test] + fn identifiers_bind_unescaped() { + use videre::value_flow::types::{Asset, AssetAmount, Erc20}; + + let erc20 = Erc20 { + token: vec![0u8; 20], + }; + let _ = Asset::Native; + let asset = Asset::Erc20(erc20); + + let amount = AssetAmount { + asset, + amount: Vec::new(), + }; + assert!(amount.amount.is_empty()); + } +} + +/// Identifier-hygiene and signature smoke for `videre:types`/`videre:venue`: +/// naming every generated type and pinning the five `client` signatures +/// fails the build on a keyword collision or signature change. +#[cfg(test)] +mod client_smoke { + wasmtime::component::bindgen!({ + inline: " + package videre:client-smoke; + world smoke { + import videre:venue/client@0.1.0; + } + ", + path: [ + "../../../wit/videre-value-flow", + "../../../wit/videre-types", + "../../../wit/nexum-host", + "../../../wit/videre-venue", + ], + }); + + use videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, + }; + use videre::value_flow::types::{Asset, AssetAmount}; + + struct DummyClient; + + impl videre::venue::client::Host for DummyClient { + fn quote(&mut self, _venue: String, _body: Vec) -> Result { + Err(VenueError::UnknownVenue) + } + + fn submit(&mut self, _venue: String, _body: Vec) -> Result { + Err(VenueError::UnknownVenue) + } + + fn observe(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { + Err(VenueError::UnknownVenue) + } + + fn status( + &mut self, + _venue: String, + _receipt: Vec, + ) -> Result { + Err(VenueError::UnknownVenue) + } + + fn cancel(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { + Err(VenueError::UnknownVenue) + } + } + + fn amount(bytes: Vec) -> AssetAmount { + AssetAmount { + asset: Asset::Native, + amount: bytes, + } + } + + #[test] + fn identifiers_bind_unescaped() { + use videre::venue::client::Host; + + let _ = AuthScheme::Eip1271; + let _ = AuthScheme::Eip712; + + let header = IntentHeader { + gives: amount(vec![1]), + wants: amount(Vec::new()), + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip712, + }; + assert!(header.wants.amount.is_empty()); + + let _ = IntentStatus::Pending; + let _ = IntentStatus::Open; + let _ = IntentStatus::Fulfilled; + let _ = IntentStatus::Cancelled; + let _ = IntentStatus::Expired; + + let tx = UnsignedTx { + chain: 1, + to: Vec::new(), + value: Vec::new(), + data: Vec::new(), + }; + let _ = SubmitOutcome::Accepted(Vec::new()); + let _ = SubmitOutcome::RequiresSigning(tx); + + let quotation = Quotation { + gives: amount(vec![1]), + wants: amount(Vec::new()), + fee: amount(Vec::new()), + valid_until_ms: 0, + }; + assert!(quotation.fee.amount.is_empty()); + + let _ = VenueError::UnknownVenue; + let _ = VenueError::InvalidBody(String::new()); + let _ = VenueError::Unsupported; + let _ = VenueError::Denied(String::new()); + let _ = VenueError::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); + let _ = VenueError::Unavailable(String::new()); + let _ = VenueError::Timeout; + + let mut client = DummyClient; + assert!(client.quote(String::new(), Vec::new()).is_err()); + assert!(client.submit(String::new(), Vec::new()).is_err()); + assert!(client.observe(String::new(), Vec::new()).is_err()); + assert!(client.status(String::new(), Vec::new()).is_err()); + assert!(client.cancel(String::new(), Vec::new()).is_err()); + } +} diff --git a/videre/crates/videre-host/src/client.rs b/videre/crates/videre-host/src/client.rs new file mode 100644 index 00000000..7fa09d31 --- /dev/null +++ b/videre/crates/videre-host/src/client.rs @@ -0,0 +1,51 @@ +//! `videre:venue/client`: the keeper-facing venue import. Every method +//! resolves the shared [`VenueRegistry`] from the store's service map and +//! delegates, metering against this store's module namespace. No registry +//! service resolves every call to `unknown-venue`. + +use std::sync::Arc; + +use nexum_runtime::host::component::RuntimeTypes; +use nexum_runtime::host::state::HostState; + +use crate::bindings::client::Host; +use crate::bindings::{IntentStatus, Quotation, SubmitOutcome, VenueError}; +use crate::registry::{VenueId, VenueRegistry}; + +/// The registry published under the videre service namespace. +fn registry(state: &HostState) -> Result, VenueError> { + state + .services + .get::(VenueRegistry::NAMESPACE) + .ok_or(VenueError::UnknownVenue) +} + +impl Host for HostState { + async fn quote(&mut self, venue: String, body: Vec) -> Result { + registry(self)? + .quote(&self.run.module, &VenueId::from(venue), body) + .await + } + + async fn submit(&mut self, venue: String, body: Vec) -> Result { + registry(self)? + .submit(&self.run.module, &VenueId::from(venue), body) + .await + } + + async fn observe(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { + registry(self)?.observe(&VenueId::from(venue), receipt) + } + + async fn status( + &mut self, + venue: String, + receipt: Vec, + ) -> Result { + registry(self)?.status(&VenueId::from(venue), receipt).await + } + + async fn cancel(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { + registry(self)?.cancel(&VenueId::from(venue), receipt).await + } +} diff --git a/videre/crates/videre-host/src/handshake.rs b/videre/crates/videre-host/src/handshake.rs new file mode 100644 index 00000000..7ef2d49a --- /dev/null +++ b/videre/crates/videre-host/src/handshake.rs @@ -0,0 +1,282 @@ +//! Install-time body-version handshake over the `[venue]` manifest +//! section: a keeper declares the one version it encodes, an adapter the +//! set it decodes, and a keeper boots only when every installed adapter +//! decodes its version. + +use std::collections::BTreeSet; + +use anyhow::{anyhow, bail}; +use nexum_runtime::host::extension::ProviderManifest; +use nexum_runtime::manifest::ExtensionSections; +use serde::Deserialize; +use tracing::error; + +use crate::registry::VenueAdapterKind; + +/// The manifest section the videre platform claims. +pub(crate) const SECTION: &str = "venue"; + +/// The claimed-section list handed to the runtime. +pub(crate) const SECTIONS: &[&str] = &[SECTION]; + +/// Keeper-side `[venue]`: the one body-schema version it encodes. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct KeeperSection { + body_version: u32, +} + +/// Adapter-side `[venue]`: the body-schema versions it decodes. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct AdapterSection { + body_versions: BTreeSet, +} + +/// Parse one `[venue]` value as `S`, tagging failures with the owner. +fn parse Deserialize<'de>>(owner: &str, value: &toml::Value) -> anyhow::Result { + value + .clone() + .try_into() + .map_err(|e| anyhow!("{owner} [venue]: {e}")) +} + +/// Admit one provider: a present `[venue]` section must be the adapter +/// shape with a non-empty version set. An absent section is admitted +/// (opt-out). +pub(crate) fn admit_provider(provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + let Some(value) = sections.get(SECTION) else { + return Ok(()); + }; + let section: AdapterSection = parse(provider, value)?; + if section.body_versions.is_empty() { + bail!("{provider} [venue]: body_versions must not be empty"); + } + Ok(()) +} + +/// An adapter's declared decode set: its `[venue] body_versions`, empty +/// when the section is absent. +pub(crate) fn declared_versions( + provider: &str, + sections: &ExtensionSections, +) -> anyhow::Result> { + let Some(value) = sections.get(SECTION) else { + return Ok(BTreeSet::new()); + }; + let section: AdapterSection = parse(provider, value)?; + Ok(section.body_versions) +} + +/// Assert an adapter's `body-versions()` export equals its manifest claim, +/// refusing the install on divergence. +pub(crate) fn verify_exported_versions( + provider: &str, + declared: &BTreeSet, + exported: Vec, +) -> anyhow::Result<()> { + let exported: BTreeSet = exported.into_iter().collect(); + if exported != *declared { + bail!( + "{provider} exports body versions {exported:?}; the manifest [venue] \ + body_versions declares {declared:?}" + ); + } + Ok(()) +} + +/// Membership predicate: a worker declaring `[venue] body_version` is +/// admitted only when every installed adapter's `body_versions` contains +/// it, so one non-decoding adapter refuses the keeper. An absent section is +/// admitted (opt-out). +pub(crate) fn admit_worker( + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], +) -> anyhow::Result<()> { + let Some(value) = sections.get(SECTION) else { + return Ok(()); + }; + let KeeperSection { body_version } = parse(worker, value)?; + let adapters: Vec<&ProviderManifest> = providers + .iter() + .filter(|p| p.kind == VenueAdapterKind::KIND) + .collect(); + if adapters.is_empty() { + return Err(refuse( + worker, + body_version, + "no venue adapter declares [venue] body_versions", + )); + } + for provider in adapters { + let Some(value) = provider.sections.get(SECTION) else { + return Err(refuse( + worker, + body_version, + &format!("{} declares no [venue] body_versions", provider.name), + )); + }; + let section: AdapterSection = parse(&provider.name, value)?; + if !section.body_versions.contains(&body_version) { + return Err(refuse( + worker, + body_version, + &format!("{} decodes {:?}", provider.name, section.body_versions), + )); + } + } + Ok(()) +} + +/// Log and build the refusal for one keeper/adapter pairing. +fn refuse(worker: &str, body_version: u32, decoded: &str) -> anyhow::Error { + error!( + keeper = %worker, + body_version, + %decoded, + "body-version handshake refused the keeper/adapter pair", + ); + anyhow!("keeper {worker} encodes body version {body_version}; {decoded}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sections(toml: &str) -> ExtensionSections { + let table: toml::Table = toml.parse().expect("parse"); + table.into_iter().collect() + } + + fn adapter(name: &str, toml: &str) -> ProviderManifest { + ProviderManifest { + name: name.to_owned(), + kind: VenueAdapterKind::KIND, + sections: sections(toml), + } + } + + /// A keeper whose version every installed adapter decodes is admitted. + #[test] + fn matching_pair_is_admitted() { + let keeper = sections("[venue]\nbody_version = 2"); + let adapters = [ + adapter("cow", "[venue]\nbody_versions = [1, 2]"), + adapter("uni", "[venue]\nbody_versions = [2, 3]"), + ]; + admit_worker("keeper", &keeper, &adapters).expect("admitted"); + } + + /// A keeper whose version no adapter decodes is refused, and the + /// refusal names the version and the declared set. + #[test] + fn mismatched_pair_is_refused() { + let keeper = sections("[venue]\nbody_version = 2"); + let adapters = [adapter("cow", "[venue]\nbody_versions = [1]")]; + let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); + let msg = err.to_string(); + assert!(msg.contains("body version 2"), "{msg}"); + assert!(msg.contains("cow decodes {1}"), "{msg}"); + } + + /// One non-decoding adapter refuses the keeper even when another decodes + /// its version. + #[test] + fn one_non_decoding_adapter_refuses_the_keeper() { + let keeper = sections("[venue]\nbody_version = 2"); + let adapters = [ + adapter("cow", "[venue]\nbody_versions = [1, 2]"), + adapter("uni", "[venue]\nbody_versions = [1]"), + ]; + let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); + let msg = err.to_string(); + assert!(msg.contains("body version 2"), "{msg}"); + assert!(msg.contains("uni decodes {1}"), "{msg}"); + } + + /// A declaring keeper with no installed venue adapter is refused. + #[test] + fn undeclared_adapters_refuse_a_declaring_keeper() { + let keeper = sections("[venue]\nbody_version = 1"); + let err = admit_worker("keeper", &keeper, &[]).expect_err("refused"); + assert!(err.to_string().contains("no venue adapter declares")); + } + + /// An installed adapter without a `[venue]` section refuses a + /// declaring keeper: its decode set is undeclared, not universal. + #[test] + fn a_section_less_adapter_refuses_a_declaring_keeper() { + let keeper = sections("[venue]\nbody_version = 1"); + let adapters = [adapter("cow", "")]; + let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); + assert!(err.to_string().contains("cow declares no [venue]")); + } + + /// A provider of another kind never satisfies the membership check. + #[test] + fn other_provider_kinds_are_ignored() { + let keeper = sections("[venue]\nbody_version = 1"); + let mut other = adapter("oracle", "[venue]\nbody_versions = [1]"); + other.kind = "price-oracle"; + admit_worker("keeper", &keeper, &[other]).expect_err("refused"); + } + + /// Workers and providers without a `[venue]` section are admitted. + #[test] + fn undeclared_sections_are_admitted() { + admit_worker("keeper", &ExtensionSections::new(), &[]).expect("worker admitted"); + admit_provider("venue", &ExtensionSections::new()).expect("provider admitted"); + } + + /// The wrong-side spelling fails loudly on both faces. + #[test] + fn wrong_side_spelling_is_refused() { + let keeper = sections("[venue]\nbody_versions = [1]"); + admit_worker("keeper", &keeper, &[]).expect_err("keeper with the adapter key"); + + let venue = sections("[venue]\nbody_version = 1"); + admit_provider("venue", &venue).expect_err("adapter with the keeper key"); + } + + /// An adapter declaring an empty decode set is refused at install. + #[test] + fn empty_adapter_set_is_refused() { + let venue = sections("[venue]\nbody_versions = []"); + let err = admit_provider("venue", &venue).expect_err("refused"); + assert!(err.to_string().contains("must not be empty")); + } + + /// A well-formed adapter declaration is admitted. + #[test] + fn adapter_declaration_is_admitted() { + let venue = sections("[venue]\nbody_versions = [1, 2]"); + admit_provider("venue", &venue).expect("admitted"); + } + + /// The exported set must equal the manifest claim exactly; either + /// direction of drift refuses the install. + #[test] + fn exported_versions_must_equal_the_manifest_claim() { + let declared = declared_versions("venue", §ions("[venue]\nbody_versions = [1, 2]")) + .expect("declared"); + verify_exported_versions("venue", &declared, vec![2, 1]).expect("equal sets"); + + let err = verify_exported_versions("venue", &declared, vec![1]).expect_err("narrower"); + assert!( + err.to_string().contains("exports body versions {1}"), + "{err}" + ); + verify_exported_versions("venue", &declared, vec![1, 2, 3]).expect_err("wider"); + } + + /// A section-less adapter must export an empty set: an undeclared + /// manifest with a declaring export is drift, not a default. + #[test] + fn a_section_less_adapter_must_export_no_versions() { + let declared = declared_versions("venue", &ExtensionSections::new()).expect("declared"); + assert!(declared.is_empty()); + verify_exported_versions("venue", &declared, Vec::new()).expect("both undeclared"); + verify_exported_versions("venue", &declared, vec![1]).expect_err("export-only drift"); + } +} diff --git a/videre/crates/videre-host/src/lib.rs b/videre/crates/videre-host/src/lib.rs new file mode 100644 index 00000000..2ff39410 --- /dev/null +++ b/videre/crates/videre-host/src/lib.rs @@ -0,0 +1,178 @@ +//! The videre venue platform as one [`nexum_runtime`] extension: the +//! venue-adapter provider kind, the [`VenueRegistry`] service, the advisory +//! [`EgressGuard`] seam, and the keeper-facing `videre:venue/client` +//! interface. A composition root wires it via +//! `builder.with_extensions([Arc::new(videre_host::platform(cfg))])`. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +pub mod bindings; +mod client; +mod handshake; +mod registry; + +use std::sync::Arc; +use std::time::Duration; + +use nexum_runtime::bindings::nexum::host::types::{CustomEvent, Event}; +use nexum_runtime::engine_config::EngineConfig; +use nexum_runtime::host::component::RuntimeTypes; +use nexum_runtime::host::extension::{ + EventSources, Extension, ExtensionEvent, ExtensionEventStream, HostService, ProviderKind, + ProviderManifest, +}; +use nexum_runtime::host::state::HostState; +use nexum_runtime::manifest::{ExtensionSections, NamespaceCaps}; +use tokio::sync::mpsc; +use tracing::warn; +use videre_status_body::INTENT_STATUS_KIND; +use wasmtime::component::{HasSelf, Linker}; + +pub use registry::{ + DuplicateVenue, EgressGuard, GuardContext, GuardVerdict, IntentStatusUpdate, VenueActor, + VenueAdapterKind, VenueId, VenueInvoker, VenueRegistry, VenueRegistryBuilder, +}; + +/// Status-poll channel buffer. +const STATUS_CHANNEL_BUF: usize = 64; + +/// The venue platform over the config-resolved quota and watch policy, +/// with the unit guard. +pub fn platform(config: &EngineConfig) -> Videre { + Videre::from_registry( + VenueRegistryBuilder::new(config.limits.quota()) + .with_watch_limit(config.limits.watch()) + .build(), + ) +} + +/// The videre platform as one runtime extension. +pub struct Videre { + registry: Arc, +} + +impl Videre { + /// Assemble over a pre-built registry, for a custom [`EgressGuard`] or + /// policy. + pub fn from_registry(registry: VenueRegistry) -> Self { + Self { + registry: Arc::new(registry), + } + } +} + +impl Extension for Videre { + fn namespace(&self) -> &'static str { + VenueRegistry::NAMESPACE + } + + /// Only `client` is a capability; the type-only packages need no + /// declaration. + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: "videre:venue/", + ifaces: &["client"], + } + } + + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()> { + bindings::client::add_to_linker::, HasSelf>>(linker, |state| { + state + })?; + Ok(()) + } + + fn service(&self) -> Option> { + Some(Arc::clone(&self.registry) as Arc) + } + + fn provider(&self) -> Option>> { + Some(Box::new(VenueAdapterKind)) + } + + fn manifest_sections(&self) -> &'static [&'static str] { + handshake::SECTIONS + } + + /// Adapter-side handshake shape check: a `[venue]` section must + /// declare a non-empty `body_versions` set. + fn admit_provider(&self, provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + handshake::admit_provider(provider, sections) + } + + /// The body-version membership predicate: a keeper declaring + /// `[venue] body_version` boots only when every installed venue + /// adapter's `[venue] body_versions` set contains it. + fn admit_worker( + &self, + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], + ) -> anyhow::Result<()> { + handshake::admit_worker(worker, sections, providers) + } + + fn subscriptions(&self) -> &'static [&'static str] { + &[INTENT_STATUS_KIND] + } + + /// The status-poll event source, opened only when a module subscribes + /// and a venue is installed. + fn events(&self, sources: &mut EventSources<'_>) -> anyhow::Result> { + if !sources.subscribed.contains(INTENT_STATUS_KIND) { + return Ok(Vec::new()); + } + let registry = (*self.registry).clone(); + if registry.venue_count() == 0 { + return Ok(Vec::new()); + } + let cadence = sources.config.limits.status_poll_interval(); + let (tx, rx) = mpsc::channel::(STATUS_CHANNEL_BUF); + sources.spawn(status_poll_task(registry, cadence, tx)); + let stream = futures::stream::unfold(rx, |mut rx| async move { + rx.recv().await.map(|item| (item, rx)) + }); + Ok(vec![Box::pin(stream)]) + } +} + +/// Poll loop behind [`Extension::events`]. Sleeps the cadence before each +/// poll; ends when the receiver drops. +async fn status_poll_task( + registry: VenueRegistry, + cadence: Duration, + tx: mpsc::Sender, +) { + loop { + tokio::time::sleep(cadence).await; + for update in registry.poll_status_transitions().await { + let attrs = vec![("venue", update.venue.clone())]; + // The transition rides the generic `custom` channel: the + // envelope is a version tag plus borsh, the status body its + // inner encoding. A keeper recovers it through + // `videre_sdk::event`. + let payload = match update.encode() { + Ok(payload) => payload, + Err(err) => { + warn!( + error = %err, + "intent-status envelope failed to encode - dropping transition", + ); + continue; + } + }; + let event = ExtensionEvent { + kind: INTENT_STATUS_KIND, + attrs, + event: Event::Custom(CustomEvent { + kind: INTENT_STATUS_KIND.to_owned(), + payload, + }), + }; + if tx.send(event).await.is_err() { + // Receiver dropped -> engine shutting down. + return; + } + } + } +} diff --git a/videre/crates/videre-host/src/registry.rs b/videre/crates/videre-host/src/registry.rs new file mode 100644 index 00000000..3f0efa46 --- /dev/null +++ b/videre/crates/videre-host/src/registry.rs @@ -0,0 +1,1828 @@ +//! The venue registry behind the keeper-facing `videre:venue/client` +//! import: resolves a venue id to its installed adapter and drives the +//! submit sequence (derive header, advisory [`EgressGuard`] seam, submit). +//! Status, cancel, and observe skip the header, guard, and quota. +//! +//! Each adapter sits behind its own [`ActorSlot`], so calls to one venue +//! serialise while calls to different venues run in parallel. A per-caller +//! quota gates every quote and submit; a decode failure is charged to the +//! calling module, so a caller feeding garbage exhausts its own budget. + +use std::collections::{HashMap, VecDeque}; +use std::fmt; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, anyhow}; +use async_trait::async_trait; +use futures::future::BoxFuture; +use nexum_runtime::bindings::nexum; +use nexum_runtime::engine_config::{SubmitQuota, WatchLimit}; +use nexum_runtime::host::actor::{ActorFault, ActorSlot, Liveness, SupervisedStore}; +use nexum_runtime::host::component::RuntimeTypes; +use nexum_runtime::host::extension::{ + HostService, Installed, ProviderInstance, ProviderKind, downcast_service, +}; +use nexum_runtime::host::state::HostState; +use tokio::sync::Mutex as AsyncMutex; +use tracing::{info, warn}; +use videre_status_body::StatusBody; +use wasmtime::Store; +use wasmtime::component::HasSelf; + +/// Status transition carried in the `custom` event payload. +pub use videre_status_body::IntentStatusUpdate; + +use crate::bindings::{ + IntentHeader, IntentStatus, Quotation, RateLimit, SubmitOutcome, VenueAdapter, VenueError, +}; + +/// Venue identifier an adapter registers under. Opaque beyond equality. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct VenueId(String); + +impl VenueId { + /// The id at its wire spelling. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From for VenueId { + fn from(id: String) -> Self { + Self(id) + } +} + +impl From<&str> for VenueId { + fn from(id: &str) -> Self { + Self(id.to_owned()) + } +} + +impl fmt::Display for VenueId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// Egress interposition run on the derived header between `derive-header` +/// and `submit`. Advisory-only: a `Deny` is logged, the submission proceeds. +pub trait EgressGuard: Send + Sync { + /// Decide whether the derived header may proceed to the adapter's submit. + fn check(&self, ctx: &GuardContext<'_>) -> GuardVerdict; +} + +/// The unit guard: allow every egress. +impl EgressGuard for () { + fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { + GuardVerdict::Allow + } +} + +/// What the guard sees. The raw body never reaches it, only the derived +/// header. +pub struct GuardContext<'a> { + /// Namespace of the calling module. + pub caller: &'a str, + /// Venue the submission is routed to. + pub venue: &'a VenueId, + /// Adapter-derived header for the body. + pub header: &'a IntentHeader, +} + +/// The guard's decision on one egress. +pub enum GuardVerdict { + /// Forward the submission to the adapter. + Allow, + /// Refuse with an operator-facing reason. Logged, not enforced. + Deny(String), +} + +/// Per-adapter invocation seam, reached behind an async mutex. Boxed +/// futures so heterogeneous adapters share one `dyn` slot. +pub trait VenueInvoker: Send { + /// Project the opaque body onto the stable header the guard runs on. + fn derive_header<'a>( + &'a mut self, + body: &'a [u8], + ) -> BoxFuture<'a, Result>; + + /// Price the opaque body at this adapter's venue. + fn quote<'a>(&'a mut self, body: &'a [u8]) -> BoxFuture<'a, Result>; + + /// Submit the opaque body to this adapter's venue. + fn submit<'a>(&'a mut self, body: &'a [u8]) + -> BoxFuture<'a, Result>; + + /// Report an intent's lifecycle state. + fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result>; + + /// Ask the venue to withdraw an intent. + fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>>; +} + +/// Live adapter: a [`SupervisedStore`] plus its `venue-adapter` bindings. +/// A guest trap is projected onto `unavailable`, never propagated. +pub struct VenueActor { + actor: SupervisedStore, + bindings: VenueAdapter, +} + +impl VenueActor { + /// Wrap an instantiated adapter store for routing, reporting traps on + /// the shared `liveness`. + pub fn new( + store: Store>, + bindings: VenueAdapter, + fuel_per_call: u64, + liveness: Liveness, + ) -> Self { + Self { + actor: SupervisedStore::new(store, fuel_per_call, liveness), + bindings, + } + } +} + +/// Project an actor fault onto `unavailable`, carrying the root cause only. +fn venue_fault(fault: ActorFault) -> VenueError { + VenueError::Unavailable(format!("adapter {fault}")) +} + +impl VenueInvoker for VenueActor { + fn derive_header<'a>( + &'a mut self, + body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_derive_header(store, body).await) + .await + .map_err(venue_fault)? + }) + } + + fn quote<'a>(&'a mut self, body: &'a [u8]) -> BoxFuture<'a, Result> { + Box::pin(async move { + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_quote(store, body).await) + .await + .map_err(venue_fault)? + }) + } + + fn submit<'a>( + &'a mut self, + body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_submit(store, body).await) + .await + .map_err(venue_fault)? + }) + } + + fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result> { + Box::pin(async move { + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_status(store, &receipt).await) + .await + .map_err(venue_fault)? + }) + } + + fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { + Box::pin(async move { + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_cancel(store, &receipt).await) + .await + .map_err(venue_fault)? + }) + } +} + +/// One installed adapter behind its serialising slot. +type AdapterSlot = ActorSlot; + +/// One installed venue: adapter slot plus shared liveness. A dead entry +/// stays installed, resolving to `unavailable` (not `unknown-venue`) until +/// the sweep restarts it. +struct InstalledVenue { + slot: AdapterSlot, + liveness: Liveness, +} + +/// Per-caller charge history, pruned to the quota window on each touch. +#[derive(Default)] +struct QuotaLedger { + per_caller: HashMap>, +} + +/// One receipt polled for status transitions. `last` starts `None` so the +/// first successful poll always reports. `expires_at` is the give-up +/// deadline, refreshed a `grace` window out whenever the venue is reachable; +/// `None` (arithmetic overflow) never expires. +struct WatchedIntent { + venue: VenueId, + receipt: Vec, + last: Option, + expires_at: Option, +} + +/// Whether a status is terminal, after which the receipt is dropped from +/// the watch. +fn is_terminal(status: IntentStatus) -> bool { + matches!( + status, + IntentStatus::Fulfilled | IntentStatus::Cancelled | IntentStatus::Expired + ) +} + +/// Lower a polled status onto the opaque status body. The registry attests +/// the lifecycle state only; proof and reason stay `None`. +fn status_body(status: IntentStatus) -> StatusBody { + use videre_status_body::IntentStatus as Lifecycle; + + let status = match status { + IntentStatus::Pending => Lifecycle::Pending, + IntentStatus::Open => Lifecycle::Open, + IntentStatus::Fulfilled => Lifecycle::Fulfilled, + IntentStatus::Cancelled => Lifecycle::Cancelled, + IntentStatus::Expired => Lifecycle::Expired, + }; + StatusBody { + status, + proof: None, + reason: None, + } +} + +/// Shared registry state behind the `Arc`. Every module store carries the +/// same handle, so all reach the same adapters and quota ledger. +struct VenueRegistryInner { + adapters: Mutex>, + guard: Arc, + quota: SubmitQuota, + ledger: Mutex, + watch_limit: WatchLimit, + /// Receipts under status watch; pruned on terminal status, expiry, or + /// [`WatchLimit`] overflow. + watched: Mutex>, +} + +/// The keeper-facing venue registry, cheap to clone. +#[derive(Clone)] +pub struct VenueRegistry { + inner: Arc, +} + +/// The registry is the venue-routing host service. +impl HostService for VenueRegistry {} + +impl VenueRegistry { + /// Service namespace: the videre extension's. + pub const NAMESPACE: &'static str = "videre"; + + /// Install an adapter under its venue id, sharing `liveness`. Rejects a + /// duplicate id while the incumbent is alive; replaces a dead incumbent + /// (the sweep restarting a trapped adapter). + pub(crate) fn install( + &self, + venue: VenueId, + liveness: Liveness, + invoker: impl VenueInvoker + 'static, + ) -> Result<(), DuplicateVenue> { + // Takes the adapter-map mutex only for the synchronous insert; never + // held across an await. + let mut adapters = self.inner.adapters.lock().expect("adapter map poisoned"); + if adapters.get(&venue).is_some_and(|v| v.liveness.is_alive()) { + return Err(DuplicateVenue { venue }); + } + adapters.insert( + venue, + InstalledVenue { + slot: Arc::new(AsyncMutex::new(invoker)), + liveness, + }, + ); + Ok(()) + } + + /// Test-only direct install, bypassing the provider boot path. + #[cfg(feature = "test-utils")] + pub fn install_for_test( + &self, + venue: VenueId, + liveness: Liveness, + invoker: impl VenueInvoker + 'static, + ) -> Result<(), DuplicateVenue> { + self.install(venue, liveness, invoker) + } + + /// Resolve a venue id to its slot: uninstalled is `unknown-venue`, + /// installed-but-dead is `unavailable` pending the restart sweep. + fn resolve(&self, venue: &VenueId) -> Result { + let adapters = self.inner.adapters.lock().expect("adapter map poisoned"); + let installed = adapters.get(venue).ok_or(VenueError::UnknownVenue)?; + if !installed.liveness.is_alive() { + return Err(VenueError::Unavailable(format!( + "venue {venue} is dead pending restart" + ))); + } + Ok(Arc::clone(&installed.slot)) + } + + /// Whether `caller` has budget left in the window. Prunes aged charges + /// but records none. + fn quota_admits(&self, caller: &str) -> bool { + let mut ledger = self.inner.ledger.lock().expect("quota ledger poisoned"); + let history = ledger.per_caller.entry(caller.to_owned()).or_default(); + prune(history, self.inner.quota.window); + (history.len() as u32) < self.inner.quota.max_charges + } + + /// Record one charge against `caller`'s budget. + fn charge(&self, caller: &str) { + let mut ledger = self.inner.ledger.lock().expect("quota ledger poisoned"); + let history = ledger.per_caller.entry(caller.to_owned()).or_default(); + prune(history, self.inner.quota.window); + history.push_back(Instant::now()); + } + + /// Submit an opaque body to `venue` for `caller`: resolve, quota-gate, + /// derive the header, run the advisory guard, forward to the adapter. + /// Charged once the header derives (ahead of guard and adapter), plus on + /// a decode failure; other derive-stage errors stay uncharged and + /// retryable. + pub async fn submit( + &self, + caller: &str, + venue: &VenueId, + body: Vec, + ) -> Result { + let slot = self.resolve(venue)?; + // Gate before touching the adapter so a quota-exhausted caller never + // reaches the adapter store or its mutex. Exhaustion is retryable + // once the window slides, so it is rate-limited, never denied. + if !self.quota_admits(caller) { + return Err(VenueError::RateLimited(RateLimit { + retry_after_ms: Some(window_ms(self.inner.quota.window)), + })); + } + let mut adapter = slot.lock().await; + let header = match adapter.derive_header(&body).await { + Ok(header) => header, + Err(e) => { + // Charge decode failures to the caller before the adapter is + // invoked again; other venue errors are not the caller's fault. + if matches!(e, VenueError::InvalidBody(_)) { + self.charge(caller); + } + return Err(e); + } + }; + let ctx = GuardContext { + caller, + venue, + header: &header, + }; + // Charge before the guard so an enforcing deny stays non-free. + self.charge(caller); + // Advisory-only checkpoint: a deny is logged, never enforced. + if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { + warn!( + caller, + venue = %venue, + reason, + "egress guard would deny - advisory-only, submission proceeds", + ); + } + let outcome = adapter.submit(&body).await?; + // An accepted receipt goes under status watch so subscribers see + // its transitions; requires-signing has no receipt to watch yet. + if let SubmitOutcome::Accepted(receipt) = &outcome { + self.watch(venue, receipt.clone()); + } + Ok(outcome) + } + + /// Price an opaque body at `venue` for `caller`. No header or guard, but + /// quota-gated: each quote spends one unit. + pub async fn quote( + &self, + caller: &str, + venue: &VenueId, + body: Vec, + ) -> Result { + let slot = self.resolve(venue)?; + if !self.quota_admits(caller) { + return Err(VenueError::RateLimited(RateLimit { + retry_after_ms: Some(window_ms(self.inner.quota.window)), + })); + } + self.charge(caller); + let mut adapter = slot.lock().await; + adapter.quote(&body).await + } + + /// Put an externally-obtained `(venue, receipt)` under status watch, for + /// receipts the registry never submitted. No header, guard, or quota; + /// watch-cap bounded. Idempotent. + pub fn observe(&self, venue: &VenueId, receipt: Vec) -> Result<(), VenueError> { + let _ = self.resolve(venue)?; + if self.watch(venue, receipt) { + Ok(()) + } else { + Err(VenueError::Unavailable("status watch set full".to_owned())) + } + } + + /// Put a `(venue, receipt)` under watch, reporting whether admitted. + /// Idempotent. Bounded: expired entries evict first; at the cap the new + /// watch is refused, not an existing one dropped. + fn watch(&self, venue: &VenueId, receipt: Vec) -> bool { + let (evicted, admitted) = { + let mut watched = self.inner.watched.lock().expect("watch list poisoned"); + let evicted = prune_expired(&mut watched); + if watched + .iter() + .any(|w| w.venue == *venue && w.receipt == receipt) + { + (evicted, true) + } else if watched.len() < self.inner.watch_limit.max_entries { + watched.push(WatchedIntent { + venue: venue.clone(), + receipt, + last: None, + expires_at: Instant::now().checked_add(self.inner.watch_limit.grace), + }); + (evicted, true) + } else { + (evicted, false) + } + }; + if evicted > 0 { + warn!(evicted, "expired status watches evicted"); + } + if !admitted { + warn!( + venue = %venue, + "status watch set full - transitions for this receipt will not be reported", + ); + } + admitted + } + + /// Number of receipts currently under status watch. + pub fn watched_count(&self) -> usize { + self.inner + .watched + .lock() + .expect("watch list poisoned") + .len() + } + + /// Poll every watched receipt and return the transitions (statuses + /// differing from the last reported; the first successful poll always + /// reports). A terminal status is reported once, then dropped. An + /// unreachable venue rides out against `grace` rather than refreshing it; + /// an entry past `grace` is evicted unpolled. + pub async fn poll_status_transitions(&self) -> Vec { + // Snapshot so the std mutex is never held across the guest await. + let (evicted, snapshot): (usize, Vec<(VenueId, Vec)>) = { + let mut watched = self.inner.watched.lock().expect("watch list poisoned"); + let evicted = prune_expired(&mut watched); + let snapshot = watched + .iter() + .map(|w| (w.venue.clone(), w.receipt.clone())) + .collect(); + (evicted, snapshot) + }; + if evicted > 0 { + warn!(evicted, "expired status watches evicted"); + } + let mut updates = Vec::new(); + for (venue, receipt) in snapshot { + // Venue unreachable: a dead venue (poisoned/mid-restart) fails + // to resolve. The watch is not refreshed but not dropped: it + // rides out against `grace` while the sweep restarts the + // adapter, and is pruned only if the outage outlasts it. + let Ok(slot) = self.resolve(&venue) else { + continue; + }; + let polled = { + let mut adapter = slot.lock().await; + adapter.status(receipt.clone()).await + }; + match polled { + // Reachable: `record_polled_status` refreshes the deadline. + Ok(status) => { + if let Some(update) = self.record_polled_status(&venue, &receipt, status) { + updates.push(update); + } + } + // Reachable adapter, errored poll (e.g. a venue-API outage): + // like a resolve failure, the watch rides out against + // `grace` rather than being refreshed or dropped. + Err(err) => { + warn!( + venue = %venue, + error = %crate::bindings::venue_error_message(&err), + "status poll failed - retrying on the next cadence", + ); + } + } + } + updates + } + + /// Fold one polled status into the watch entry: `Some(update)` on a + /// change. The venue answered, so every path refreshes the deadline (or + /// drops the entry on a clean terminal); an encode failure costs the + /// update, not the watch. `None` also covers an entry gone while the poll + /// was in flight. + fn record_polled_status( + &self, + venue: &VenueId, + receipt: &[u8], + status: IntentStatus, + ) -> Option { + let mut watched = self.inner.watched.lock().expect("watch list poisoned"); + let pos = watched + .iter() + .position(|w| w.venue == *venue && w.receipt == receipt)?; + let grace = self.inner.watch_limit.grace; + if watched[pos].last == Some(status) { + // No transition, but the venue answered: refresh the deadline. + watched[pos].expires_at = Instant::now().checked_add(grace); + return None; + } + match status_body(status).encode() { + Ok(body) => { + if is_terminal(status) { + watched.remove(pos); + } else { + watched[pos].last = Some(status); + watched[pos].expires_at = Instant::now().checked_add(grace); + } + Some(IntentStatusUpdate { + venue: venue.as_str().to_owned(), + receipt: receipt.to_vec(), + status: body, + }) + } + Err(err) => { + // A host-side encode bug, not a silent venue. Refresh the + // deadline (the venue is alive) and retry next cadence + // rather than letting the watch expire. + warn!( + venue = %venue, + error = %err, + "status body failed to encode - retrying on the next cadence", + ); + watched[pos].expires_at = Instant::now().checked_add(grace); + None + } + } + } + + /// Report an intent's lifecycle state. No header, guard, or quota. + pub async fn status( + &self, + venue: &VenueId, + receipt: Vec, + ) -> Result { + let slot = self.resolve(venue)?; + let mut adapter = slot.lock().await; + adapter.status(receipt).await + } + + /// Ask the venue to withdraw an intent. No header, guard, or quota. + pub async fn cancel(&self, venue: &VenueId, receipt: Vec) -> Result<(), VenueError> { + let slot = self.resolve(venue)?; + let mut adapter = slot.lock().await; + adapter.cancel(receipt).await + } + + /// Number of installed, routable adapters. + pub fn venue_count(&self) -> usize { + self.inner + .adapters + .lock() + .expect("adapter map poisoned") + .len() + } +} + +/// Provider kind that boots a `videre:venue/venue-adapter` component and +/// installs its actor in the registry. +pub struct VenueAdapterKind; + +impl VenueAdapterKind { + /// The manifest kind spelling. + pub const KIND: &'static str = "venue-adapter"; +} + +#[async_trait] +impl ProviderKind for VenueAdapterKind { + fn kind(&self) -> &'static str { + Self::KIND + } + + fn link(&self, linker: &mut wasmtime::component::Linker>) -> anyhow::Result<()> { + // The scoped transport only; the WASI base is the host's, and the + // withheld core interfaces fail instantiation. + nexum::host::chain::add_to_linker::, HasSelf>>(linker, |s| s)?; + nexum::host::messaging::add_to_linker::, HasSelf>>( + linker, + |s| s, + )?; + Ok(()) + } + + async fn install( + &self, + instance: ProviderInstance<'_, T>, + service: &Arc, + ) -> anyhow::Result { + let registry = downcast_service::(service) + .ok_or_else(|| anyhow!("the venue-adapter kind requires the venue-registry service"))?; + let ProviderInstance { + component, + linker, + mut store, + config, + sections, + fuel_per_call, + liveness, + } = instance; + let bindings = VenueAdapter::instantiate_async(&mut store, component, linker) + .await + .map_err(anyhow::Error::from) + .context("instantiate adapter")?; + // The venue id is the adapter's namespace: its manifest name. + let venue_id = VenueId::from(&*store.data().run.module); + // The manifest `[venue] body_versions` is the install-time + // authority the keeper handshake reads; the export must agree, + // so a manifest claiming versions the code does not decode never + // installs. + let declared = crate::handshake::declared_versions(venue_id.as_str(), sections)?; + let exported = bindings + .videre_venue_adapter() + .call_body_versions(&mut store) + .await + .map_err(anyhow::Error::from) + .context("read adapter body-versions")?; + // Post-instantiation, pre-init: an export cannot be called before + // instantiating, so unlike the pre-compile manifest-section + // predicates in `supervisor.rs` a buggy or malicious adapter fully + // instantiates, running any instantiation side effects, before this + // divergence check catches the mismatch. + crate::handshake::verify_exported_versions(venue_id.as_str(), &declared, exported)?; + match bindings + .call_init(&mut store, &config) + .await + .map_err(anyhow::Error::from)? + { + Ok(()) => info!(adapter = %venue_id, "adapter init succeeded"), + Err(e) => { + warn!( + adapter = %venue_id, + kind = nexum_runtime::host::error::fault_label(&e), + fault = %nexum_runtime::host::error::fault_message(&e), + "adapter init failed - loaded but marked dead", + ); + return Ok(Installed::Dead); + } + } + registry + .install( + venue_id.clone(), + liveness.clone(), + VenueActor::new(store, bindings, fuel_per_call, liveness), + ) + .with_context(|| format!("install adapter {venue_id}"))?; + Ok(Installed::Live) + } +} + +/// A quota window as whole milliseconds, saturating at `u64::MAX`. +fn window_ms(window: Duration) -> u64 { + u64::try_from(window.as_millis()).unwrap_or(u64::MAX) +} + +/// Drop watch entries whose eviction deadline has passed, returning how +/// many were evicted. +fn prune_expired(watched: &mut Vec) -> usize { + let now = Instant::now(); + let before = watched.len(); + watched.retain(|w| w.expires_at.is_none_or(|at| now < at)); + before - watched.len() +} + +/// Drop charge timestamps that have aged out of the window. +fn prune(history: &mut VecDeque, window: Duration) { + let now = Instant::now(); + while let Some(&front) = history.front() { + if now.duration_since(front) > window { + history.pop_front(); + } else { + break; + } + } +} + +/// Assembles a [`VenueRegistry`]'s policy: guard, quota, watch bounds. +/// Adapters install afterwards at provider boot. Guard defaults to the unit +/// guard. +pub struct VenueRegistryBuilder { + guard: Arc, + quota: SubmitQuota, + watch_limit: WatchLimit, +} + +impl VenueRegistryBuilder { + /// Builder with the given quota, the unit guard, and the default watch + /// limit. + pub fn new(quota: SubmitQuota) -> Self { + Self { + guard: Arc::new(()), + quota, + watch_limit: WatchLimit::default(), + } + } + + /// Override the guard policy. + pub fn with_guard(mut self, guard: Arc) -> Self { + self.guard = guard; + self + } + + /// Override the status-watch bounds. + pub fn with_watch_limit(mut self, watch_limit: WatchLimit) -> Self { + self.watch_limit = watch_limit; + self + } + + /// Freeze the builder into a shared registry. + pub fn build(self) -> VenueRegistry { + if self.quota.max_charges == 0 { + // A zero budget would refuse every submission; saturate up to one + // so a misconfigured quota still admits a single submission rather + // than bricking every venue. Mirrors the poison-policy clamp. + warn!("submission quota max_charges is 0; clamping to 1"); + } + let quota = SubmitQuota::new(self.quota.max_charges.max(1), self.quota.window); + if self.watch_limit.max_entries == 0 { + // A zero cap would refuse every watch; saturate up to one so a + // misconfigured bound still tracks a single receipt. + warn!("watch limit max_entries is 0; clamping to 1"); + } + let watch_limit = + WatchLimit::new(self.watch_limit.max_entries.max(1), self.watch_limit.expiry); + VenueRegistry { + inner: Arc::new(VenueRegistryInner { + adapters: Mutex::new(HashMap::new()), + guard: self.guard, + quota, + watch_limit, + ledger: Mutex::new(QuotaLedger::default()), + watched: Mutex::new(Vec::new()), + }), + } + } +} + +/// Two installed adapters claimed the same venue id. +#[derive(Debug, thiserror::Error)] +#[error("venue id {venue} is claimed by more than one installed adapter")] +pub struct DuplicateVenue { + /// The colliding venue id. + pub venue: VenueId, +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use videre_status_body::IntentStatus as Lifecycle; + + use crate::bindings::value_flow::{Asset, AssetAmount}; + use crate::bindings::{AuthScheme, IntentHeader, Settlement, UnsignedTx}; + use nexum_runtime::engine_config::WATCH_GRACE_MAX; + + use super::*; + + /// The venue id every test installs its stub adapter under. + fn cow() -> VenueId { + VenueId::from("cow") + } + + /// Decode an update's opaque status body. + fn decoded(update: &IntentStatusUpdate) -> StatusBody { + StatusBody::decode(&update.status).expect("status body decodes") + } + + /// A body carrying a bare lifecycle state. + fn plain(status: Lifecycle) -> StatusBody { + StatusBody { + status, + proof: None, + reason: None, + } + } + + /// Programmable adapter recording call counts, so routing is tested + /// without a wasmtime store. + #[derive(Default)] + struct StubCalls { + derive: AtomicUsize, + quote: AtomicUsize, + submit: AtomicUsize, + status: AtomicUsize, + cancel: AtomicUsize, + /// Highest overlapping invocation count observed; proves the mutex + /// serialises. + max_concurrency: AtomicUsize, + live: AtomicUsize, + } + + struct StubAdapter { + calls: Arc, + derive: Result, + submit: Result, + /// Accept each submission with its body as the receipt. + echo_receipt: bool, + /// Statuses served front-first by consecutive `status` calls; + /// once drained, every further call reports `open`. + status_script: VecDeque>, + } + + impl StubAdapter { + fn new(calls: Arc) -> Self { + Self { + calls, + derive: Ok(header()), + submit: Ok(SubmitOutcome::Accepted(b"receipt".to_vec())), + echo_receipt: false, + status_script: VecDeque::new(), + } + } + + fn with_receipt_echo(mut self) -> Self { + self.echo_receipt = true; + self + } + + fn with_derive(mut self, derive: Result) -> Self { + self.derive = derive; + self + } + + fn with_submit(mut self, submit: Result) -> Self { + self.submit = submit; + self + } + + fn with_status_script( + mut self, + script: impl IntoIterator>, + ) -> Self { + self.status_script = script.into_iter().collect(); + self + } + + async fn enter(&self) { + let live = self.calls.live.fetch_add(1, Ordering::SeqCst) + 1; + self.calls.max_concurrency.fetch_max(live, Ordering::SeqCst); + // Yield inside the critical section so any missing serialisation + // would let a second call observe `live == 2`. + tokio::task::yield_now().await; + self.calls.live.fetch_sub(1, Ordering::SeqCst); + } + } + + impl VenueInvoker for StubAdapter { + fn derive_header<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.calls.derive.fetch_add(1, Ordering::SeqCst); + self.enter().await; + self.derive.clone() + }) + } + + fn quote<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.calls.quote.fetch_add(1, Ordering::SeqCst); + self.enter().await; + Ok(quotation()) + }) + } + + fn submit<'a>( + &'a mut self, + body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.calls.submit.fetch_add(1, Ordering::SeqCst); + self.enter().await; + if self.echo_receipt { + return Ok(SubmitOutcome::Accepted(body.to_vec())); + } + self.submit.clone() + }) + } + + fn status(&mut self, _receipt: Vec) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.calls.status.fetch_add(1, Ordering::SeqCst); + self.status_script + .pop_front() + .unwrap_or(Ok(IntentStatus::Open)) + }) + } + + fn cancel(&mut self, _receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { + Box::pin(async move { + self.calls.cancel.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + } + } + + /// A guard that refuses every egress with a fixed reason. + struct DenyGuard; + impl EgressGuard for DenyGuard { + fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { + GuardVerdict::Deny("blocked by test policy".to_owned()) + } + } + + fn quotation() -> Quotation { + Quotation { + gives: AssetAmount { + asset: Asset::Native, + amount: vec![1], + }, + wants: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + fee: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + valid_until_ms: 1_700_000_000_000, + } + } + + fn header() -> IntentHeader { + IntentHeader { + gives: AssetAmount { + asset: Asset::Native, + amount: vec![1], + }, + wants: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip712, + } + } + + fn registry_with( + quota: SubmitQuota, + guard: Option>, + adapter: StubAdapter, + ) -> VenueRegistry { + let mut builder = VenueRegistryBuilder::new(quota); + if let Some(guard) = guard { + builder = builder.with_guard(guard); + } + let registry = builder.build(); + registry + .install(cow(), Liveness::default(), adapter) + .expect("install adapter"); + registry + } + + #[tokio::test] + async fn submit_round_trips_through_derive_guard_submit() { + let calls = Arc::new(StubCalls::default()); + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(calls.clone()), + ); + + let outcome = registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"receipt")); + assert_eq!(calls.derive.load(Ordering::SeqCst), 1); + assert_eq!(calls.submit.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn unknown_venue_is_rejected_without_touching_an_adapter() { + let calls = Arc::new(StubCalls::default()); + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(calls.clone()), + ); + + let err = registry + .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) + .await + .expect_err("unknown venue rejected"); + + assert!(matches!(err, VenueError::UnknownVenue)); + assert_eq!(calls.derive.load(Ordering::SeqCst), 0); + assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn guard_deny_is_advisory_and_does_not_block_submit() { + let calls = Arc::new(StubCalls::default()); + let registry = registry_with( + SubmitQuota::default(), + Some(Arc::new(DenyGuard)), + StubAdapter::new(calls.clone()), + ); + + let outcome = registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("advisory deny does not block"); + + // The seam runs on the derived header but only logs: derive ran and + // the submission still reached the adapter. + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"receipt")); + assert_eq!(calls.derive.load(Ordering::SeqCst), 1); + assert_eq!(calls.submit.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn repeated_guard_denies_exhaust_the_caller_quota() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(2, Duration::from_secs(3600)); + let registry = registry_with( + quota, + Some(Arc::new(DenyGuard)), + StubAdapter::new(calls.clone()), + ); + + // Each denied submit spends exactly one unit: the second is still + // admitted, so a deny is never double-charged. + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + // The deny loop is rate-limited at the gate, not free. + assert!(matches!( + registry.submit("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::RateLimited(_)) + )); + assert_eq!(calls.derive.load(Ordering::SeqCst), 2); + assert_eq!(calls.submit.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn quote_reaches_the_adapter_without_header_or_guard() { + let calls = Arc::new(StubCalls::default()); + // A denying guard proves quotes skip the seam: no value moves. + let registry = registry_with( + SubmitQuota::default(), + Some(Arc::new(DenyGuard)), + StubAdapter::new(calls.clone()), + ); + + let quoted = registry + .quote("mod-a", &cow(), b"body".to_vec()) + .await + .expect("quote succeeds"); + + assert_eq!(quoted, quotation()); + assert_eq!(calls.quote.load(Ordering::SeqCst), 1); + assert_eq!(calls.derive.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn quote_spends_the_caller_quota() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); + + assert!(registry.quote("mod-a", &cow(), b"b".to_vec()).await.is_ok()); + // The quote spent the only unit: both a further quote and a + // submit are stopped at the gate. + assert!(matches!( + registry.quote("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::RateLimited(_)) + )); + assert!(matches!( + registry.submit("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::RateLimited(_)) + )); + assert_eq!(calls.quote.load(Ordering::SeqCst), 1); + assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn quote_to_an_unknown_venue_is_rejected() { + let calls = Arc::new(StubCalls::default()); + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(calls.clone()), + ); + + assert!(matches!( + registry + .quote("mod-a", &VenueId::from("unlisted"), b"b".to_vec()) + .await, + Err(VenueError::UnknownVenue) + )); + assert_eq!(calls.quote.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn submission_quota_rate_limits_once_the_budget_is_spent() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(2, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); + + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + let err = registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .expect_err("third submit over quota"); + + // Exhaustion is retryable once the window slides: rate-limited + // carrying the window, never denied. + assert!(matches!( + err, + VenueError::RateLimited(rl) if rl.retry_after_ms == Some(3_600_000) + )); + // The over-quota call is stopped at the gate, so the adapter saw only + // the two admitted submits. + assert_eq!(calls.submit.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn quota_is_per_caller() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); + + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_err(), + "mod-a is over its own budget" + ); + // A different caller has its own budget. + assert!( + registry + .submit("mod-b", &cow(), b"b".to_vec()) + .await + .is_ok(), + "mod-b has an independent budget" + ); + } + + #[tokio::test] + async fn decode_failures_are_charged_and_stop_re_invoking_the_adapter() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); + let adapter = + StubAdapter::new(calls.clone()).with_derive(Err(VenueError::InvalidBody("bad".into()))); + let registry = registry_with(quota, None, adapter); + + // First garbage body: derive fails, the failure is charged. + let first = registry.submit("mod-a", &cow(), b"junk".to_vec()).await; + assert!(matches!(first, Err(VenueError::InvalidBody(_)))); + // Second: the charge from the decode failure exhausts the budget, so + // the caller is stopped at the gate and the adapter is not re-invoked. + let second = registry.submit("mod-a", &cow(), b"junk".to_vec()).await; + assert!(matches!(second, Err(VenueError::RateLimited(_)))); + assert_eq!( + calls.derive.load(Ordering::SeqCst), + 1, + "adapter derive-header was invoked exactly once", + ); + } + + #[tokio::test] + async fn non_decode_venue_errors_are_not_charged() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); + let adapter = StubAdapter::new(calls.clone()) + .with_derive(Err(VenueError::Unavailable("rpc down".into()))); + let registry = registry_with(quota, None, adapter); + + assert!(matches!( + registry.submit("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + // A venue-side failure did not spend the caller's budget: it may try + // again, so derive is reached a second time. + assert!(matches!( + registry.submit("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + assert_eq!(calls.derive.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn status_and_cancel_pass_through_without_quota() { + let calls = Arc::new(StubCalls::default()); + // A spent budget must not block reads: status and cancel are not + // submissions. + let quota = SubmitQuota::new(1, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); + + assert!(matches!( + registry.status(&cow(), b"r".to_vec()).await, + Ok(IntentStatus::Open) + )); + assert!(registry.cancel(&cow(), b"r".to_vec()).await.is_ok()); + assert_eq!(calls.status.load(Ordering::SeqCst), 1); + assert_eq!(calls.cancel.load(Ordering::SeqCst), 1); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_calls_to_one_adapter_are_serialised() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(1000, Duration::from_secs(3600)); + let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); + + let mut handles = Vec::new(); + for _ in 0..8 { + let registry = registry.clone(); + handles.push(tokio::spawn(async move { + let _ = registry.submit("mod-a", &cow(), b"b".to_vec()).await; + })); + } + for h in handles { + h.await.expect("task joins"); + } + // The adapter mutex is held across the guest await, so no two calls + // ever overlapped inside the adapter. + assert_eq!(calls.max_concurrency.load(Ordering::SeqCst), 1); + } + + #[test] + fn duplicate_venue_id_is_rejected() { + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); + let a = Arc::new(StubCalls::default()); + let b = Arc::new(StubCalls::default()); + registry + .install(cow(), Liveness::default(), StubAdapter::new(a)) + .expect("first install"); + let err = registry + .install(cow(), Liveness::default(), StubAdapter::new(b)) + .expect_err("second install collides"); + assert_eq!(err.venue, cow()); + } + + #[tokio::test] + async fn dead_venue_is_unavailable_not_unknown() { + let calls = Arc::new(StubCalls::default()); + let liveness = Liveness::default(); + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); + registry + .install(cow(), liveness.clone(), StubAdapter::new(calls.clone())) + .expect("install adapter"); + liveness.mark_dead(); + + // Temporarily dead resolves distinctly from never installed, and + // the dead adapter's slot is never entered. + assert!(matches!( + registry.submit("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + assert!(matches!( + registry + .submit("mod-a", &VenueId::from("unlisted"), b"b".to_vec()) + .await, + Err(VenueError::UnknownVenue) + )); + assert_eq!(calls.derive.load(Ordering::SeqCst), 0); + } + + #[test] + fn a_dead_incumbent_is_replaced_on_reinstall() { + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); + let liveness = Liveness::default(); + registry + .install( + cow(), + liveness.clone(), + StubAdapter::new(Arc::new(StubCalls::default())), + ) + .expect("first install"); + liveness.mark_dead(); + registry + .install( + cow(), + Liveness::default(), + StubAdapter::new(Arc::new(StubCalls::default())), + ) + .expect("a restart replaces the dead incumbent"); + assert_eq!(registry.venue_count(), 1); + } + + #[test] + fn zero_quota_saturates_to_one() { + let registry = + VenueRegistryBuilder::new(SubmitQuota::new(0, Duration::from_secs(60))).build(); + assert_eq!(registry.inner.quota.max_charges, 1); + } + + #[test] + fn zero_watch_cap_saturates_to_one() { + let registry = VenueRegistryBuilder::new(SubmitQuota::default()) + .with_watch_limit(WatchLimit::new(0, Duration::from_secs(60))) + .build(); + assert_eq!(registry.inner.watch_limit.max_entries, 1); + } + + // ── status watch + polling ──────────────────────────────────────── + + #[tokio::test] + async fn accepted_submission_goes_under_status_watch() { + let calls = Arc::new(StubCalls::default()); + let registry = registry_with(SubmitQuota::default(), None, StubAdapter::new(calls)); + + assert_eq!(registry.watched_count(), 0); + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + assert_eq!(registry.watched_count(), 1); + + // Re-submitting the same receipt does not double-watch it. + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + assert_eq!(registry.watched_count(), 1); + } + + #[tokio::test] + async fn observe_watches_an_externally_obtained_receipt() { + let calls = Arc::new(StubCalls::default()); + let adapter = + StubAdapter::new(calls.clone()).with_status_script([Ok(IntentStatus::Fulfilled)]); + let registry = registry_with(SubmitQuota::default(), None, adapter); + + registry + .observe(&cow(), b"onchain".to_vec()) + .expect("observe succeeds"); + // Re-observing keeps the existing entry. + registry + .observe(&cow(), b"onchain".to_vec()) + .expect("observe is idempotent"); + assert_eq!(registry.watched_count(), 1); + // No adapter work happened at observe time. + assert_eq!(calls.status.load(Ordering::SeqCst), 0); + assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + + // The watch polls like a submitted one: the terminal status + // reports once and prunes the entry. + let updates = registry.poll_status_transitions().await; + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].receipt, b"onchain"); + assert_eq!(decoded(&updates[0]), plain(Lifecycle::Fulfilled)); + assert_eq!(registry.watched_count(), 0); + } + + #[test] + fn observe_rejects_an_unknown_venue() { + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(Arc::new(StubCalls::default())), + ); + assert!(matches!( + registry.observe(&VenueId::from("unlisted"), b"r".to_vec()), + Err(VenueError::UnknownVenue) + )); + assert_eq!(registry.watched_count(), 0); + } + + #[test] + fn observe_of_a_dead_venue_is_unavailable() { + let liveness = Liveness::default(); + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); + registry + .install( + cow(), + liveness.clone(), + StubAdapter::new(Arc::new(StubCalls::default())), + ) + .expect("install adapter"); + liveness.mark_dead(); + assert!(matches!( + registry.observe(&cow(), b"r".to_vec()), + Err(VenueError::Unavailable(_)) + )); + assert_eq!(registry.watched_count(), 0); + } + + #[test] + fn observe_at_the_watch_cap_is_refused_typedly() { + let limit = WatchLimit::new(1, Duration::from_secs(3600)); + let registry = + watch_bounded_registry(limit, StubAdapter::new(Arc::new(StubCalls::default()))); + + registry.observe(&cow(), b"a".to_vec()).expect("admitted"); + let err = registry + .observe(&cow(), b"b".to_vec()) + .expect_err("overflow refused"); + assert!(matches!(err, VenueError::Unavailable(_))); + // The live watch is kept; the overflow was refused. + assert_eq!(registry.watched_count(), 1); + } + + #[tokio::test] + async fn requires_signing_outcome_is_not_watched() { + let calls = Arc::new(StubCalls::default()); + let adapter = + StubAdapter::new(calls).with_submit(Ok(SubmitOutcome::RequiresSigning(UnsignedTx { + chain: 1, + to: vec![0u8; 20], + value: Vec::new(), + data: Vec::new(), + }))); + let registry = registry_with(SubmitQuota::default(), None, adapter); + + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + // No receipt exists yet, so there is nothing to poll. + assert_eq!(registry.watched_count(), 0); + assert!(registry.poll_status_transitions().await.is_empty()); + } + + #[tokio::test] + async fn poll_reports_the_first_status_then_dedupes_repeats() { + let calls = Arc::new(StubCalls::default()); + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(calls.clone()), + ); + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + + // First poll: `last` is unset, so the current status reports. + let first = registry.poll_status_transitions().await; + assert_eq!(first.len(), 1); + assert_eq!(first[0].venue, "cow"); + assert_eq!(first[0].receipt, b"receipt"); + assert_eq!(decoded(&first[0]), plain(Lifecycle::Open)); + + // Second poll: same status, nothing to report. + assert!(registry.poll_status_transitions().await.is_empty()); + assert_eq!(calls.status.load(Ordering::SeqCst), 2); + assert_eq!(registry.watched_count(), 1, "open is not terminal"); + } + + #[tokio::test] + async fn poll_reports_each_transition_and_prunes_on_terminal() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls).with_status_script([ + Ok(IntentStatus::Pending), + Ok(IntentStatus::Pending), + Ok(IntentStatus::Open), + Ok(IntentStatus::Fulfilled), + ]); + let registry = registry_with(SubmitQuota::default(), None, adapter); + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + + let mut seen = Vec::new(); + for _ in 0..4 { + seen.extend(registry.poll_status_transitions().await); + } + let statuses: Vec = seen.iter().map(decoded).collect(); + assert_eq!( + statuses, + vec![ + plain(Lifecycle::Pending), + plain(Lifecycle::Open), + plain(Lifecycle::Fulfilled), + ], + "the repeated pending is deduplicated; each transition reports once", + ); + assert_eq!(registry.watched_count(), 0, "fulfilled prunes the watch"); + // A further poll has nothing left to ask the adapter about. + assert!(registry.poll_status_transitions().await.is_empty()); + } + + #[tokio::test] + async fn poll_failure_keeps_the_watch_for_the_next_cadence() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls) + .with_status_script([Err(VenueError::Unavailable("venue down".into()))]); + let registry = registry_with(SubmitQuota::default(), None, adapter); + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + + assert!(registry.poll_status_transitions().await.is_empty()); + assert_eq!( + registry.watched_count(), + 1, + "transient failure keeps the entry" + ); + + // The venue recovered: the next poll reports the current status. + let updates = registry.poll_status_transitions().await; + assert_eq!(updates.len(), 1); + assert_eq!(decoded(&updates[0]), plain(Lifecycle::Open)); + } + + /// A registry with the given watch bounds and one echo-receipt-capable + /// stub adapter under `cow`. + fn watch_bounded_registry(watch_limit: WatchLimit, adapter: StubAdapter) -> VenueRegistry { + let registry = VenueRegistryBuilder::new(SubmitQuota::default()) + .with_watch_limit(watch_limit) + .build(); + registry + .install(cow(), Liveness::default(), adapter) + .expect("install adapter"); + registry + } + + #[tokio::test] + async fn watch_cap_refuses_the_overflow_and_never_drops_live_watches() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls) + .with_receipt_echo() + .with_status_script([Ok(IntentStatus::Pending), Ok(IntentStatus::Pending)]); + let limit = WatchLimit::new(2, Duration::from_secs(3600)); + let registry = watch_bounded_registry(limit, adapter); + + for body in [b"a".to_vec(), b"b".to_vec(), b"c".to_vec()] { + registry + .submit("mod-a", &cow(), body) + .await + .expect("submit succeeds"); + } + assert_eq!(registry.watched_count(), 2, "the cap bounds the set"); + + // The live pending watches kept their tracking; only the overflow + // watch was refused. + let updates = registry.poll_status_transitions().await; + let receipts: Vec<&[u8]> = updates.iter().map(|u| u.receipt.as_slice()).collect(); + assert_eq!(receipts, vec![b"a".as_slice(), b"b".as_slice()]); + assert!( + updates + .iter() + .all(|u| decoded(u) == plain(Lifecycle::Pending)) + ); + } + + #[tokio::test] + async fn pending_polls_keep_a_live_watch_across_expiry_windows() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls).with_status_script([ + Ok(IntentStatus::Pending), + Ok(IntentStatus::Pending), + Ok(IntentStatus::Fulfilled), + ]); + let expiry = Duration::from_secs(1); + let registry = watch_bounded_registry(WatchLimit::new(8, expiry), adapter); + + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + let deadline_at = |registry: &VenueRegistry| { + let watched = registry.inner.watched.lock().expect("watch list poisoned"); + watched[0].expires_at + }; + let inserted = deadline_at(®istry); + + // Two pending polls, each pushing the deadline a full window out. + let mut reported = Vec::new(); + for _ in 0..2 { + reported.extend(registry.poll_status_transitions().await); + assert_eq!( + registry.watched_count(), + 1, + "a reporting venue stays watched" + ); + assert!( + deadline_at(®istry) > inserted, + "the poll refreshed the deadline" + ); + tokio::time::sleep(expiry * 7 / 10).await; + } + + // Well past the insert-time window, the terminal transition still + // reports and prunes the watch. + reported.extend(registry.poll_status_transitions().await); + let statuses: Vec = reported.iter().map(decoded).collect(); + assert_eq!( + statuses, + vec![plain(Lifecycle::Pending), plain(Lifecycle::Fulfilled)], + ); + assert_eq!(registry.watched_count(), 0); + } + + #[tokio::test] + async fn expired_watches_are_evicted_unpolled() { + let calls = Arc::new(StubCalls::default()); + let limit = WatchLimit::new(8, Duration::ZERO); + let registry = watch_bounded_registry(limit, StubAdapter::new(calls.clone())); + + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + assert_eq!(registry.watched_count(), 1); + + // The entry expired before the cadence: evicted without a venue call. + assert!(registry.poll_status_transitions().await.is_empty()); + assert_eq!(registry.watched_count(), 0); + assert_eq!(calls.status.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn expiry_frees_room_at_the_cap() { + let calls = Arc::new(StubCalls::default()); + let limit = WatchLimit::new(1, Duration::ZERO); + let registry = watch_bounded_registry(limit, StubAdapter::new(calls).with_receipt_echo()); + + registry + .submit("mod-a", &cow(), b"a".to_vec()) + .await + .expect("submit succeeds"); + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .expect("submit succeeds"); + + // The expired first watch was evicted at insert, admitting the second. + let watched = registry.inner.watched.lock().expect("watch list poisoned"); + assert_eq!(watched.len(), 1); + assert_eq!(watched[0].receipt, b"b"); + } + + #[test] + fn grace_derives_from_expiry_and_caps_at_a_day() { + // A short base window: grace is the fixed multiple of it. + assert_eq!( + WatchLimit::new(8, Duration::from_secs(900)).grace, + Duration::from_secs(1800), + ); + // A long base window: grace saturates at the ceiling. + assert_eq!( + WatchLimit::new(8, Duration::from_secs(86_400)).grace, + WATCH_GRACE_MAX, + ); + // An explicit grace overrides the derivation. + assert_eq!( + WatchLimit::with_grace(8, Duration::from_secs(900), Duration::from_secs(60)).grace, + Duration::from_secs(60), + ); + } + + /// Read the sole watch entry's give-up deadline. + fn sole_deadline(registry: &VenueRegistry) -> Option { + registry + .inner + .watched + .lock() + .expect("watch list poisoned") + .first() + .and_then(|e| e.expires_at) + } + + #[tokio::test] + async fn a_dead_venue_rides_out_without_refreshing_the_deadline() { + let calls = Arc::new(StubCalls::default()); + let registry = VenueRegistryBuilder::new(SubmitQuota::default()) + .with_watch_limit(WatchLimit::new(8, Duration::from_secs(3600))) + .build(); + let liveness = Liveness::default(); + registry + .install(cow(), liveness.clone(), StubAdapter::new(calls)) + .expect("install adapter"); + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + let inserted = sole_deadline(®istry); + + // Venue goes dead: resolve fails, so the poll neither reports nor + // refreshes - the watch rides out against grace instead of being + // dropped or having its deadline pushed out. + liveness.mark_dead(); + assert!(registry.poll_status_transitions().await.is_empty()); + assert_eq!( + registry.watched_count(), + 1, + "a dead venue rides out rather than being dropped", + ); + assert_eq!( + sole_deadline(®istry), + inserted, + "a resolve failure does not refresh the deadline", + ); + } + + #[tokio::test] + async fn an_errored_poll_rides_out_without_refreshing_the_deadline() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls) + .with_status_script([Err(VenueError::Unavailable("api down".into()))]); + let registry = + watch_bounded_registry(WatchLimit::new(8, Duration::from_secs(3600)), adapter); + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + let inserted = sole_deadline(®istry); + + // A reachable adapter whose poll errors (a venue-API outage) rides + // out exactly like a resolve failure: kept, deadline untouched. + assert!(registry.poll_status_transitions().await.is_empty()); + assert_eq!( + registry.watched_count(), + 1, + "an errored poll keeps the watch", + ); + assert_eq!( + sole_deadline(®istry), + inserted, + "an errored poll does not refresh the deadline", + ); + } + + #[test] + fn every_lifecycle_state_lowers_onto_the_status_body() { + for (wire, lowered) in [ + (IntentStatus::Pending, Lifecycle::Pending), + (IntentStatus::Open, Lifecycle::Open), + (IntentStatus::Fulfilled, Lifecycle::Fulfilled), + (IntentStatus::Cancelled, Lifecycle::Cancelled), + (IntentStatus::Expired, Lifecycle::Expired), + ] { + assert_eq!(status_body(wire), plain(lowered)); + } + } +} diff --git a/videre/crates/videre-host/tests/platform.rs b/videre/crates/videre-host/tests/platform.rs new file mode 100644 index 00000000..883891b1 --- /dev/null +++ b/videre/crates/videre-host/tests/platform.rs @@ -0,0 +1,1191 @@ +//! E2E coverage for the videre platform over the generic runtime seam: the +//! venue-adapter provider boot, the client -> registry -> adapter round +//! trip, the status-poll event source, and the trap-to-recovery sweeps. +//! Skips gracefully when a wasm artefact is absent. + +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use futures::future::BoxFuture; +use nexum_runtime::bindings::nexum; +use nexum_runtime::engine_config::{ + AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, PoisonLimitsSection, +}; +use nexum_runtime::host::component::ChainMethod; +use nexum_runtime::host::extension::{EventSources, Extension, ExtensionEvent, ProviderManifest}; +use nexum_runtime::host::state::HostState; +use nexum_runtime::manifest::{CapabilityRegistry, ExtensionSections, NamespaceCaps}; +use nexum_runtime::supervisor::{Supervisor, build_linker, build_provider_linker}; +use nexum_runtime::test_utils::{MockChainProvider, MockStateStore, MockTypes, mock_components}; +use videre_host::bindings::{ + IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueError, value_flow, +}; +use videre_host::{ + VenueAdapterKind, VenueId, VenueInvoker, VenueRegistry, VenueRegistryBuilder, Videre, platform, +}; +use wasmtime::component::Linker; + +/// The subscription kind the platform's status poller emits. +const INTENT_STATUS: &str = "intent-status"; + +// ── fixtures + assembly ─────────────────────────────────────────────── + +/// Path under the workspace root (the topmost ancestor with a `Cargo.toml`). +fn workspace_path(relative: &str) -> PathBuf { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + manifest + .ancestors() + .filter(|d| d.join("Cargo.toml").is_file()) + .last() + .unwrap_or(manifest) + .join(relative) +} + +/// Path to a module's `.wasm` artefact under the workspace target dir, +/// or `None` with a skip message when it is not built. +fn module_wasm_or_skip(module_name: &str) -> Option { + let artifact = module_name.replace('-', "_"); + let p = workspace_path(&format!("target/wasm32-wasip2/release/{artifact}.wasm")); + if p.exists() { + Some(p) + } else { + eprintln!( + "SKIP: {} not found - build with `cargo build -p {module_name} --target wasm32-wasip2 --release`", + p.display() + ); + None + } +} + +fn make_wasmtime_engine() -> wasmtime::Engine { + let mut config = wasmtime::Config::new(); + config.wasm_component_model(true); + config.consume_fuel(true); + wasmtime::Engine::new(&config).expect("wasmtime engine") +} + +/// The platform's extension slice, keeping the concrete handle for +/// event-source calls. +fn videre_assembly(videre: &Arc) -> Vec>> { + vec![Arc::clone(videre) as Arc>] +} + +fn make_linker( + engine: &wasmtime::Engine, + extensions: &[Arc>], +) -> Linker> { + build_linker::(engine, extensions).expect("build_linker") +} + +/// The registry the booted supervisor publishes. +fn registry_of(supervisor: &Supervisor) -> Arc { + supervisor + .services() + .get::(VenueRegistry::NAMESPACE) + .expect("registry service") +} + +/// A test block that drives dispatch and the dispatch-time sweeps. +fn block(chain_id: u64) -> nexum::host::types::Block { + nexum::host::types::Block { + chain_id, + number: 19_000_000, + hash: vec![0xab; 32], + timestamp: 1_700_000_000_000, + } +} + +/// Wrap a polled transition as the extension event the platform emits. +fn status_event(update: videre_host::IntentStatusUpdate) -> ExtensionEvent { + let attrs = vec![("venue", update.venue.clone())]; + let payload = update.encode().expect("encode intent-status envelope"); + ExtensionEvent { + kind: INTENT_STATUS, + attrs, + event: nexum::host::types::Event::Custom(nexum::host::types::CustomEvent { + kind: INTENT_STATUS.to_owned(), + payload, + }), + } +} + +// ── world contract ──────────────────────────────────────────────────── + +/// An adapter built through `#[videre_sdk::venue]` imports exactly the +/// scoped transport its manifest declares (`chain`). +#[test] +fn e2e_echo_venue_component_imports_equal_declared_capabilities() { + let Some(wasm) = module_wasm_or_skip("echo-venue") else { + return; + }; + let engine = make_wasmtime_engine(); + let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); + let imports: Vec = component + .component_type() + .imports(&engine) + .map(|(name, _)| name.to_owned()) + .collect(); + + // Capability-bearing imports resolve to exactly the declared set. + let registry = CapabilityRegistry::core(); + let caps: std::collections::BTreeSet<&str> = imports + .iter() + .filter_map(|name| registry.wit_import_to_cap(name)) + .collect(); + assert_eq!( + caps, + std::collections::BTreeSet::from(["chain"]), + "imports were: {imports:?}" + ); + + // No host key-material or persistence interface leaks in: an adapter + // structurally cannot reach messaging it never declared, local-store, + // identity, or logging. + assert!( + imports.iter().all(|name| !name.contains("messaging") + && !name.contains("local-store") + && !name.contains("identity") + && !name.contains("logging")), + "imports were: {imports:?}" + ); +} + +/// The shipped cow adapter's only capability is outbound HTTP, so it cannot +/// reach chain, messaging, key material, or persistence. +#[test] +fn e2e_cow_venue_component_imports_equal_declared_capabilities() { + let wasm = workspace_path("target/wasm32-wasip2/release/cow_venue.wasm"); + if !wasm.exists() { + eprintln!( + "SKIP: {} not found - build with `just build-cow-venue`", + wasm.display() + ); + return; + } + let engine = make_wasmtime_engine(); + let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); + let imports: Vec = component + .component_type() + .imports(&engine) + .map(|(name, _)| name.to_owned()) + .collect(); + + let registry = CapabilityRegistry::core(); + let caps: std::collections::BTreeSet<&str> = imports + .iter() + .filter_map(|name| registry.wit_import_to_cap(name)) + .collect(); + assert_eq!( + caps, + std::collections::BTreeSet::from(["http"]), + "imports were: {imports:?}" + ); + assert!( + imports.iter().all(|name| !name.contains("nexum:host/chain") + && !name.contains("messaging") + && !name.contains("local-store") + && !name.contains("identity") + && !name.contains("logging")), + "imports were: {imports:?}" + ); +} + +/// The venue-adapter provider linker binds only the scoped transport and +/// withholds the core-only interfaces, without a duplicate-definition clash. +#[tokio::test] +async fn provider_linker_assembles_with_scoped_transport() { + let engine = make_wasmtime_engine(); + build_provider_linker::(&engine, &VenueAdapterKind) + .expect("provider linker assembles"); +} + +// ── intent-status subscription E2E ──────────────────────────────────── + +/// Scripted registry adapter: accepts every submission with a fixed receipt +/// and serves statuses front-first; once drained, reports `open`. +struct ScriptedAdapter { + statuses: VecDeque, +} + +impl ScriptedAdapter { + fn new(statuses: impl IntoIterator) -> Self { + Self { + statuses: statuses.into_iter().collect(), + } + } +} + +fn native(bytes: Vec) -> value_flow::AssetAmount { + value_flow::AssetAmount { + asset: value_flow::Asset::Native, + amount: bytes, + } +} + +impl VenueInvoker for ScriptedAdapter { + fn derive_header<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(IntentHeader { + gives: native(vec![1]), + wants: native(Vec::new()), + settlement: Settlement { chain: 1 }, + authorisation: videre_host::bindings::AuthScheme::Eip712, + }) + }) + } + + fn quote<'a>(&'a mut self, _body: &'a [u8]) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(Quotation { + gives: native(vec![1]), + wants: native(Vec::new()), + fee: native(Vec::new()), + valid_until_ms: 1_700_000_000_000, + }) + }) + } + + fn submit<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { Ok(SubmitOutcome::Accepted(b"receipt".to_vec())) }) + } + + fn status(&mut self, _receipt: Vec) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(self.statuses.pop_front().unwrap_or(IntentStatus::Open)) }) + } + + fn cancel(&mut self, _receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { + Box::pin(async move { Ok(()) }) + } +} + +/// A registry with one scripted adapter installed under `cow`. +fn scripted_registry(adapter: ScriptedAdapter) -> VenueRegistry { + let registry = VenueRegistryBuilder::new(Default::default()).build(); + registry + .install_for_test( + VenueId::from("cow"), + nexum_runtime::host::actor::Liveness::default(), + adapter, + ) + .expect("install scripted adapter"); + registry +} + +/// Write a manifest subscribing the example module to intent-status +/// events from the `cow` venue. +fn echo_client_status_manifest(dir: &Path) -> PathBuf { + let manifest = dir.join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "echo-client" + +[capabilities] +required = ["client", "logging"] + +[[subscription]] +kind = "intent-status" +venue = "cow" +"#, + ) + .expect("write manifest"); + manifest +} + +fn intent_status_manifest(dir: &Path) -> PathBuf { + let manifest = dir.join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "example" + +[capabilities] +required = ["logging"] + +[[subscription]] +kind = "intent-status" +venue = "cow" +"#, + ) + .expect("write manifest"); + manifest +} + +/// Boot the example module against the given videre platform. +async fn boot_example(videre: &Arc, wasm: &Path, manifest: &Path) -> Supervisor { + let engine = make_wasmtime_engine(); + let extensions = videre_assembly(videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + let limits = ModuleLimits::default(); + Supervisor::boot_single( + &engine, + &linker, + wasm, + Some(manifest), + &components, + &limits, + &extensions, + None, + ) + .await + .expect("boot_single") +} + +/// A module subscribed to `intent-status` receives the polled transitions; +/// a transition outside its venue filter is not delivered. +#[tokio::test] +async fn e2e_intent_status_subscription_receives_polled_transitions() { + let Some(wasm) = module_wasm_or_skip("example") else { + return; + }; + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = intent_status_manifest(dir.path()); + + let registry = scripted_registry(ScriptedAdapter::new([ + IntentStatus::Pending, + IntentStatus::Fulfilled, + ])); + let videre = Arc::new(Videre::from_registry(registry.clone())); + let mut supervisor = boot_example(&videre, &wasm, &manifest).await; + assert!( + supervisor + .extension_subscription_kinds() + .contains(INTENT_STATUS) + ); + + // The registry watches the receipt of an accepted submission and polls + // the adapter's status export; each poll here observes a transition. + registry + .submit("test-caller", &VenueId::from("cow"), b"body".to_vec()) + .await + .expect("submit"); + + let mut delivered = 0; + for _ in 0..2 { + for update in registry.poll_status_transitions().await { + delivered += supervisor + .dispatch_extension_event(status_event(update)) + .await; + } + } + assert_eq!(delivered, 2, "pending then fulfilled, one subscriber each"); + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + + // A venue outside the module's filter is not delivered. + let foreign = videre_host::IntentStatusUpdate { + venue: "other".to_owned(), + receipt: b"receipt".to_vec(), + status: videre_status_body::StatusBody { + status: videre_status_body::IntentStatus::Open, + proof: None, + reason: None, + } + .encode() + .expect("encode"), + }; + assert_eq!( + supervisor + .dispatch_extension_event(status_event(foreign)) + .await, + 0 + ); +} + +/// ethflow-watcher (built by `#[videre_sdk::keeper]`) boots with its shipped +/// manifest and handles a delivered cow status transition without trapping. +#[tokio::test] +async fn e2e_ethflow_watcher_boots_and_handles_intent_status() { + let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { + return; + }; + let manifest = workspace_path("shepherd/modules/ethflow-watcher/module.toml"); + let videre = Arc::new(platform(&EngineConfig::default())); + let mut supervisor = boot_example(&videre, &wasm, &manifest).await; + assert_eq!(supervisor.alive_count(), 1); + assert!( + supervisor + .extension_subscription_kinds() + .contains(INTENT_STATUS) + ); + + let update = videre_host::IntentStatusUpdate { + venue: "cow".to_owned(), + receipt: vec![0xAB; 56], + status: videre_status_body::StatusBody { + status: videre_status_body::IntentStatus::Open, + proof: None, + reason: None, + } + .encode() + .expect("encode"), + }; + assert_eq!( + supervisor + .dispatch_extension_event(status_event(update)) + .await, + 1 + ); + assert_eq!(supervisor.alive_count(), 1); +} + +/// The event-loop wiring through the real seam: the platform's `events` +/// source opens, its poll task drives the supervisor, and the module's +/// handler observably ran. +#[tokio::test] +async fn e2e_intent_status_flows_through_the_event_loop() { + use nexum_tasks::{TaskManager, TaskSet}; + + let Some(wasm) = module_wasm_or_skip("echo-client") else { + return; + }; + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = echo_client_status_manifest(dir.path()); + + let registry = scripted_registry(ScriptedAdapter::new([])); + let videre = Arc::new(Videre::from_registry(registry.clone())); + + let engine = make_wasmtime_engine(); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + let logs = components.logs.clone(); + let limits = ModuleLimits::default(); + let mut supervisor = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &extensions, + None, + ) + .await + .expect("boot_single"); + + registry + .submit("test-caller", &VenueId::from("cow"), b"body".to_vec()) + .await + .expect("submit"); + + // A fast cadence so the 300 ms window sees the first poll. + let mut config = EngineConfig::default(); + config.limits.status_poll.interval_ms = Some(10); + + let manager = TaskManager::new(); + let executor = manager.executor(); + let mut tasks = TaskSet::new(); + let subscribed = supervisor.extension_subscription_kinds(); + let streams = { + let mut sources = EventSources::new( + &config, + supervisor.services(), + &subscribed, + &executor, + &mut tasks, + ); + Extension::::events(&*videre, &mut sources).expect("open event source") + }; + assert_eq!(streams.len(), 1, "one status-poll stream opened"); + + nexum_runtime::runtime::event_loop::run( + &mut supervisor, + Vec::new(), + Vec::new(), + streams, + tasks, + tokio::time::sleep(Duration::from_millis(300)), + ) + .await; + + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + let runs = logs.list_runs("echo-client"); + assert_eq!(runs.len(), 1, "one run recorded for the echo-client module"); + let page = logs.read(&runs[0].run, 0); + assert!( + page.records + .iter() + .any(|r| r.message.contains("intent status from venue cow")), + "the module's on_custom handler decoded the transition; records were: {:?}", + page.records + .iter() + .map(|r| r.message.as_str()) + .collect::>(), + ); +} + +/// With no subscriber or no installed venue, the platform opens no event +/// source. +#[tokio::test] +async fn event_source_stays_closed_without_subscribers_or_venues() { + use nexum_tasks::{TaskManager, TaskSet}; + + let config = EngineConfig::default(); + let manager = TaskManager::new(); + let executor = manager.executor(); + let services = nexum_runtime::host::extension::HostServices::default(); + + // A venue is installed but nothing subscribes. + let with_venue = Arc::new(Videre::from_registry(scripted_registry( + ScriptedAdapter::new([]), + ))); + let empty = std::collections::BTreeSet::new(); + let mut tasks = TaskSet::new(); + let mut sources = EventSources::new(&config, &services, &empty, &executor, &mut tasks); + let streams = Extension::::events(&*with_venue, &mut sources).expect("events"); + assert!(streams.is_empty(), "no subscriber, no stream"); + + // A subscriber exists but no venue is installed. + let no_venue = Arc::new(platform(&config)); + let subscribed: std::collections::BTreeSet = + std::iter::once(INTENT_STATUS.to_owned()).collect(); + let mut tasks = TaskSet::new(); + let mut sources = EventSources::new(&config, &services, &subscribed, &executor, &mut tasks); + let streams = Extension::::events(&*no_venue, &mut sources).expect("events"); + assert!(streams.is_empty(), "no venue, no stream"); +} + +// ── echo round trip ─────────────────────────────────────────────────── + +/// End to end over two real components: the echo-client module submits +/// through `videre:venue/client`, the host registry forwards to the +/// echo-venue adapter, and the module receives the fulfilled +/// `intent-status` polled back. +#[tokio::test] +async fn e2e_echo_module_registry_adapter_round_trip() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-client"), + ) else { + return; + }; + + // The adapter reads eth_blockNumber on submit to justify its `chain` + // grant; program the mock so that read succeeds. The response body is + // discarded by the adapter, so any Ok value serves. + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + let components = nexum_runtime::test_utils::mock_components_from(chain, MockStateStore::new()); + let logs = components.logs.clone(); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path( + "videre/modules/examples/echo-venue/module.toml", + )), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path( + "videre/modules/examples/echo-client/module.toml", + )), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + + let mut supervisor = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + assert_eq!( + supervisor.adapter_alive_count(), + 1, + "echo-venue is routable" + ); + assert_eq!(supervisor.alive_count(), 1, "echo-client is alive"); + assert!( + supervisor + .extension_subscription_kinds() + .contains(INTENT_STATUS) + ); + + // A block drives the module's on_block, which submits to the echo venue + // through the shared registry; the registry watches the accepted receipt. + assert_eq!(supervisor.dispatch_block(block(1)).await, 1); + + // Poll the registry the module submitted through and fan its transitions + // back to the module. echo-venue settles instantly, so the first poll + // reports a terminal status and the watch is pruned. + let registry = registry_of(&supervisor); + let mut delivered = 0; + for _ in 0..2 { + for update in registry.poll_status_transitions().await { + assert_eq!(update.venue, "echo-venue"); + let body = videre_status_body::StatusBody::decode(&update.status) + .expect("status body decodes"); + assert_eq!( + body.status, + videre_status_body::IntentStatus::Fulfilled, + "echo settles instantly", + ); + delivered += supervisor + .dispatch_extension_event(status_event(update)) + .await; + } + } + assert_eq!( + delivered, 1, + "one terminal status delivered to the subscriber" + ); + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + + // The module observably completed the round trip: it quoted, it + // submitted, and it received the settled status from the echo venue. + let runs = logs.list_runs("echo-client"); + assert_eq!(runs.len(), 1, "one run recorded for echo-client"); + let page = logs.read(&runs[0].run, 0); + let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); + assert!( + messages + .iter() + .any(|m| m.contains("quoted") && m.contains("echo-venue")), + "module quoted through the client face; records were: {messages:?}", + ); + assert!( + messages + .iter() + .any(|m| m.contains("submitted") && m.contains("echo-venue")), + "module submitted through the client face; records were: {messages:?}", + ); + assert!( + messages + .iter() + .any(|m| m.contains("intent status from venue echo-venue")), + "module received the settled status; records were: {messages:?}", + ); +} + +/// The keeper path over two real components: the echo-keeper module (built +/// by `#[videre_sdk::keeper]`) drives the echo-venue adapter through the +/// typed `VenueClient` (quote, submit, status, cancel) and +/// receives the fulfilled `intent-status` polled back. +#[tokio::test] +async fn e2e_keeper_module_drives_the_venue_through_the_typed_client() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-keeper"), + ) else { + return; + }; + + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + let components = nexum_runtime::test_utils::mock_components_from(chain, MockStateStore::new()); + let logs = components.logs.clone(); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path( + "videre/modules/examples/echo-venue/module.toml", + )), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path( + "videre/modules/examples/echo-keeper/module.toml", + )), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + + let mut supervisor = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + assert_eq!( + supervisor.adapter_alive_count(), + 1, + "echo-venue is routable" + ); + assert_eq!(supervisor.alive_count(), 1, "echo-keeper is alive"); + + // One block drives the keeper's async on_block: quote, submit, + // status, cancel, all through the typed client. + assert_eq!(supervisor.dispatch_block(block(1)).await, 1); + + // The accepted receipt is under status watch; echo settles + // instantly, so the first poll fans the terminal status back. + let registry = registry_of(&supervisor); + let mut delivered = 0; + for _ in 0..2 { + for update in registry.poll_status_transitions().await { + assert_eq!(update.venue, "echo-venue"); + delivered += supervisor + .dispatch_extension_event(status_event(update)) + .await; + } + } + assert_eq!(delivered, 1, "one terminal status delivered to the keeper"); + assert_eq!(supervisor.alive_count(), 1, "keeper must remain alive"); + + // Every typed verb observably ran. + let runs = logs.list_runs("echo-keeper"); + assert_eq!(runs.len(), 1, "one run recorded for echo-keeper"); + let page = logs.read(&runs[0].run, 0); + let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); + for needle in [ + "quoted at echo-venue", + "submitted to echo-venue", + "status at echo-venue", + "cancelled at echo-venue", + "intent status from venue echo-venue", + ] { + assert!( + messages.iter().any(|m| m.contains(needle)), + "missing `{needle}`; records were: {messages:?}", + ); + } +} + +/// The shepherd bundle pair: twap-monitor (a `#[videre_sdk::keeper]` worker) +/// boots against the cow adapter (the body-version handshake admits the +/// pair) and a Sepolia block dispatch reaches it and keeps it alive. +#[tokio::test] +async fn e2e_twap_monitor_boots_against_the_cow_adapter() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("cow-venue"), + module_wasm_or_skip("twap-monitor"), + ) else { + return; + }; + + let components = mock_components(); + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + // Sepolia variant: twap-monitor pins chain 11155111, so the + // adapter manifest must name the same chain for the pair to + // submit to the right orderbook. + manifest: Some(workspace_path( + "shepherd/crates/cow-venue/module.sepolia.toml", + )), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path("shepherd/modules/twap-monitor/module.toml")), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + + let mut supervisor = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + assert_eq!(supervisor.adapter_alive_count(), 1, "cow is routable"); + assert_eq!(supervisor.alive_count(), 1, "twap-monitor is alive"); + + // twap-monitor subscribes to Sepolia blocks (poll path); with no + // watches indexed the run is empty and the keeper stays alive. + assert_eq!(supervisor.dispatch_block(block(11_155_111)).await, 1); + assert_eq!(supervisor.alive_count(), 1); +} + +/// The body-version handshake refuses a mismatched pair: an adapter decoding +/// only v1 against a keeper encoding v2 fails the boot before instantiation. +#[tokio::test] +async fn e2e_mismatched_body_versions_refuse_the_pair_at_boot() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-client"), + ) else { + return; + }; + + let dir = tempfile::tempdir().expect("tempdir"); + let adapter_manifest = dir.path().join("echo-venue.toml"); + std::fs::write( + &adapter_manifest, + r#" +[module] +name = "echo-venue" +kind = "venue-adapter" + +[capabilities] +required = ["chain"] + +[venue] +body_versions = [1] +"#, + ) + .expect("write adapter manifest"); + let keeper_manifest = dir.path().join("echo-client.toml"); + std::fs::write( + &keeper_manifest, + r#" +[module] +name = "echo-client" + +[capabilities] +required = ["client", "logging"] + +[venue] +body_version = 2 +"#, + ) + .expect("write keeper manifest"); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(adapter_manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(keeper_manifest), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + + let Err(err) = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await + else { + panic!("mismatched pair must refuse to boot"); + }; + let chain = format!("{err:#}"); + assert!(chain.contains("body version 2"), "{chain}"); + assert!(chain.contains("echo-venue decodes {1}"), "{chain}"); +} + +/// An adapter whose `body-versions()` export diverges from its manifest +/// `[venue] body_versions` fails its own install. +#[tokio::test] +async fn e2e_manifest_export_divergence_refuses_the_adapter_at_boot() { + let Some(adapter_wasm) = module_wasm_or_skip("echo-venue") else { + return; + }; + + let dir = tempfile::tempdir().expect("tempdir"); + let adapter_manifest = dir.path().join("echo-venue.toml"); + std::fs::write( + &adapter_manifest, + r#" +[module] +name = "echo-venue" +kind = "venue-adapter" + +[capabilities] +required = ["chain"] + +[venue] +body_versions = [1, 2] +"#, + ) + .expect("write adapter manifest"); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(adapter_manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + + let Err(err) = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await + else { + panic!("a diverging adapter must refuse to boot"); + }; + let chain = format!("{err:#}"); + assert!(chain.contains("exports body versions {1}"), "{chain}"); + assert!(chain.contains("declares {1, 2}"), "{chain}"); +} + +// ── venue-adapter trap recovery ─────────────────────────────────────── + +/// Boot one flaky-venue adapter over the mock chain, its head at the +/// fixture's poison sentinel. Returns the chain handle for recovery. +async fn boot_flaky_venue( + adapter_wasm: PathBuf, + limits: ModuleLimits, +) -> (Supervisor, MockChainProvider) { + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0xdead\""); + let components = + nexum_runtime::test_utils::mock_components_from(chain.clone(), MockStateStore::new()); + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path( + "videre/modules/fixtures/flaky-venue/module.toml", + )), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + limits, + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let supervisor = Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + (supervisor, chain) +} + +/// The trap-to-recovery lifecycle over a real wasm adapter: a trapped venue +/// is `unavailable` (not `unknown-venue`), the restart sweep reinstantiates +/// it after backoff, and a submit then succeeds again. +#[tokio::test] +async fn e2e_trapped_adapter_is_swept_and_restarts() { + let Some(wasm) = module_wasm_or_skip("flaky-venue") else { + return; + }; + let (mut supervisor, chain) = boot_flaky_venue(wasm, ModuleLimits::default()).await; + assert_eq!(supervisor.adapter_count(), 1); + assert_eq!(supervisor.adapter_alive_count(), 1, "boots alive"); + let registry = registry_of(&supervisor); + let venue = VenueId::from("flaky-venue"); + + // The poison head detonates submit: the guest panic traps the store + // and the shared liveness drops. + let err = registry + .submit("mod-a", &venue, b"body".to_vec()) + .await + .expect_err("the poison head traps the adapter"); + assert!(matches!(err, VenueError::Unavailable(_)), "{err:?}"); + assert_eq!( + supervisor.adapter_alive_count(), + 0, + "the trap drops liveness" + ); + + // Temporarily dead resolves distinctly from never installed. + assert!(matches!( + registry.submit("mod-a", &venue, b"body".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + assert!(matches!( + registry + .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) + .await, + Err(VenueError::UnknownVenue) + )); + + // The venue recovers; past the 1s backoff the dispatch-time sweep + // reinstalls the adapter on a fresh store. + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + tokio::time::sleep(Duration::from_millis(1_200)).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!(supervisor.adapter_alive_count(), 1, "the sweep revived it"); + let outcome = registry + .submit("mod-a", &venue, b"body".to_vec()) + .await + .expect("the recovered adapter accepts"); + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"body")); +} + +/// A crash-looping adapter is quarantined by the poison sweep: at the +/// threshold the restarts stop and the venue stays dead. +#[tokio::test] +async fn e2e_crash_looping_adapter_is_poisoned() { + let Some(wasm) = module_wasm_or_skip("flaky-venue") else { + return; + }; + let limits = ModuleLimits { + poison: PoisonLimitsSection { + max_failures: Some(2), + window_secs: Some(600), + }, + ..ModuleLimits::default() + }; + // The chain head stays at the poison sentinel for the whole test: every + // submit after a restart traps again. + let (mut supervisor, _chain) = boot_flaky_venue(wasm, limits).await; + let registry = registry_of(&supervisor); + let venue = VenueId::from("flaky-venue"); + + // Trap 1, then a successful restart past the 1s backoff. + let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; + tokio::time::sleep(Duration::from_millis(1_200)).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!(supervisor.adapter_alive_count(), 1, "first restart lands"); + + // Trap 2 crosses the 2-failure threshold: the sweep quarantines the + // adapter instead of scheduling another restart. + let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!(supervisor.adapter_alive_count(), 0, "quarantined"); + + // Past every backoff the poisoned adapter stays dead and unavailable. + tokio::time::sleep(Duration::from_millis(1_500)).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!( + supervisor.adapter_alive_count(), + 0, + "no restart while poisoned" + ); + assert!(matches!( + registry.submit("mod-a", &venue, b"body".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); +} + +// ── service-missing unknown-venue ───────────────────────────────────── + +/// The videre platform with its registry service withheld: `service` +/// returns `None`, so `HostServices::from_extensions` seeds no venue +/// registry. +struct ClientWithoutRegistry(Videre); + +impl Extension for ClientWithoutRegistry { + fn namespace(&self) -> &'static str { + Extension::::namespace(&self.0) + } + + fn capabilities(&self) -> NamespaceCaps { + Extension::::capabilities(&self.0) + } + + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()> { + Extension::::link(&self.0, linker) + } + + fn manifest_sections(&self) -> &'static [&'static str] { + Extension::::manifest_sections(&self.0) + } + + fn subscriptions(&self) -> &'static [&'static str] { + Extension::::subscriptions(&self.0) + } + + fn admit_worker( + &self, + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], + ) -> anyhow::Result<()> { + Extension::::admit_worker(&self.0, worker, sections, providers) + } +} + +/// The service-lookup miss: with no registry service seeded, `client.rs` +/// resolves every venue call to `unknown-venue`, distinct from the +/// adapter-map miss where the registry is present but the venue id unlisted. +#[tokio::test] +async fn client_without_registry_service_resolves_every_venue_to_unknown() { + let Some(wasm) = module_wasm_or_skip("echo-client") else { + return; + }; + + // A [venue]-free manifest: no adapter boots under `boot_single`, so a + // keeper declaring `[venue] body_version` would be refused before it + // could reach the client face at all. + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("echo-client.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "echo-client" + +[capabilities] +required = ["client", "logging"] + +[[subscription]] +kind = "block" +chain_id = 1 +"#, + ) + .expect("write manifest"); + + let engine = make_wasmtime_engine(); + let extensions: Vec>> = vec![Arc::new(ClientWithoutRegistry( + platform(&EngineConfig::default()), + ))]; + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + let logs = components.logs.clone(); + let limits = ModuleLimits::default(); + + let mut supervisor = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &extensions, + None, + ) + .await + .expect("boot_single"); + + // The precondition of the client.rs unknown-venue branch: the booted + // service map holds no venue registry. + assert!( + supervisor + .services() + .get::(VenueRegistry::NAMESPACE) + .is_none(), + "boot_single must seed no registry service", + ); + + // One chain-1 block drives the keeper's quote then submit; with no + // registry both resolve to unknown-venue, which the keeper absorbs. + assert_eq!(supervisor.dispatch_block(block(1)).await, 1); + assert_eq!(supervisor.alive_count(), 1, "the keeper stays alive"); + + let runs = logs.list_runs("echo-client"); + assert_eq!(runs.len(), 1, "one run recorded for echo-client"); + let page = logs.read(&runs[0].run, 0); + let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); + assert!( + messages + .iter() + .any(|m| m.contains("quote at echo-venue was refused")), + "quote resolved to unknown-venue; records were: {messages:?}", + ); + assert!( + messages + .iter() + .any(|m| m.contains("submit to echo-venue was refused")), + "submit resolved to unknown-venue; records were: {messages:?}", + ); +} diff --git a/videre/crates/videre-host/tests/zero_leak.rs b/videre/crates/videre-host/tests/zero_leak.rs new file mode 100644 index 00000000..f592a35b --- /dev/null +++ b/videre/crates/videre-host/tests/zero_leak.rs @@ -0,0 +1,159 @@ +//! Zero-leak oracle: the host boots the echo venue and routes a worker's +//! submission purely through the generic extension seam, while the +//! `nexum-runtime` crate graph reaches no venue-shaped crate. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; + +use nexum_runtime::bindings::nexum; +use nexum_runtime::engine_config::{AdapterEntry, EngineConfig, ModuleEntry}; +use nexum_runtime::host::component::ChainMethod; +use nexum_runtime::host::extension::Extension; +use nexum_runtime::supervisor::{Supervisor, build_linker}; +use nexum_runtime::test_utils::{ + MockChainProvider, MockStateStore, MockTypes, mock_components_from, +}; +use videre_host::{VenueRegistry, platform}; + +/// Path under the workspace root (the topmost ancestor with a `Cargo.toml`). +fn workspace_path(relative: &str) -> PathBuf { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + manifest + .ancestors() + .filter(|d| d.join("Cargo.toml").is_file()) + .last() + .unwrap_or(manifest) + .join(relative) +} + +/// Path to a module's `.wasm` artefact under the workspace target dir. +/// A missing artefact is a hard failure under CI (the gate may not skip +/// itself) and a soft skip locally. +fn module_wasm_or_skip(module_name: &str) -> Option { + let artifact = module_name.replace('-', "_"); + let p = workspace_path(&format!("target/wasm32-wasip2/release/{artifact}.wasm")); + if p.exists() { + return Some(p); + } + assert!( + std::env::var_os("CI").is_none(), + "{} must be prebuilt in CI", + p.display() + ); + eprintln!( + "SKIP: {} not found - build with `cargo build -p {module_name} --target wasm32-wasip2 --release`", + p.display() + ); + None +} + +/// The boot oracle: the venue adapter installs and a worker's submission +/// reaches it, with the platform supplied only as a generic extension. +#[tokio::test] +async fn e2e_echo_venue_boots_and_submits_through_the_generic_seam() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-client"), + ) else { + return; + }; + + // The adapter reads eth_blockNumber on submit to justify its `chain` + // grant; program the mock so that read succeeds. + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + let components = mock_components_from(chain, MockStateStore::new()); + + let mut engine_config = wasmtime::Config::new(); + engine_config.wasm_component_model(true); + engine_config.consume_fuel(true); + let engine = wasmtime::Engine::new(&engine_config).expect("wasmtime engine"); + + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path( + "videre/modules/examples/echo-venue/module.toml", + )), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path( + "videre/modules/examples/echo-client/module.toml", + )), + }], + ..Default::default() + }; + let extensions: Vec>> = vec![Arc::new(platform(&config))]; + let linker = build_linker::(&engine, &extensions).expect("build_linker"); + + let mut supervisor = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + assert_eq!(supervisor.adapter_alive_count(), 1, "echo-venue installed"); + assert_eq!(supervisor.alive_count(), 1, "echo-client alive"); + + // One block drives the worker's on_block submission; the registry the + // extension published on the service map observes the accepted receipt. + let block = nexum::host::types::Block { + chain_id: 1, + number: 19_000_000, + hash: vec![0xab; 32], + timestamp: 1_700_000_000_000, + }; + assert_eq!(supervisor.dispatch_block(block).await, 1); + let registry = supervisor + .services() + .get::(VenueRegistry::NAMESPACE) + .expect("registry service"); + let updates = registry.poll_status_transitions().await; + assert!( + updates.iter().any(|u| u.venue == "echo-venue"), + "the submission reached the venue; updates were: {updates:?}" + ); +} + +/// The graph oracle: `cargo tree` for the host crate (normal + build +/// edges) names no videre, intent, venue, or cow crate. +#[test] +fn host_crate_graph_reaches_no_venue_shaped_crate() { + let output = Command::new(env!("CARGO")) + .args([ + "tree", + "-p", + "nexum-runtime", + "-e", + "normal,build", + "--all-features", + "--prefix", + "none", + "--locked", + ]) + .current_dir(workspace_path("")) + .output() + .expect("cargo tree runs"); + assert!( + output.status.success(), + "cargo tree failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let tree = String::from_utf8_lossy(&output.stdout); + let reached: Vec<&str> = tree + .lines() + .filter_map(|line| line.split_whitespace().next()) + .filter(|name| { + let name = name.to_lowercase(); + ["videre", "intent", "venue", "cow"] + .iter() + .any(|word| name.contains(word)) + }) + .collect(); + assert!( + reached.is_empty(), + "venue-shaped crates reached: {reached:?}" + ); +} diff --git a/videre/crates/videre-macros/Cargo.toml b/videre/crates/videre-macros/Cargo.toml new file mode 100644 index 00000000..b2edd840 --- /dev/null +++ b/videre/crates/videre-macros/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "videre-macros" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Proc-macro glue for the videre personas: #[venue] turns an impl VenueAdapter into the per-cdylib wit-bindgen and adapter export; #[keeper] emits the worker world wired to the typed venue client; derive(IntentBody) emits the versioned body codec." + +[lib] +proc-macro = true + +[lints] +workspace = true + +[dependencies] +nexum-world = { path = "../../../nexum/crates/nexum-world", features = ["macros"] } +proc-macro2.workspace = true +quote.workspace = true +syn = { workspace = true, features = ["full"] } diff --git a/videre/crates/videre-macros/src/intent_body.rs b/videre/crates/videre-macros/src/intent_body.rs new file mode 100644 index 00000000..bb0dc82a --- /dev/null +++ b/videre/crates/videre-macros/src/intent_body.rs @@ -0,0 +1,130 @@ +//! Expansion for `#[derive(IntentBody)]`: the borsh codec over a per-venue +//! version enum. Enforces the outer-enum shape (newtype variants, one body +//! version each) and emits `to_bytes`/`from_bytes` over the borsh enum +//! layout (a one-byte version tag at the variant index). An unknown version +//! surfaces as `BodyError::UnknownVersion`. Names only `::core` and the +//! SDK's `__private` re-exports, so a `#![no_std]` consumer needs no +//! `extern crate alloc`. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, Fields}; + +/// Expand the derive input into the `IntentBody` impl. +pub(crate) fn expand(input: &DeriveInput) -> syn::Result { + let name = &input.ident; + + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "#[derive(IntentBody)] does not support generic version enums: a wire schema has \ + exactly one shape", + )); + } + + let Data::Enum(data) = &input.data else { + return Err(syn::Error::new_spanned( + name, + "#[derive(IntentBody)] applies to the outer per-venue version enum: an enum with one \ + newtype variant per published body version", + )); + }; + + if data.variants.is_empty() { + return Err(syn::Error::new_spanned( + name, + "#[derive(IntentBody)] needs at least one version variant", + )); + } + if data.variants.len() > usize::from(u8::MAX) + 1 { + return Err(syn::Error::new_spanned( + name, + "#[derive(IntentBody)] supports at most 256 versions: the wire tag is one byte", + )); + } + + let mut encode_arms = Vec::with_capacity(data.variants.len()); + let mut decode_arms = Vec::with_capacity(data.variants.len()); + for (index, variant) in data.variants.iter().enumerate() { + if let Some((eq, _)) = &variant.discriminant { + return Err(syn::Error::new_spanned( + eq, + "#[derive(IntentBody)] does not support explicit discriminants: the version tag \ + is the variant's declaration index, so append new versions at the end", + )); + } + let payload_ty = match &variant.fields { + Fields::Unnamed(fields) if fields.unnamed.len() == 1 => &fields.unnamed[0].ty, + _ => { + return Err(syn::Error::new_spanned( + &variant.ident, + "#[derive(IntentBody)] version variants carry exactly one unnamed payload \ + field, e.g. `V1(BodyV1)`", + )); + } + }; + + let ident = &variant.ident; + let tag = proc_macro2::Literal::u8_suffixed( + u8::try_from(index).expect("variant count checked above"), + ); + + encode_arms.push(quote! { + Self::#ident(payload) => { + let mut out = ::videre_sdk::body::__private::alloc::vec::Vec::new(); + out.push(#tag); + ::videre_sdk::body::__private::borsh::to_writer(&mut out, payload).map_err( + |err| ::videre_sdk::body::BodyError::Encode { + version: #tag, + detail: ::videre_sdk::body::__private::alloc::string::ToString::to_string(&err), + }, + )?; + ::core::result::Result::Ok(out) + } + }); + decode_arms.push(quote! { + #tag => ::core::result::Result::Ok(Self::#ident( + ::videre_sdk::body::__private::borsh::from_slice::<#payload_ty>(payload) + .map_err(|err| ::videre_sdk::body::BodyError::Malformed { + version: #tag, + detail: ::videre_sdk::body::__private::alloc::string::ToString::to_string( + &err, + ), + })?, + )), + }); + } + + Ok(quote! { + #[automatically_derived] + impl ::videre_sdk::body::__private::Derived for #name {} + + #[automatically_derived] + impl ::videre_sdk::body::IntentBody for #name { + fn to_bytes( + &self, + ) -> ::core::result::Result< + ::videre_sdk::body::__private::alloc::vec::Vec, + ::videre_sdk::body::BodyError, + > { + match self { + #(#encode_arms)* + } + } + + fn from_bytes( + bytes: &[u8], + ) -> ::core::result::Result { + let (version, payload) = bytes + .split_first() + .ok_or(::videre_sdk::body::BodyError::Empty)?; + match *version { + #(#decode_arms)* + version => ::core::result::Result::Err( + ::videre_sdk::body::BodyError::UnknownVersion { version }, + ), + } + } + } + }) +} diff --git a/videre/crates/videre-macros/src/keeper.rs b/videre/crates/videre-macros/src/keeper.rs new file mode 100644 index 00000000..a6a3fa74 --- /dev/null +++ b/videre/crates/videre-macros/src/keeper.rs @@ -0,0 +1,316 @@ +//! Expansion for `#[keeper]`: the worker mirror of `#[module]`. Same world +//! synthesis and event dispatch, with the keeper deltas: the `client` +//! capability is required, the videre interfaces remap onto the SDK +//! bindings, async handlers complete via `videre_sdk::client::poll_once`, +//! and `ClientError` folds into the wire fault so `?` works in handlers. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ImplItem, ItemImpl}; + +/// The handler names recognised on a `#[keeper]` impl. +const HANDLERS: [&str; 6] = [ + "init", + "on_block", + "on_chain_logs", + "on_tick", + "on_message", + "on_intent_status", +]; + +/// The manifest capability granting the client import. +const CLIENT_CAPABILITY: &str = "client"; + +/// The import the `client` capability must map to. +const CLIENT_IMPORT: &str = "videre:venue/client@0.1.0"; + +/// WIT packages the client import needs on the resolve path, in +/// dependency order. +const CLIENT_PACKAGES: [&str; 3] = ["videre-value-flow", "videre-types", "videre-venue"]; + +/// The fault detail for a handler future that suspended. +const SUSPENDED: &str = "keeper handler suspended: guest futures complete in one poll"; + +/// Expand the handler impl into the keeper module glue. +pub(crate) fn expand(input: &ItemImpl) -> syn::Result { + let self_ty = &input.self_ty; + if !nexum_world::is_plain_type(self_ty) { + return Err(syn::Error::new_spanned( + self_ty, + "#[videre_sdk::keeper] must be applied to an inherent impl of a named type", + )); + } + if let Some((_, trait_path, _)) = &input.trait_ { + return Err(syn::Error::new_spanned( + trait_path, + "#[videre_sdk::keeper] must be applied to an inherent impl, not a trait impl", + )); + } + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "#[videre_sdk::keeper] must be applied to a non-generic impl", + )); + } + + // Reserve the `on_` prefix for the recognised handler set, exactly + // as `#[module]` does: a typo'd handler must not silently no-op. + for item in &input.items { + if let ImplItem::Fn(f) = item { + let name = f.sig.ident.to_string(); + if name.starts_with("on_") && !HANDLERS.contains(&name.as_str()) { + return Err(syn::Error::new_spanned( + &f.sig.ident, + format!( + "`{name}` is not a recognised #[videre_sdk::keeper] handler; expected one \ + of {HANDLERS:?} (rename helpers so they do not start with `on_`)" + ), + )); + } + } + } + + // Present handlers with their asyncness: async ones are completed + // on the synchronous guest boundary by the emitted dispatch. + let present: Vec<(&str, bool)> = input + .items + .iter() + .filter_map(|item| match item { + ImplItem::Fn(f) => { + let name = f.sig.ident.to_string(); + HANDLERS + .into_iter() + .find(|h| *h == name) + .map(|h| (h, f.sig.asyncness.is_some())) + } + _ => None, + }) + .collect(); + if present.is_empty() { + return Err(syn::Error::new_spanned( + self_ty, + "#[videre_sdk::keeper] found no recognised handlers on this impl; define at least one \ + of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_intent_status`", + )); + } + let handler = |name: &str| present.iter().find(|(h, _)| *h == name).copied(); + + let (anchors, module_world) = derive_keeper_world() + .map_err(|msg| syn::Error::new(proc_macro2::Span::call_site(), msg))?; + let wit_paths = nexum_world::manifest_wit_packages(&module_world.packages) + .map_err(|msg| syn::Error::new(proc_macro2::Span::call_site(), msg))?; + let inline_world = &module_world.wit; + let adapter_caps: Vec = module_world + .adapters + .iter() + .map(|cap| syn::Ident::new(cap, proc_macro2::Span::call_site())) + .collect(); + + // Complete an async handler's future in one poll; a suspension is a + // typed internal fault, never a hang. + let drive = |call: TokenStream| { + quote! { + match ::videre_sdk::client::poll_once(#call) { + ::core::task::Poll::Ready(result) => result, + ::core::task::Poll::Pending => ::core::result::Result::Err( + nexum::host::types::Fault::Internal( + ::std::string::String::from(#SUSPENDED), + ), + ), + } + } + }; + + let init_impl = match handler("init") { + Some((_, is_async)) => { + let call = quote! { <#self_ty>::init(config) }; + let body = if is_async { drive(call) } else { call }; + quote! { + fn init( + config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + #body + } + } + } + None => quote! { + fn init( + _config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + ::core::result::Result::Ok(()) + } + }, + }; + + let arm = |name: &str, variant: &str| -> TokenStream { + let variant = syn::Ident::new(variant, proc_macro2::Span::call_site()); + match handler(name) { + Some((_, is_async)) => { + let call = syn::Ident::new(name, proc_macro2::Span::call_site()); + let call = quote! { <#self_ty>::#call(payload) }; + let body = if is_async { drive(call) } else { call }; + quote! { nexum::host::types::Event::#variant(payload) => #body, } + } + None => quote! { + nexum::host::types::Event::#variant(_) => ::core::result::Result::Ok(()), + }, + } + }; + let block_arm = arm("on_block", "Block"); + let logs_arm = arm("on_chain_logs", "ChainLogs"); + let tick_arm = arm("on_tick", "Tick"); + let message_arm = arm("on_message", "Message"); + // The intent-status transition rides the generic `custom` channel; + // recover it typed through `videre_sdk::event` and dispatch to the + // keeper's `on_intent_status` when the kind matches. A malformed + // payload is the caller's `invalid-input`; a foreign kind is another + // extension's event and no-ops. + let custom_arm = match handler("on_intent_status") { + Some((_, is_async)) => { + let call = quote! { <#self_ty>::on_intent_status(update) }; + let body = if is_async { drive(call) } else { call }; + quote! { + nexum::host::types::Event::Custom(payload) => { + match ::videre_sdk::event::intent_status_update( + &payload.kind, + &payload.payload, + ) { + ::core::option::Option::Some(::core::result::Result::Ok(update)) => #body, + ::core::option::Option::Some(::core::result::Result::Err(err)) => { + ::core::result::Result::Err(nexum::host::types::Fault::InvalidInput( + ::std::string::ToString::to_string(&err), + )) + } + ::core::option::Option::None => ::core::result::Result::Ok(()), + } + } + } + } + None => quote! { + nexum::host::types::Event::Custom(_) => ::core::result::Result::Ok(()), + }, + }; + + Ok(quote! { + // Anchor a rebuild on the manifest and the extension registry: + // the emitted world is derived from them. + #(const _: &[u8] = ::core::include_bytes!(#anchors);)* + + wit_bindgen::generate!({ + inline: #inline_world, + path: [#(#wit_paths),*], + world: "nexum:module-world/module", + generate_all, + with: { + "videre:types/types@0.1.0": ::videre_sdk::bindings::videre::types::types, + "videre:value-flow/types@0.1.0": + ::videre_sdk::bindings::videre::value_flow::types, + "videre:venue/client@0.1.0": + ::videre_sdk::bindings::videre::venue::client, + }, + }); + + ::nexum_sdk::bind_host_via_wit_bindgen!(caps: [#(#adapter_caps),*]); + + #input + + // Folds a typed client failure into the wire fault, so `?` + // applies to client calls inside handlers. + impl ::core::convert::From<::videre_sdk::ClientError> for nexum::host::types::Fault { + fn from(err: ::videre_sdk::ClientError) -> Self { + ::core::convert::Into::into(::nexum_sdk::host::Fault::from(err)) + } + } + + #[doc(hidden)] + struct __VidereKeeperExport; + + impl Guest for __VidereKeeperExport { + #init_impl + + fn on_event(event: nexum::host::types::Event) -> ::core::result::Result<(), Fault> { + match event { + #block_arm + #logs_arm + #tick_arm + #message_arm + #custom_arm + } + } + } + + export!(__VidereKeeperExport); + }) +} + +/// The canonical `client` extension row, injected when the composition +/// root's registry does not carry one. +fn client_row() -> nexum_world::ExtensionRow { + nexum_world::ExtensionRow { + name: CLIENT_CAPABILITY.to_owned(), + import: CLIENT_IMPORT.to_owned(), + packages: CLIENT_PACKAGES.map(str::to_owned).into(), + } +} + +/// Read `module.toml`, require the worker shape (no `[module] kind`) and +/// the `client` capability, and synthesize the module world with the client +/// extension row. Returns the rebuild anchor paths and the world. +fn derive_keeper_world() -> Result<(Vec, nexum_world::ModuleWorld), String> { + let crate_dir = nexum_world::manifest_dir()?; + let manifest_path = crate_dir.join("module.toml"); + let text = std::fs::read_to_string(&manifest_path).map_err(|e| { + format!( + "could not read {} ({e}); #[videre_sdk::keeper] derives the component's WIT world \ + from the manifest's [capabilities] section, so the manifest must sit next to \ + Cargo.toml", + manifest_path.display() + ) + })?; + if let Some(kind) = nexum_world::manifest_kind(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))? + { + return Err(format!( + "{}: a #[videre_sdk::keeper] module is a plain worker; drop `[module] kind = \ + \"{kind}\"`", + manifest_path.display() + )); + } + let declared = nexum_world::manifest_capabilities(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))?; + if !declared.iter().any(|cap| cap == CLIENT_CAPABILITY) { + return Err(format!( + "{}: a keeper drives venues through `{CLIENT_IMPORT}`; declare the \ + `{CLIENT_CAPABILITY}` capability under [capabilities]", + manifest_path.display() + )); + } + let manifest_path = manifest_path.to_string_lossy().into_owned(); + + let mut anchors = vec![manifest_path.clone()]; + let mut extensions = match nexum_world::find_extensions_manifest(&crate_dir) { + None => Vec::new(), + Some(registry) => { + let text = std::fs::read_to_string(®istry) + .map_err(|e| format!("could not read {}: {e}", registry.display()))?; + let rows = nexum_world::manifest_extensions(&text) + .map_err(|e| format!("{}: {e}", registry.display()))?; + anchors.push(registry.to_string_lossy().into_owned()); + rows + } + }; + match extensions.iter().find(|row| row.name == CLIENT_CAPABILITY) { + None => extensions.push(client_row()), + Some(row) if row.import == CLIENT_IMPORT => {} + Some(row) => { + return Err(format!( + "the registered `{CLIENT_CAPABILITY}` extension imports `{}`; \ + #[videre_sdk::keeper] requires `{CLIENT_IMPORT}`", + row.import + )); + } + } + let module_world = nexum_world::synthesize(&declared, &extensions) + .map_err(|e| format!("{manifest_path}: {e}"))?; + Ok((anchors, module_world)) +} diff --git a/videre/crates/videre-macros/src/lib.rs b/videre/crates/videre-macros/src/lib.rs new file mode 100644 index 00000000..1c78a137 --- /dev/null +++ b/videre/crates/videre-macros/src/lib.rs @@ -0,0 +1,204 @@ +//! Proc-macro glue for the videre personas, reached through the SDK +//! re-exports (`videre_sdk::venue`/`keeper`/`IntentBody`), not directly. +//! +//! - [`venue`]: on an `impl VenueAdapter` block, emits the per-cdylib +//! wit-bindgen for a manifest-derived venue-adapter world plus the SDK +//! export codegen. +//! - [`keeper`]: the worker mirror, wiring the `videre:venue/client` import +//! onto the SDK shims and dispatching events to the handler impl. +//! - [`derive@IntentBody`]: the versioned body codec over a per-venue enum. + +mod intent_body; +mod keeper; +mod venue_marker; +mod world; + +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, ItemImpl}; + +/// Derive the `IntentBody` codec on a per-venue version enum: one newtype +/// variant per body version. The wire form is the borsh enum layout (a +/// one-byte tag at the variant's declaration index), so append versions, +/// never reorder; an unknown tag fails as `BodyError::UnknownVersion`. Use +/// the `videre_sdk::IntentBody` re-export. +#[proc_macro_derive(IntentBody)] +pub fn derive_intent_body(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as DeriveInput); + intent_body::expand(&input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/// The manifest `kind` a venue adapter must declare. +const VENUE_KIND: &str = "venue-adapter"; + +/// Generate the per-cdylib glue for a venue adapter. +/// +/// Apply to an `impl VenueAdapter for MyVenue` block: reads `module.toml`, +/// asserts `[module] kind = venue-adapter`, synthesizes a world exporting +/// `videre:venue/adapter` and importing exactly the manifest's declared +/// scoped transport, then emits `wit_bindgen::generate!`, the trait impl, +/// and the SDK export codegen. The world remaps the `videre` and +/// `nexum:host` types onto the SDK bindings, so the impl speaks `videre_sdk` +/// types directly. A capability outside the venue-permitted set (`chain`, +/// `messaging`, `http`) is rejected at expansion. The consuming crate must +/// declare `wit-bindgen` and `videre-sdk` as direct dependencies. +/// +/// # Client marker +/// +/// With arguments (`#[videre_sdk::venue(id = "cow", body = CowBody)]`) it +/// instead fills a client-side `impl Venue for Marker {}`, emitting the +/// `const ID`/`type Body` and asserting the id equals `[module] name`. No +/// component world, so a keeper linking the client slice never pulls adapter +/// bindgen. +#[proc_macro_attribute] +pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(item as ItemImpl); + + if !attr.is_empty() { + return venue_marker::expand(attr.into(), &input) + .unwrap_or_else(syn::Error::into_compile_error) + .into(); + } + + let Some((None, trait_path, _)) = &input.trait_ else { + return syn::Error::new_spanned( + &input.self_ty, + "#[videre_sdk::venue] must be applied to an `impl VenueAdapter for ...` block", + ) + .to_compile_error() + .into(); + }; + if trait_path + .segments + .last() + .is_none_or(|segment| segment.ident != "VenueAdapter") + { + return syn::Error::new_spanned( + trait_path, + "#[videre_sdk::venue] must be applied to an impl of `videre_sdk::VenueAdapter`", + ) + .to_compile_error() + .into(); + } + let self_ty = &input.self_ty; + if !nexum_world::is_plain_type(self_ty) { + return syn::Error::new_spanned( + self_ty, + "#[videre_sdk::venue] must be applied to an impl on a named type", + ) + .to_compile_error() + .into(); + } + if !input.generics.params.is_empty() { + return syn::Error::new_spanned( + &input.generics, + "#[videre_sdk::venue] must be applied to a non-generic impl", + ) + .to_compile_error() + .into(); + } + + let (manifest_path, venue_world) = match derive_venue_world() { + Ok(parts) => parts, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let wit_paths = match nexum_world::manifest_wit_packages(&venue_world.packages) { + Ok(paths) => paths, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let inline_world = &venue_world.wit; + + quote! { + // Anchor a rebuild on the manifest: the emitted world is derived + // from it, so an edited [capabilities] must recompile the adapter. + const _: &[u8] = ::core::include_bytes!(#manifest_path); + + wit_bindgen::generate!({ + inline: #inline_world, + path: [#(#wit_paths),*], + world: "nexum:venue-world/venue-adapter", + generate_all, + with: { + "nexum:host/types@0.1.0": ::videre_sdk::bindings::nexum::host::types, + "videre:types/types@0.1.0": ::videre_sdk::bindings::videre::types::types, + "videre:value-flow/types@0.1.0": + ::videre_sdk::bindings::videre::value_flow::types, + }, + }); + + #input + + ::videre_sdk::__export_venue_adapter!(#self_ty); + } + .into() +} + +/// Generate the per-cdylib glue for a keeper: a worker driving venues +/// through the typed client. +/// +/// Apply to an `impl` block whose functions are the event handlers (`init`, +/// `on_block`, `on_chain_logs`, `on_tick`, `on_message`, +/// `on_intent_status`); handlers may be `async`, completed on the guest +/// boundary, so one can await the typed `VenueClient` directly. Reads +/// `module.toml`, requires the `client` capability, synthesizes the module +/// world as `#[module]` does, and remaps the videre interfaces onto the SDK +/// bindings so the client and wire share one type set. Emits a +/// `From` onto the wire fault, so `?` works in handlers. The +/// consuming crate must declare `wit-bindgen`, `videre-sdk`, and `nexum-sdk` +/// as direct dependencies. +#[proc_macro_attribute] +pub fn keeper(attr: TokenStream, item: TokenStream) -> TokenStream { + if !attr.is_empty() { + return syn::Error::new( + proc_macro2::Span::call_site(), + "#[videre_sdk::keeper] takes no arguments", + ) + .to_compile_error() + .into(); + } + let input = syn::parse_macro_input!(item as ItemImpl); + keeper::expand(&input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/// Read `module.toml`, assert the venue-adapter kind, and synthesize the +/// venue-adapter world from its `[capabilities]`. Returns the manifest path +/// (rebuild anchor) and the world. +fn derive_venue_world() -> Result<(String, nexum_world::ModuleWorld), String> { + let manifest_path = nexum_world::manifest_dir()?.join("module.toml"); + let text = std::fs::read_to_string(&manifest_path).map_err(|e| { + format!( + "could not read {} ({e}); #[videre_sdk::venue] derives the component's WIT world \ + from the manifest's [capabilities] section, so the manifest must sit next to \ + Cargo.toml", + manifest_path.display() + ) + })?; + let kind = nexum_world::manifest_kind(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))?; + if kind.as_deref() != Some(VENUE_KIND) { + return Err(format!( + "{}: [module] kind must be \"{VENUE_KIND}\" for a #[videre_sdk::venue] adapter, \ + found {}", + manifest_path.display(), + kind.map_or_else(|| "none".to_owned(), |kind| format!("\"{kind}\"")), + )); + } + let declared = nexum_world::manifest_capabilities(&text) + .map_err(|e| format!("{}: {e}", manifest_path.display()))?; + let manifest_path = manifest_path.to_string_lossy().into_owned(); + let venue_world = + world::synthesize_venue(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; + Ok((manifest_path, venue_world)) +} diff --git a/videre/crates/videre-macros/src/venue_marker.rs b/videre/crates/videre-macros/src/venue_marker.rs new file mode 100644 index 00000000..315f9d7f --- /dev/null +++ b/videre/crates/videre-macros/src/venue_marker.rs @@ -0,0 +1,127 @@ +//! The client-side `#[videre_sdk::venue(id = "...", body = Type)]` path: +//! fills a `Venue` marker impl and checks the id against `module.toml` +//! at expansion. No component world, so a keeper linking the client +//! slice never pulls adapter bindgen. + +use proc_macro2::{Span, TokenStream}; +use quote::quote; +use syn::parse::{Parse, ParseStream}; +use syn::{ItemImpl, LitStr, Token, Type}; + +/// `id = "cow", body = CowIntentBody`: the venue id and the body schema +/// the marker binds. +struct Args { + id: LitStr, + body: Type, +} + +impl Parse for Args { + fn parse(input: ParseStream<'_>) -> syn::Result { + let mut id = None; + let mut body = None; + while !input.is_empty() { + let key: syn::Ident = input.parse()?; + input.parse::()?; + match key.to_string().as_str() { + "id" => id = Some(input.parse()?), + "body" => body = Some(input.parse()?), + other => { + return Err(syn::Error::new( + key.span(), + format!("unknown argument `{other}`, expected `id` or `body`"), + )); + } + } + if input.peek(Token![,]) { + input.parse::()?; + } + } + Ok(Self { + id: id.ok_or_else(|| syn::Error::new(Span::call_site(), "missing `id = \"...\"`"))?, + body: body + .ok_or_else(|| syn::Error::new(Span::call_site(), "missing `body = Type`"))?, + }) + } +} + +/// Expand `#[videre_sdk::venue(id, body)]` on an `impl Venue for Marker +/// {}` block: inject `const ID`/`type Body` from the args and assert the +/// id equals the crate manifest's `[module] name`. +pub fn expand(attr: TokenStream, input: &ItemImpl) -> Result { + let args: Args = syn::parse2(attr)?; + + let Some((None, trait_path, _)) = &input.trait_ else { + return Err(syn::Error::new_spanned( + &input.self_ty, + "#[videre_sdk::venue(id = ..)] must be applied to an `impl Venue for ...` block", + )); + }; + if trait_path + .segments + .last() + .is_none_or(|segment| segment.ident != "Venue") + { + return Err(syn::Error::new_spanned( + trait_path, + "#[videre_sdk::venue(id = ..)] must be applied to an impl of `videre_sdk::client::Venue`", + )); + } + if !input.items.is_empty() { + return Err(syn::Error::new_spanned( + &input.self_ty, + "#[videre_sdk::venue(id = ..)] fills the impl body; leave it empty", + )); + } + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "#[videre_sdk::venue(id = ..)] must be applied to a non-generic impl", + )); + } + + let self_ty = &input.self_ty; + let id = &args.id; + let body = &args.body; + + // Read the crate manifest at expansion and hold the id to its + // registered name, alloy-`sol!`-style. Any mismatch is a + // compile_error, so the marker and the adapter it types cannot drift. + let manifest_path = manifest_id_check(id)?; + + Ok(quote! { + // Rebuild anchor: an edited `[module] name` re-runs the check. + const _: &[u8] = ::core::include_bytes!(#manifest_path); + + impl #trait_path for #self_ty { + const ID: ::videre_sdk::client::VenueId = + ::videre_sdk::client::VenueId::from_static(#id); + type Body = #body; + } + }) +} + +/// Assert `id` equals the crate manifest's `[module] name`, returning the +/// manifest path for the rebuild anchor. +fn manifest_id_check(id: &LitStr) -> Result { + let err = |msg: String| syn::Error::new(id.span(), msg); + let manifest_path = nexum_world::manifest_dir() + .map_err(&err)? + .join("module.toml"); + let text = std::fs::read_to_string(&manifest_path).map_err(|e| { + err(format!( + "could not read {} ({e}); #[videre_sdk::venue(id = ..)] holds the id to the \ + manifest's [module] name, so the manifest must sit next to Cargo.toml", + manifest_path.display() + )) + })?; + let name = nexum_world::manifest_name(&text) + .map_err(|e| err(format!("{}: {e}", manifest_path.display())))?; + if name != id.value() { + return Err(err(format!( + "{}: venue id {:?} disagrees with [module] name {name:?}", + manifest_path.display(), + id.value(), + ))); + } + Ok(manifest_path.to_string_lossy().into_owned()) +} diff --git a/videre/crates/videre-macros/src/world.rs b/videre/crates/videre-macros/src/world.rs new file mode 100644 index 00000000..3f6fd283 --- /dev/null +++ b/videre/crates/videre-macros/src/world.rs @@ -0,0 +1,149 @@ +//! World wiring for the venue macro: the venue-adapter world synthesis. +//! The module world synthesis, the core capability table, and the +//! extension registry parsing live in `nexum-world`. + +pub use nexum_world::ModuleWorld; + +/// Capabilities a venue adapter may import: scoped transport only (chain, +/// messaging, and HTTP via the SDK's wasi:http client). `local-store`, +/// `remote-store`, `identity`, and `logging` are refused. +const VENUE_CAPABILITIES: &[&str] = &["chain", "messaging", "http"]; + +/// Build the venue-adapter world from the declared capability names: exports +/// `init` and the `videre:venue/adapter` face, imports exactly the declared +/// scoped transport. A capability outside the venue-permitted set is a +/// compile error. +pub fn synthesize_venue(declared: &[String]) -> Result { + for name in declared { + if !VENUE_CAPABILITIES.contains(&name.as_str()) { + let permitted = VENUE_CAPABILITIES.join(", "); + return Err(format!( + "capability `{name}` is not available to a venue adapter; a venue may import \ + only scoped transport ({permitted}) and structurally cannot touch local-store, \ + remote-store, identity, or logging" + )); + } + } + + let mut imports = String::new(); + // The export face (`videre:venue/adapter`, its types, and the + // value-flow vocabulary they are expressed in) needs the videre + // packages on the resolve path beyond the leaf host package, in + // dependency order: a package precedes its dependants. + let mut packages: Vec = [ + "videre-value-flow", + "videre-types", + "nexum-host", + "videre-venue", + ] + .map(str::to_owned) + .into(); + for cap in nexum_world::CORE { + if !declared.iter().any(|d| d == cap.name.as_str()) { + continue; + } + if let Some(import) = cap.import { + imports.push_str(&format!(" import {import};\n")); + } + // Accumulate any extra WIT packages a venue capability needs, + // exactly as the module synthesis does. All venue-permitted + // capabilities are packageless today, so this leaves the base set + // untouched; mirroring the loop keeps a future venue capability + // from silently failing to reach its package onto the resolve + // path. + for package in cap.packages { + if !packages.iter().any(|p| p == package) { + packages.push((*package).to_owned()); + } + } + } + + let mut wit = String::from( + "package nexum:venue-world;\n\nworld venue-adapter {\n \ + use nexum:host/types@0.1.0.{config, fault};\n\n", + ); + wit.push_str(&imports); + wit.push_str( + "\n export init: func(config: config) -> result<_, fault>;\n \ + export videre:venue/adapter@0.1.0;\n}\n", + ); + + Ok(ModuleWorld { + wit, + packages, + // The venue export glue wires the adapter's associated functions + // to the world's Guest traits directly; there is no host-trait + // adapter to bind, so no capability idents to pass on. + adapters: Vec::new(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The package set every venue world resolves against: the exported + /// adapter face pulls the videre vocabulary, in dependency order. + const VENUE_PACKAGES: [&str; 4] = [ + "videre-value-flow", + "videre-types", + "nexum-host", + "videre-venue", + ]; + + #[test] + fn venue_world_exports_the_adapter_face() { + let world = synthesize_venue(&["chain".to_string()]).unwrap(); + assert!(world.wit.starts_with("package nexum:venue-world;")); + assert!(world.wit.contains("world venue-adapter {")); + assert!( + world + .wit + .contains("export init: func(config: config) -> result<_, fault>;") + ); + assert!(world.wit.contains("export videre:venue/adapter@0.1.0;")); + assert_eq!(world.packages, VENUE_PACKAGES); + assert!(world.adapters.is_empty()); + } + + #[test] + fn venue_world_imports_only_declared_transport() { + let world = synthesize_venue(&["chain".to_string()]).unwrap(); + assert!(world.wit.contains("import nexum:host/chain@0.1.0;")); + assert!(!world.wit.contains("import nexum:host/messaging")); + + let both = synthesize_venue(&["chain".to_string(), "messaging".to_string()]).unwrap(); + assert!(both.wit.contains("import nexum:host/chain@0.1.0;")); + assert!(both.wit.contains("import nexum:host/messaging@0.1.0;")); + } + + #[test] + fn venue_world_grants_http_without_a_world_import() { + let world = synthesize_venue(&["http".to_string()]).unwrap(); + assert!(!world.wit.contains("import")); + assert!(!world.wit.contains("wasi:http")); + assert_eq!(world.packages, VENUE_PACKAGES); + } + + #[test] + fn venue_world_with_no_capabilities_imports_nothing() { + let world = synthesize_venue(&[]).unwrap(); + assert!(!world.wit.contains("import")); + assert!(world.wit.contains("export videre:venue/adapter@0.1.0;")); + } + + #[test] + fn venue_world_refuses_non_transport_capabilities() { + for cap in [ + "local-store", + "remote-store", + "identity", + "logging", + "client", + ] { + let err = synthesize_venue(&[cap.to_string()]).unwrap_err(); + assert!(err.contains(cap), "message was: {err}"); + assert!(err.contains("venue adapter"), "message was: {err}"); + } + } +} diff --git a/videre/crates/videre-sdk/Cargo.toml b/videre/crates/videre-sdk/Cargo.toml new file mode 100644 index 00000000..16e5b4dc --- /dev/null +++ b/videre/crates/videre-sdk/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "videre-sdk" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Guest-side videre SDK: the VenueAdapter trait mirroring the venue-adapter world, the borsh-versioned IntentBody codec, the typed venue client over the native-AFIT transport seam, the generic keeper run assembler, and typed wrappers over the scoped transport imports." + +[lib] +# Plain library - adapters link this and emit their own cdylib for the +# WASM Component. Building on the host target is also supported so the +# codec, client core, and conversions are unit-testable without a wasm +# toolchain (the wit-bindgen import shims compile to unreachable stubs +# off-wasm). + +[lints] +workspace = true + +[dependencies] +# Backs the `IntentBody` wire codec; re-exported (`body::__private`) for +# the derive's generated code so an adapter crate needs no direct borsh +# declaration unless its payloads derive the borsh traits themselves. +borsh.workspace = true +# Source of `#[venue]` and the `IntentBody` derive, re-exported at the +# crate root next to the trait they implement against. +videre-macros = { path = "../videre-macros" } +# Host-neutral SDK layer this crate builds on: the `ChainHost` seam the +# chain wrapper implements, the shared `Fault` vocabulary, and the +# wasi:http `fetch` surface re-exported as `transport::http`. +nexum-sdk = { path = "../../../nexum/crates/nexum-sdk" } +# The venue status-body codec, re-exported as `videre_sdk::status_body`; +# venue guests reach the `intent-status` decode path through here. +videre-status-body = { path = "../videre-status-body" } +# The standard request/response types the `Fetch` seam (and the +# `BoundedFetch` clamp over it) is expressed in. +http.workspace = true +strum.workspace = true +thiserror.workspace = true +# Best-effort fault logs on the run's non-critical store cleanups. +tracing.workspace = true +wit-bindgen.workspace = true + +[dev-dependencies] +# In-memory `LocalStoreHost` behind the keeper run tests. +nexum-sdk-test = { path = "../../../nexum/crates/nexum-sdk-test" } diff --git a/videre/crates/videre-sdk/src/adapter.rs b/videre/crates/videre-sdk/src/adapter.rs new file mode 100644 index 00000000..d7c5b5fc --- /dev/null +++ b/videre/crates/videre-sdk/src/adapter.rs @@ -0,0 +1,132 @@ +//! The [`VenueAdapter`] trait and the internal export codegen that turns +//! an impl of it into the component's `venue-adapter` world surface. +//! +//! The trait mirrors the world's export face one to one. Functions are +//! associated (no `self`): the component model calls exports statically, +//! so adapter state lives in the adapter's own statics. + +use crate::{Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueError}; + +/// Reject an empty receipt as `invalid-receipt` before it reaches an +/// adapter. Called by the export shim ahead of `status` and `cancel`. +#[doc(hidden)] +pub fn guard_receipt(receipt: &[u8]) -> Result<(), VenueError> { + if receipt.is_empty() { + return Err(VenueError::InvalidReceipt); + } + Ok(()) +} + +/// One venue's protocol speaker: the guest-side face of the +/// `venue-adapter` world. Implement on a unit struct and apply +/// [`#[videre_sdk::venue]`](crate::venue) to the impl; bodies and +/// receipts arrive as opaque bytes, typed through +/// [`IntentBody`](crate::IntentBody). +pub trait VenueAdapter { + /// Configure the adapter from its `[config]` table before any + /// submission. + fn init(config: Config) -> Result<(), Fault>; + + /// Body-schema versions this adapter decodes; install asserts it + /// equals the manifest `[venue] body_versions` set. Defaults to none. + fn body_versions() -> Vec { + Vec::new() + } + + /// Project an opaque body onto the header guard policy runs on. A pure + /// derivation: no transport, no side effects. + fn derive_header(body: Vec) -> Result; + + /// Price an opaque intent body: indicative, not an offer the venue is + /// bound to fill. + fn quote(body: Vec) -> Result; + + /// Submit an opaque intent body. Success is the venue's receipt or + /// `requires-signing`, a transaction the host must sign and send + /// before the intent exists. + fn submit(body: Vec) -> Result; + + /// Report where a previously submitted intent is in its life. The + /// export shim rejects an empty receipt as `invalid-receipt`. + fn status(receipt: Vec) -> Result; + + /// Ask the venue to withdraw an intent. Success means the venue + /// accepted the cancellation, not that an in-flight settlement can no + /// longer win the race. The export shim rejects an empty receipt. + fn cancel(receipt: Vec) -> Result<(), VenueError>; +} + +/// Internal codegen `#[videre_sdk::venue]` expands to: a hidden shim +/// wiring a [`VenueAdapter`] impl to the world's `Guest` faces, then its +/// `export!`. Meaningful only inside the attribute's output, where the +/// world bindgen resolves `Guest`, `exports`, and `export!`. +#[doc(hidden)] +#[macro_export] +macro_rules! __export_venue_adapter { + ($adapter:ty) => { + #[doc(hidden)] + struct __VidereVenueAdapterExport; + + impl Guest for __VidereVenueAdapterExport { + fn init( + config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), $crate::Fault> { + <$adapter as $crate::VenueAdapter>::init(config) + } + } + + impl exports::videre::venue::adapter::Guest for __VidereVenueAdapterExport { + fn body_versions() -> ::std::vec::Vec { + <$adapter as $crate::VenueAdapter>::body_versions() + } + + fn derive_header( + body: ::std::vec::Vec, + ) -> ::core::result::Result<$crate::IntentHeader, $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::derive_header(body) + } + + fn quote( + body: ::std::vec::Vec, + ) -> ::core::result::Result<$crate::Quotation, $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::quote(body) + } + + fn submit( + body: ::std::vec::Vec, + ) -> ::core::result::Result<$crate::SubmitOutcome, $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::submit(body) + } + + fn status( + receipt: ::std::vec::Vec, + ) -> ::core::result::Result<$crate::IntentStatus, $crate::VenueError> { + $crate::adapter::guard_receipt(&receipt)?; + <$adapter as $crate::VenueAdapter>::status(receipt) + } + + fn cancel( + receipt: ::std::vec::Vec, + ) -> ::core::result::Result<(), $crate::VenueError> { + $crate::adapter::guard_receipt(&receipt)?; + <$adapter as $crate::VenueAdapter>::cancel(receipt) + } + } + + export!(__VidereVenueAdapterExport); + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_receipt_is_rejected_as_invalid_receipt() { + match guard_receipt(&[]).unwrap_err() { + VenueError::InvalidReceipt => {} + other => panic!("expected invalid-receipt, got {other:?}"), + } + guard_receipt(&[1]).unwrap(); + } +} diff --git a/videre/crates/videre-sdk/src/bindings.rs b/videre/crates/videre-sdk/src/bindings.rs new file mode 100644 index 00000000..71ec301e --- /dev/null +++ b/videre/crates/videre-sdk/src/bindings.rs @@ -0,0 +1,35 @@ +//! Guest bindings for the videre SDK, generated once from an import-only +//! inline world carrying every interface both personas speak: the videre +//! types, the host types and scoped transport, and the keeper-facing +//! `videre:venue/client` shims. The [`VenueAdapter`](crate::VenueAdapter) +//! trait, transport wrappers, and client core are all expressed over +//! them; the per-cdylib bindgens remap the shared interfaces onto these +//! modules with `with`, so a macro-built component shares type identity. +//! +//! Import-only on purpose: keeping the adapter export out of the SDK +//! world lets a keeper module link this crate without becoming a venue, +//! since an unused import prunes at componentization but an export is a +//! hard obligation. + +wit_bindgen::generate!({ + inline: "package videre:sdk-shims; + +world sdk-imports { + import videre:types/types@0.1.0; + import videre:value-flow/types@0.1.0; + import nexum:host/types@0.1.0; + import nexum:host/chain@0.1.0; + import nexum:host/messaging@0.1.0; + import videre:venue/client@0.1.0; +} +", + path: [ + "../../../wit/videre-value-flow", + "../../../wit/videre-types", + "../../../wit/nexum-host", + "../../../wit/videre-venue", + ], + world: "videre:sdk-shims/sdk-imports", + generate_all, + additional_derives: [PartialEq], +}); diff --git a/videre/crates/videre-sdk/src/body.rs b/videre/crates/videre-sdk/src/body.rs new file mode 100644 index 00000000..8c7a5e48 --- /dev/null +++ b/videre/crates/videre-sdk/src/body.rs @@ -0,0 +1,89 @@ +//! The versioned intent-body codec: [`IntentBody`] and its typed +//! [`BodyError`]. +//! +//! A body crosses the pool and adapter boundaries as opaque bytes; typing +//! is recovered guest-side against the venue's schema, an outer version +//! enum whose wire form is a one-byte version tag (the variant's +//! declaration index) plus the borsh payload. `#[derive(IntentBody)]` +//! (re-exported at the crate root) is the intended impl. +//! +//! Invariant: the tag order is the schema. Venues append new versions and +//! never reorder or remove variants. + +use strum::IntoStaticStr; + +use crate::VenueError; + +/// The codec between a venue's typed body enum and the opaque wire bytes. +/// Sealed to `#[derive(IntentBody)]`, which owns the tag rules. +pub trait IntentBody: Sized + __private::Derived { + /// Encode as the one-byte version tag plus the borsh payload. + fn to_bytes(&self) -> Result, BodyError>; + + /// Decode, failing typedly on an empty body, an unknown version + /// tag, or a payload that does not parse as the tagged version + /// (including trailing bytes). + fn from_bytes(bytes: &[u8]) -> Result; +} + +/// Why a body failed to cross the [`IntentBody`] codec. `IntoStaticStr` +/// yields a snake_case label per case. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum BodyError { + /// No bytes at all: not even a version tag. + #[error("empty body: missing the version tag")] + Empty, + /// The version tag names no published version of this body. + #[error("unknown body version {version}")] + UnknownVersion { + /// The unrecognised wire tag. + version: u8, + }, + /// The tag named a known version but its payload did not decode + /// (malformed borsh or trailing bytes). + #[error("malformed version {version} payload: {detail}")] + Malformed { + /// The wire tag whose payload failed. + version: u8, + /// Borsh's decode failure detail. + detail: String, + }, + /// A payload failed to encode; only reachable through a fallible + /// custom `BorshSerialize` impl. + #[error("version {version} payload failed to encode: {detail}")] + Encode { + /// The wire tag whose payload failed. + version: u8, + /// Borsh's encode failure detail. + detail: String, + }, +} + +/// Fold a codec failure into the wire error an adapter returns: decode +/// failures are the caller's `invalid-body`; an encode failure is the +/// adapter's own bug, reported retryable `unavailable`. +impl From for VenueError { + fn from(err: BodyError) -> Self { + match err { + BodyError::Empty | BodyError::UnknownVersion { .. } | BodyError::Malformed { .. } => { + VenueError::InvalidBody(err.to_string()) + } + BodyError::Encode { .. } => VenueError::Unavailable(err.to_string()), + } + } +} + +/// Re-exports for `#[derive(IntentBody)]` generated code only. `alloc` +/// rides along so the expansion resolves in a `#![no_std]` consumer. +#[doc(hidden)] +pub mod __private { + pub extern crate alloc; + + pub use borsh; + + /// The [`IntentBody`](super::IntentBody) seal: implemented only by + /// `#[derive(IntentBody)]` expansions. + pub trait Derived {} +} diff --git a/videre/crates/videre-sdk/src/client.rs b/videre/crates/videre-sdk/src/client.rs new file mode 100644 index 00000000..9d699e8e --- /dev/null +++ b/videre/crates/videre-sdk/src/client.rs @@ -0,0 +1,363 @@ +//! The typed venue client: [`VenueClient`] binds one [`Venue`] over the +//! byte-level [`VenueTransport`] seam, encoding each call through +//! [`IntentBody`] so keeper code never handles wire bytes. [`HostVenues`] +//! binds the seam to the module's `videre:venue/client` import; tests +//! implement [`VenueTransport`] directly. Transport methods are native +//! AFIT, so dispatch is static. + +use std::borrow::Cow; +use std::fmt; +use std::future::Future; +use std::marker::PhantomData; +use std::pin::pin; +use std::task::{Context, Poll, Waker}; + +use strum::IntoStaticStr; + +use crate::bindings::videre::venue::client as shims; +use crate::{BodyError, IntentBody, IntentStatus, Quotation, SubmitOutcome, VenueFault}; + +/// Venue identifier: the id an adapter registers under and every client +/// call routes to. Opaque beyond equality. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct VenueId(Cow<'static, str>); + +impl VenueId { + /// Wrap a static id without allocating. + #[must_use] + pub const fn from_static(id: &'static str) -> Self { + Self(Cow::Borrowed(id)) + } + + /// The id at its wire spelling. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From for VenueId { + fn from(id: String) -> Self { + Self(Cow::Owned(id)) + } +} + +impl From<&str> for VenueId { + fn from(id: &str) -> Self { + Self(Cow::Owned(id.to_owned())) + } +} + +impl AsRef for VenueId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for VenueId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// One venue as a keeper types it. Implement on a unit marker +/// (`struct CowVenue;`) and drive it through [`VenueClient`]. +pub trait Venue { + /// The id the venue's adapter registers under. + const ID: VenueId; + + /// The versioned body schema the venue decodes. + type Body: IntentBody; +} + +/// Sealing markers: a transport opts into [`VenueTransport`], and an +/// adapter into [`VenueReconcile`], by also implementing the respective +/// marker. +#[doc(hidden)] +pub mod sealed { + pub trait SealedTransport {} + pub trait SealedReconcile {} +} + +/// The byte-level seam under the typed client: `videre:venue/client` +/// with the venue named per call. Native AFIT, so a [`VenueClient`] +/// over any transport dispatches statically. Sealed: a transport opts +/// in by also implementing the sealing marker. +pub trait VenueTransport: sealed::SealedTransport { + /// Price an opaque intent body at the named venue. + fn quote( + &self, + venue: &VenueId, + body: Vec, + ) -> impl Future>; + + /// Submit an opaque intent body to the named venue. + fn submit( + &self, + venue: &VenueId, + body: Vec, + ) -> impl Future>; + + /// Put an externally-obtained receipt under the host's status watch; + /// an accepted submit is watched implicitly. Defaults to + /// `unsupported`. + fn observe( + &self, + venue: &VenueId, + receipt: &[u8], + ) -> impl Future> { + let _ = (venue, receipt); + async { Err(VenueFault::Unsupported) } + } + + /// Report where a previously submitted intent is in its life. + fn status( + &self, + venue: &VenueId, + receipt: &[u8], + ) -> impl Future>; + + /// Ask the venue to withdraw an intent. Success means the venue + /// accepted the cancellation, not that an in-flight settlement can + /// no longer win the race. + fn cancel( + &self, + venue: &VenueId, + receipt: &[u8], + ) -> impl Future>; +} + +/// Marker naming the reconcile guarantees an adapter's +/// [`submit`](crate::VenueAdapter::submit) and +/// [`status`](crate::VenueAdapter::status) give, so a keeper recovers a +/// stranded reservation without double-placing. An adapter opts in by +/// implementing it (and the sealing marker), and proves it with +/// `videre_test::venue_reconcile_compliance!`. +/// +/// # Contract +/// +/// 1. Re-POST idempotency (mandatory): a +/// [`submit`](crate::VenueAdapter::submit) of a body the venue already +/// holds resolves to the SAME outcome as the first, never to a +/// terminal [`VenueFault`]. This is what makes a reconcile resubmit +/// safe. +/// 2. Status fast-path (optional): an adapter MAY derive a receipt from +/// the body, so a reconcile can [`status`](crate::VenueAdapter::status) +/// first and commit without a redundant POST. The reconcile primitive +/// is `submit`, not [`observe`](VenueTransport::observe). +/// +/// Reconcile trusts venue-side order validity and does not re-poll the +/// watch source, so adapters must carry self-describing order validity. +pub trait VenueReconcile: crate::VenueAdapter + sealed::SealedReconcile {} + +/// Poll a future once and return its state. A [`VenueTransport`] over the +/// host import resolves on the first poll; [`Poll::Pending`] means a +/// foreign impl suspended, which the keeper macro folds to +/// `Fault::Internal`. +pub fn poll_once(future: F) -> Poll { + let mut future = pin!(future); + let mut cx = Context::from_waker(Waker::noop()); + future.as_mut().poll(&mut cx) +} + +/// The module's `videre:venue/client` import behind the +/// [`VenueTransport`] seam; the default transport for a [`VenueClient`]. +#[derive(Clone, Copy, Debug, Default)] +pub struct HostVenues; + +impl sealed::SealedTransport for HostVenues {} + +impl VenueTransport for HostVenues { + async fn quote(&self, venue: &VenueId, body: Vec) -> Result { + shims::quote(venue.as_str(), &body).map_err(VenueFault::from) + } + + async fn submit(&self, venue: &VenueId, body: Vec) -> Result { + shims::submit(venue.as_str(), &body).map_err(VenueFault::from) + } + + async fn observe(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + shims::observe(venue.as_str(), receipt).map_err(VenueFault::from) + } + + async fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result { + shims::status(venue.as_str(), receipt).map_err(VenueFault::from) + } + + async fn cancel(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + shims::cancel(venue.as_str(), receipt).map_err(VenueFault::from) + } +} + +/// A typed client bound to one [`Venue`]: encodes the venue's +/// [`IntentBody`] and forwards through the [`VenueTransport`] seam under +/// [`Venue::ID`]. Zero-sized over the default [`HostVenues`] transport. +pub struct VenueClient { + transport: T, + venue: PhantomData, +} + +impl VenueClient { + /// Bind the venue over the module's own `videre:venue/client` + /// import. + #[must_use] + pub const fn new() -> Self { + Self { + transport: HostVenues, + venue: PhantomData, + } + } +} + +impl Default for VenueClient { + fn default() -> Self { + Self::new() + } +} + +impl VenueClient { + /// Bind the venue over a caller-supplied transport. + pub const fn with_transport(transport: T) -> Self { + Self { + transport, + venue: PhantomData, + } + } + + /// The venue id every call on this client routes to. + #[must_use] + pub fn venue(&self) -> VenueId { + V::ID + } + + /// The bound transport, so a reconcile pass resubmits reserved bodies + /// through the same seam this client submits on. + #[must_use] + pub fn transport(&self) -> &T { + &self.transport + } + + /// Encode the typed body and price it at the bound venue. The returned + /// [`Quoted`] carries the encoded bytes, so `submit` sends exactly the + /// body the venue priced. + pub async fn quote(&self, body: &V::Body) -> Result, ClientError> { + let bytes = body.to_bytes()?; + let quotation = self.transport.quote(&V::ID, bytes.clone()).await?; + Ok(Quoted { + client: self, + bytes, + quotation, + }) + } + + /// Encode the typed body and submit it to the bound venue. + pub async fn submit(&self, body: &V::Body) -> Result { + let bytes = body.to_bytes()?; + Ok(self.transport.submit(&V::ID, bytes).await?) + } + + /// Put an externally-obtained receipt under the host's status + /// watch at the bound venue. + pub async fn observe(&self, receipt: &[u8]) -> Result<(), ClientError> { + Ok(self.transport.observe(&V::ID, receipt).await?) + } + + /// Report where a previously submitted intent is in its life. + /// Rejects an empty receipt as `invalid-receipt` before the wire. + pub async fn status(&self, receipt: &[u8]) -> Result { + crate::adapter::guard_receipt(receipt).map_err(VenueFault::from)?; + Ok(self.transport.status(&V::ID, receipt).await?) + } + + /// Ask the bound venue to withdraw an intent. Rejects an empty + /// receipt as `invalid-receipt` before the wire. + pub async fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { + crate::adapter::guard_receipt(receipt).map_err(VenueFault::from)?; + Ok(self.transport.cancel(&V::ID, receipt).await?) + } +} + +impl Clone for VenueClient { + fn clone(&self) -> Self { + Self { + transport: self.transport.clone(), + venue: PhantomData, + } + } +} + +impl fmt::Debug for VenueClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("VenueClient") + .field("venue", &V::ID) + .finish_non_exhaustive() + } +} + +/// A priced intent: the quotation plus the exact bytes it prices, bound +/// to the client that fetched it. [`submit`](Self::submit) is the only +/// way from a quote to a submission, so the submitted body is always the +/// quoted one. +pub struct Quoted<'a, V: Venue, T: VenueTransport> { + client: &'a VenueClient, + bytes: Vec, + quotation: Quotation, +} + +impl Quoted<'_, V, T> { + /// The venue's indicative quotation for the body. + #[must_use] + pub fn quotation(&self) -> &Quotation { + &self.quotation + } + + /// Submit the quoted body to the venue that priced it. + pub async fn submit(self) -> Result { + Ok(self.client.transport.submit(&V::ID, self.bytes).await?) + } +} + +impl fmt::Debug for Quoted<'_, V, T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Quoted") + .field("venue", &V::ID) + .field("quotation", &self.quotation) + .finish_non_exhaustive() + } +} + +/// Why a typed client call failed: before the wire (the body failed to +/// encode) or beyond it (the registry or venue refused). `IntoStaticStr` +/// yields a snake_case label per case. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum ClientError { + /// The typed body failed to encode; nothing crossed the wire. + #[error(transparent)] + Body(#[from] BodyError), + /// The registry or the venue behind it refused the call. + #[error(transparent)] + Venue(#[from] VenueFault), +} + +#[cfg(test)] +mod tests { + use std::task::Poll; + + use super::poll_once; + + #[test] + fn ready_chain_completes_in_one_poll() { + async fn two() -> u8 { + let one = async { 1u8 }.await; + one + async { 1u8 }.await + } + assert_eq!(poll_once(two()), Poll::Ready(2)); + } + + #[test] + fn suspending_future_reports_pending() { + assert_eq!(poll_once(std::future::pending::<()>()), Poll::Pending); + } +} diff --git a/videre/crates/videre-sdk/src/event.rs b/videre/crates/videre-sdk/src/event.rs new file mode 100644 index 00000000..c0b48cfd --- /dev/null +++ b/videre/crates/videre-sdk/src/event.rs @@ -0,0 +1,90 @@ +//! Typed recovery of videre events from the core `custom` channel: decode +//! a `custom` event back into the typed [`IntentStatusUpdate`] a keeper +//! handler works with. The `#[keeper]` macro calls it for +//! `on_intent_status`. + +use crate::IntentStatusUpdate; + +/// The `custom-event.kind` an intent-status transition rides on. +pub use crate::status_body::INTENT_STATUS_KIND; + +/// Why an intent-status `custom` payload did not decode. +pub use crate::status_body::EnvelopeError; + +/// Recover an [`IntentStatusUpdate`] from a `custom` event, keyed by its +/// `kind` and `payload`. `None` when the kind is another extension's; +/// `Some(Err)` when the payload is empty, tagged to an envelope version +/// this build does not publish, or malformed. +pub fn intent_status_update( + kind: &str, + payload: &[u8], +) -> Option> { + if kind != INTENT_STATUS_KIND { + return None; + } + Some(IntentStatusUpdate::decode(payload)) +} + +#[cfg(test)] +mod tests { + use crate::status_body::{IntentStatus, StatusBody}; + + use super::*; + + fn envelope() -> Vec { + IntentStatusUpdate { + venue: "cow".to_owned(), + receipt: b"receipt".to_vec(), + status: StatusBody { + status: IntentStatus::Fulfilled, + proof: None, + reason: None, + } + .encode() + .expect("encode body"), + } + .encode() + .expect("encode envelope") + } + + #[test] + fn recovers_a_matching_kind() { + let recovered = intent_status_update(INTENT_STATUS_KIND, &envelope()) + .expect("kind matches") + .expect("payload decodes"); + assert_eq!(recovered.venue, "cow"); + assert_eq!(recovered.receipt, b"receipt"); + } + + #[test] + fn ignores_a_foreign_kind() { + assert!(intent_status_update("other-kind", &envelope()).is_none()); + } + + /// A host framing the envelope to a version this guest does not + /// publish is refused at the seam, not misread into a plausible + /// update. + #[test] + fn reports_a_skewed_envelope_version() { + let mut skewed = envelope(); + skewed[0] = crate::status_body::ENVELOPE_VERSION_V1 + 1; + assert!(matches!( + intent_status_update(INTENT_STATUS_KIND, &skewed).expect("kind matches"), + Err(EnvelopeError::UnknownVersion { .. }), + )); + } + + /// A payload tagged to a version this build does publish, whose body + /// is garbage, is the caller's `invalid-input`, not a skew report. + #[test] + fn reports_a_malformed_payload() { + assert!(matches!( + intent_status_update( + INTENT_STATUS_KIND, + &[crate::status_body::ENVELOPE_VERSION_V1, 0xff], + ) + .expect("kind matches"), + Err(EnvelopeError::Malformed { .. }), + )); + } +} diff --git a/videre/crates/videre-sdk/src/faults.rs b/videre/crates/videre-sdk/src/faults.rs new file mode 100644 index 00000000..016b478f --- /dev/null +++ b/videre/crates/videre-sdk/src/faults.rs @@ -0,0 +1,234 @@ +//! Conversions between the failure vocabularies an adapter touches: the +//! wire [`Fault`] its exports return, the SDK-neutral [`host::Fault`] the +//! transport seams speak, and the [`VenueError`] the intent face reports; +//! plus [`VenueFault`], the owned client-side mirror of the wire error. +//! +//! Conversions are lossy only downward (a structured case folds to a +//! string case, never the reverse), so `?` preserves the most structured +//! form the target vocabulary can carry. + +use nexum_sdk::host; +use strum::IntoStaticStr; + +use crate::bindings::nexum::host::types::RateLimit as WireRateLimit; +use crate::client::ClientError; +use crate::{Fault, RateLimit, VenueError}; + +/// Owned mirror of the wire `venue-error`: what typed client code reports +/// when the registry or a venue refuses. `IntoStaticStr` yields a +/// snake_case label per case. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum VenueFault { + /// No adapter is registered under the named venue id. + #[error("unknown venue")] + UnknownVenue, + /// The venue rejected the body as malformed. + #[error("invalid body: {0}")] + InvalidBody(String), + /// The venue does not support the operation. + #[error("unsupported")] + Unsupported, + /// The venue or a policy refused the call. + #[error("denied: {0}")] + Denied(String), + /// The venue throttled the call. + #[error("rate limited{}", retry_after_ms.map_or_else(String::new, |ms| format!(", retry after {ms} ms")))] + RateLimited { + /// Venue-suggested wait before retrying, in milliseconds. + retry_after_ms: Option, + }, + /// The venue is temporarily unreachable or failing. + #[error("unavailable: {0}")] + Unavailable(String), + /// The call timed out. + #[error("timeout")] + Timeout, + /// The receipt is empty or structurally invalid. + #[error("invalid receipt")] + InvalidReceipt, + /// The venue-returned identifier disagrees with the locally derived + /// one. + #[error("receipt mismatch")] + ReceiptMismatch, +} + +/// Lift the wire error into the owned mirror; exhaustive, so a new WIT +/// case fails to compile here. +impl From for VenueFault { + fn from(err: VenueError) -> Self { + match err { + VenueError::UnknownVenue => Self::UnknownVenue, + VenueError::InvalidBody(s) => Self::InvalidBody(s), + VenueError::Unsupported => Self::Unsupported, + VenueError::Denied(s) => Self::Denied(s), + VenueError::RateLimited(rl) => Self::RateLimited { + retry_after_ms: rl.retry_after_ms, + }, + VenueError::Unavailable(s) => Self::Unavailable(s), + VenueError::Timeout => Self::Timeout, + VenueError::InvalidReceipt => Self::InvalidReceipt, + VenueError::ReceiptMismatch => Self::ReceiptMismatch, + } + } +} + +/// Lift the wire fault into the SDK-neutral vocabulary the transport +/// seams speak; exhaustive, so a new WIT case fails to compile here. +pub fn fault_into_sdk(fault: Fault) -> host::Fault { + match fault { + Fault::Unsupported(s) => host::Fault::Unsupported(s), + Fault::Unavailable(s) => host::Fault::Unavailable(s), + Fault::Denied(s) => host::Fault::Denied(s), + Fault::RateLimited(rl) => host::Fault::RateLimited(host::RateLimit { + retry_after_ms: rl.retry_after_ms, + }), + Fault::Timeout => host::Fault::Timeout, + Fault::InvalidInput(s) => host::Fault::InvalidInput(s), + Fault::Internal(s) => host::Fault::Internal(s), + } +} + +/// Lower the SDK-neutral fault back into the wire fault an adapter's +/// `init` returns. `host::Fault` is `#[non_exhaustive]`, so a future case +/// lands as `internal` carrying its `Display` detail. +impl From for Fault { + fn from(fault: host::Fault) -> Self { + match fault { + host::Fault::Unsupported(s) => Fault::Unsupported(s), + host::Fault::Unavailable(s) => Fault::Unavailable(s), + host::Fault::Denied(s) => Fault::Denied(s), + host::Fault::RateLimited(rl) => Fault::RateLimited(WireRateLimit { + retry_after_ms: rl.retry_after_ms, + }), + host::Fault::Timeout => Fault::Timeout, + host::Fault::InvalidInput(s) => Fault::InvalidInput(s), + host::Fault::Internal(s) => Fault::Internal(s), + other => Fault::Internal(other.to_string()), + } + } +} + +/// Fold a transport fault into the venue error an intent function +/// returns: `denied`, `rate-limited`, `timeout`, and `unsupported` map +/// structurally; the caller-shaped cases (`invalid-input`, `internal`) +/// fold to retryable `unavailable`, since inside an intent function the +/// caller is the adapter itself. +impl From for VenueError { + fn from(fault: host::Fault) -> Self { + match fault { + host::Fault::Denied(s) => VenueError::Denied(s), + host::Fault::Unsupported(_) => VenueError::Unsupported, + host::Fault::RateLimited(rl) => VenueError::RateLimited(RateLimit { + retry_after_ms: rl.retry_after_ms, + }), + host::Fault::Timeout => VenueError::Timeout, + host::Fault::Unavailable(s) => VenueError::Unavailable(s), + other => VenueError::Unavailable(other.to_string()), + } + } +} + +/// Fold a typed client failure into the SDK-neutral fault a keeper +/// handler returns: an encode failure, a misnamed venue, and an invalid +/// receipt are the caller's `invalid-input`; a receipt mismatch is a +/// venue integrity `internal`; other refusals map structurally. +impl From for host::Fault { + fn from(err: ClientError) -> Self { + match err { + ClientError::Body(body) => host::Fault::InvalidInput(body.to_string()), + ClientError::Venue(fault) => match fault { + VenueFault::UnknownVenue => host::Fault::InvalidInput(fault.to_string()), + VenueFault::InvalidBody(s) => host::Fault::InvalidInput(s), + VenueFault::Unsupported => host::Fault::Unsupported(fault.to_string()), + VenueFault::Denied(s) => host::Fault::Denied(s), + VenueFault::RateLimited { retry_after_ms } => { + host::Fault::RateLimited(host::RateLimit { retry_after_ms }) + } + VenueFault::Unavailable(s) => host::Fault::Unavailable(s), + VenueFault::Timeout => host::Fault::Timeout, + VenueFault::InvalidReceipt => host::Fault::InvalidInput(fault.to_string()), + VenueFault::ReceiptMismatch => host::Fault::Internal(fault.to_string()), + }, + } + } +} + +/// Fold a wasi:http fetch failure into the venue error an intent +/// function returns: an allowlist refusal stays `denied`, a timeout is +/// `timeout`, and transport failures are retryable `unavailable`. +impl From for VenueError { + fn from(err: nexum_sdk::http::FetchError) -> Self { + use nexum_sdk::http::FetchError; + match err { + FetchError::Denied => VenueError::Denied(err.to_string()), + FetchError::Timeout(_) => VenueError::Timeout, + // `FetchError` is `#[non_exhaustive]`: a future transport + // case folds to retryable `unavailable` with its detail. + _ => VenueError::Unavailable(err.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use nexum_sdk::host; + + use crate::{Fault, VenueError}; + + #[test] + fn wire_fault_round_trips_through_sdk() { + let cases = [ + Fault::Unsupported("u".into()), + Fault::Unavailable("u".into()), + Fault::Denied("d".into()), + Fault::RateLimited(crate::bindings::nexum::host::types::RateLimit { + retry_after_ms: Some(250), + }), + Fault::Timeout, + Fault::InvalidInput("i".into()), + Fault::Internal("i".into()), + ]; + for case in cases { + let there = super::fault_into_sdk(case.clone()); + assert_eq!(Fault::from(there), case); + } + } + + #[test] + fn transport_fault_folds_to_venue_error_by_shape() { + assert_eq!( + VenueError::from(host::Fault::Denied("nope".into())), + VenueError::Denied("nope".into()), + ); + assert_eq!(VenueError::from(host::Fault::Timeout), VenueError::Timeout); + assert!(matches!( + VenueError::from(host::Fault::RateLimited(host::RateLimit { + retry_after_ms: Some(250), + })), + VenueError::RateLimited(rl) if rl.retry_after_ms == Some(250) + )); + assert!(matches!( + VenueError::from(host::Fault::InvalidInput("bug".into())), + VenueError::Unavailable(_) + )); + } + + #[test] + fn fetch_error_folds_to_venue_error_by_shape() { + use nexum_sdk::http::FetchError; + assert!(matches!( + VenueError::from(FetchError::Denied), + VenueError::Denied(_) + )); + assert!(matches!( + VenueError::from(FetchError::Transport("reset".into())), + VenueError::Unavailable(_) + )); + assert!(matches!( + VenueError::from(FetchError::InvalidRequest("bad url".into())), + VenueError::Unavailable(_) + )); + } +} diff --git a/videre/crates/videre-sdk/src/keeper.rs b/videre/crates/videre-sdk/src/keeper.rs new file mode 100644 index 00000000..4cbf8385 --- /dev/null +++ b/videre/crates/videre-sdk/src/keeper.rs @@ -0,0 +1,1309 @@ +//! The generic keeper run: [`Keeper::run`] assembles the world-neutral +//! `nexum_sdk::keeper` stores ([`WatchSet`], [`Gates`], [`Retrier`], +//! [`Journal`]) over a [`Poller`] and routes submissions through the +//! [`VenueTransport`] seam. [`Outcome`] is the shared [`Poller::Outcome`] +//! a keeper's pollers produce. + +use nexum_sdk::host::{Fault, LocalStoreHost}; +use nexum_sdk::keeper::{ + Gates, Journal, Mark, Poller, Reservation, Retrier, RetryAction, Tick, WatchRef, WatchSet, +}; +use nexum_sdk::prelude::{hex, keccak256}; + +use crate::client::{VenueId, VenueTransport}; +use crate::{SubmitOutcome, UnsignedTx, VenueFault}; + +/// What one poll asks the run to do with its watch. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Outcome { + /// Submit these encoded intent-body bytes to the bound venue. + Submit(Vec), + /// Nothing to do yet; the next tick re-polls. + WaitBlock, + /// Gate the watch for `seconds` on the epoch clock. + Backoff { + /// Seconds to wait before the next poll. + seconds: u64, + }, + /// The commitment is spent or unservable; drop the watch. + Drop, +} + +/// Default `[limits.reconcile]` `max_per_tick`: stranded reservations +/// the top-of-sweep reconcile pass resolves per run. +pub const DEFAULT_RECONCILE_BUDGET: usize = 16; + +/// A keeper: one poller bound to one venue, run over the +/// keeper stores. +pub struct Keeper { + source: S, + venues: P, + venue: VenueId, + max_per_tick: usize, +} + +impl Keeper { + /// Bind a source to the venue id its submissions route to. The + /// reconcile budget defaults to [`DEFAULT_RECONCILE_BUDGET`]. + pub fn new(source: S, venues: P, venue: impl Into) -> Self { + Self { + source, + venues, + venue: venue.into(), + max_per_tick: DEFAULT_RECONCILE_BUDGET, + } + } + + /// Override the per-tick reconcile budget (`[limits.reconcile]` + /// `max_per_tick`). The fresh-watch loop is never budget-bounded; + /// only the reconcile pass is. + #[must_use] + pub fn with_reconcile_budget(mut self, max_per_tick: usize) -> Self { + self.max_per_tick = max_per_tick; + self + } + + /// The venue every submission routes to. + pub fn venue(&self) -> &VenueId { + &self.venue + } +} + +impl Keeper { + /// Run one sweep at `tick`: the [`reconcile`] pass first (budget-bounded), + /// then every gate-ready watch is polled and an [`Outcome::Submit`] + /// body reserved on its [`submission_key`] before the venue await, + /// committed on acceptance. + /// + /// Reconcile-not-release contract: `release` runs only on a known + /// synchronous non-accept (`requires-signing` or a venue refusal); a + /// crash, trap, `OutOfFuel`, or deadline-cancel mid-await leaves the + /// `RESERVED` marker for the next tick's reconcile pass. A `RESERVED` + /// marker at the submit arm is owned by this tick's reconcile and never + /// re-POSTed; a `COMMITTED` marker is an idempotent skip. Store faults + /// abort the run (bar the best-effort commit and marker clear); venue + /// refusals fold into per-watch retry actions. + pub async fn run(&self, host: &H, tick: &Tick) -> Result + where + H: LocalStoreHost, + S: Poller, + { + let watches = WatchSet::new(host); + let gates = Gates::new(host); + let retrier = Retrier::new(host); + let journal = Journal::submitted(host); + let mut report = RunReport::default(); + + let rec = reconcile(&self.venue, &self.venues, &journal, tick, self.max_per_tick).await?; + report.reconciled_committed = rec.committed; + report.reconciled_released = rec.released; + report.reconciled_pending = rec.pending; + report.reconciled_gated = rec.gated; + report.reconciled_unsigned = rec.unsigned.len(); + report.unsigned.extend(rec.unsigned); + + for key in watches.list()? { + let Some(watch) = WatchRef::parse(&key) else { + report.skipped += 1; + continue; + }; + if !gates.is_ready(watch, tick.block, tick.epoch_s)? { + report.gated += 1; + continue; + } + let Some(params) = watches.get(watch)? else { + report.skipped += 1; + continue; + }; + report.polled += 1; + + let action = match self.source.poll(host, watch, ¶ms, tick) { + Outcome::Submit(body) => { + let key = submission_key(&self.venue, &body); + match journal.mark(&key)? { + // Durable already: idempotent skip, no venue call. + Some(Mark::Committed) => { + report.duplicates += 1; + continue; + } + // This tick's reconcile pass owns the reservation; + // never a second POST here. + Some(Mark::Reserved) => { + report.retried += 1; + continue; + } + None => { + // Reserve the real body before the await: a + // crash, trap, or deadline-cancel now strands a + // RESERVED marker the next tick's reconcile + // pass resolves, never a silent drop. + journal.reserve(&key, &body)?; + match self.venues.submit(&self.venue, body).await { + Ok(SubmitOutcome::Accepted(_)) => { + // Best-effort: a commit fault just + // reconciles next tick. + let _ = journal.commit(&key); + if let Err(fault) = retrier.clear_refusal(watch) { + tracing::error!( + %fault, + "refusal-marker clear failed after commit", + ); + } + report.submitted += 1; + continue; + } + Ok(SubmitOutcome::RequiresSigning(tx)) => { + journal.release(&key)?; + report.unsigned.push(tx); + continue; + } + // A known synchronous non-accept: release + // the reserve, then fold the refusal. + Err(fault) => { + journal.release(&key)?; + retry_action(&fault) + } + } + } + } + } + Outcome::WaitBlock => RetryAction::TryNextBlock, + Outcome::Backoff { seconds } => RetryAction::Backoff { seconds }, + Outcome::Drop => RetryAction::Drop, + }; + match action { + RetryAction::Drop => report.dropped += 1, + _ => report.retried += 1, + } + retrier.apply(watch, action, tick)?; + } + Ok(report) + } +} + +/// Top-of-sweep reconcile pass: resolve each stranded reservation from +/// [`Journal::pending`] against the venue, budget-bounded by +/// `max_per_tick`. A `RESERVED` marker is an unknown submit outcome and +/// is NEVER skipped. +/// +/// - `next_eligible > tick.epoch_s`: still backing off, left untouched. +/// - accepted: committed. +/// - `requires-signing`: released, the tx surfaced to the caller. +/// - terminal refusal (a [`RetryAction::Drop`] fault): released. +/// - transient refusal: left `RESERVED` for the next tick; a rate-limit +/// hint re-parks `next_eligible`. +pub async fn reconcile( + venue: &VenueId, + venues: &P, + journal: &Journal<'_, H>, + tick: &Tick, + max_per_tick: usize, +) -> Result +where + H: LocalStoreHost, + P: VenueTransport, +{ + let mut report = ReconcileReport::default(); + let mut spent = 0usize; + for Reservation { + key, + next_eligible, + body, + } in journal.pending()? + { + if next_eligible > tick.epoch_s { + report.gated += 1; + continue; + } + if spent >= max_per_tick { + break; + } + spent += 1; + match venues.submit(venue, body.clone()).await { + Ok(SubmitOutcome::Accepted(_)) => { + journal.commit(&key)?; + report.committed += 1; + } + Ok(SubmitOutcome::RequiresSigning(tx)) => { + journal.release(&key)?; + report.unsigned.push(tx); + } + Err(fault) if is_terminal(&fault) => { + journal.release(&key)?; + report.released += 1; + } + Err(VenueFault::RateLimited { + retry_after_ms: Some(ms), + }) => { + journal.park(&key, &body, tick.epoch_s.saturating_add(ms.div_ceil(1000)))?; + report.pending += 1; + } + // Transient (timeout, unavailable, throttle without a hint): + // leave the marker for the next tick. + Err(_) => report.pending += 1, + } + } + Ok(report) +} + +/// A venue refusal no resubmit can cure: its [`retry_action`] drops the +/// watch, so the reservation is safe to release. +fn is_terminal(fault: &VenueFault) -> bool { + matches!(retry_action(fault), RetryAction::Drop) +} + +/// One run's tally, by watch disposition. +#[derive(Clone, Debug, Default, PartialEq)] +#[non_exhaustive] +pub struct RunReport { + /// Watches polled. + pub polled: usize, + /// Watches skipped by an unexpired gate. + pub gated: usize, + /// Watches skipped unread: a malformed key or a vanished row. + pub skipped: usize, + /// Bodies the venue accepted, submission key newly journalled. + pub submitted: usize, + /// Bodies an earlier run journalled, skipped without a venue call. + pub duplicates: usize, + /// Watches left in place for a later tick, plus submit arms the + /// reconcile pass already owns this tick. + pub retried: usize, + /// Watches dropped. + pub dropped: usize, + /// Stranded reservations the reconcile pass committed this tick. + pub reconciled_committed: usize, + /// Reservations the reconcile pass released on a terminal refusal. + pub reconciled_released: usize, + /// Reservations the reconcile pass left `RESERVED` for a later tick. + pub reconciled_pending: usize, + /// Reservations still inside their backoff window this tick. + pub reconciled_gated: usize, + /// Reservations the reconcile pass answered `requires-signing`; the + /// txs ride [`unsigned`](Self::unsigned). + pub reconciled_unsigned: usize, + /// Transactions the venue answered `requires-signing`; a run cannot + /// sign, so the caller owns them. Fresh-watch and reconcile answers. + pub unsigned: Vec, +} + +/// One reconcile pass's tally, by reservation disposition. +#[derive(Clone, Debug, Default, PartialEq)] +#[non_exhaustive] +pub struct ReconcileReport { + /// Stranded reservations the venue re-accepted, now committed. + pub committed: usize, + /// Reservations released on a terminal venue refusal. + pub released: usize, + /// Reservations left `RESERVED` for a later tick on a transient + /// fault. + pub pending: usize, + /// Reservations still inside their backoff window, untouched. + pub gated: usize, + /// Transactions the venue answered `requires-signing`; released and + /// handed to the caller. + pub unsigned: Vec, +} + +/// Deterministic pre-submit journal key: the venue id and the keccak-256 +/// of the body as a fixed-length suffix, so the key is unambiguous +/// whatever the venue id contains. +pub fn submission_key(venue: &VenueId, body: &[u8]) -> String { + format!("{venue}:{}", hex::encode_prefixed(keccak256(body))) +} + +/// Fold a venue refusal into a retry action: a throttle hint becomes an +/// epoch gate, transient failures retry next block, and refusals no retry +/// can cure drop the watch. +pub fn retry_action(fault: &VenueFault) -> RetryAction { + match fault { + VenueFault::RateLimited { + retry_after_ms: Some(ms), + } => RetryAction::Backoff { + seconds: ms.div_ceil(1000), + }, + VenueFault::RateLimited { + retry_after_ms: None, + } + | VenueFault::Timeout + | VenueFault::Unavailable(_) => RetryAction::TryNextBlock, + VenueFault::UnknownVenue + | VenueFault::InvalidBody(_) + | VenueFault::Unsupported + | VenueFault::Denied(_) + | VenueFault::InvalidReceipt + | VenueFault::ReceiptMismatch => RetryAction::Drop, + } +} + +#[cfg(test)] +mod tests { + use std::cell::{Cell, RefCell}; + use std::collections::HashSet; + + use nexum_sdk::host::{Fault, LocalStoreHost as _}; + use nexum_sdk::keeper::{Disposition, Gates, Guarded, Journal, Mark, Tick, WatchRef, WatchSet}; + use nexum_sdk::prelude::{Address, B256, hex, keccak256}; + use nexum_sdk_test::{MockLocalStore, TrapStore}; + + use super::{ + DEFAULT_RECONCILE_BUDGET, Keeper, Outcome, RunReport, is_terminal, reconcile, + submission_key, + }; + use crate::client::{VenueId, VenueTransport}; + use crate::{IntentStatus, Quotation, SubmitOutcome, UnsignedTx, VenueFault}; + + /// Drive a run on the test's synchronous boundary. + fn run(future: F) -> F::Output { + match crate::client::poll_once(future) { + std::task::Poll::Ready(output) => output, + std::task::Poll::Pending => panic!("run futures complete in one poll"), + } + } + + /// Answers every poll with one programmed outcome. + struct StubSource(Outcome); + + impl nexum_sdk::keeper::Poller for StubSource { + type Outcome = Outcome; + + fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Outcome { + self.0.clone() + } + } + + /// Pops one programmed outcome per poll, from the back. + struct SeqSource(RefCell>); + + impl nexum_sdk::keeper::Poller for SeqSource { + type Outcome = Outcome; + + fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Outcome { + self.0.borrow_mut().pop().unwrap_or(Outcome::WaitBlock) + } + } + + /// Answers every submit with one programmed outcome, logging bodies. + struct StubVenue { + outcome: Result, + submitted: RefCell>>, + } + + impl StubVenue { + fn new(outcome: Result) -> Self { + Self { + outcome, + submitted: RefCell::new(Vec::new()), + } + } + } + + impl crate::client::sealed::SealedTransport for &StubVenue {} + + impl VenueTransport for &StubVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit( + &self, + _venue: &VenueId, + body: Vec, + ) -> Result { + self.submitted.borrow_mut().push(body); + self.outcome.clone() + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + const TICK: Tick = Tick { + chain_id: 1, + block: 100, + epoch_s: 1_000, + }; + + fn put_watch(host: &MockLocalStore) -> String { + WatchSet::new(host) + .put(&Address::ZERO, &B256::ZERO, b"params") + .expect("mock store accepts the watch") + } + + fn keeper(outcome: Outcome, venue: &StubVenue) -> Keeper { + Keeper::new(StubSource(outcome), venue, "stub") + } + + #[test] + fn accepted_body_is_journalled_and_never_resubmitted() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![0xA5, 0x5A]))); + let keeper = keeper(Outcome::Submit(b"body".to_vec()), &venue); + + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.polled, 1); + assert_eq!(report.submitted, 1); + assert_eq!(venue.submitted.borrow().as_slice(), [b"body".to_vec()]); + + let journal = Journal::submitted(&host); + let key = format!("stub:{}", hex::encode_prefixed(keccak256(b"body"))); + assert!(journal.contains(&key).expect("journal reads")); + assert_eq!(WatchSet::new(&host).list().expect("list reads").len(), 1); + + // A later run re-polls the watch but never re-posts the body. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.submitted, 0); + assert_eq!(report.duplicates, 1); + assert_eq!(venue.submitted.borrow().len(), 1); + } + + #[test] + fn accepted_body_clears_the_refusal_marker() { + let host = MockLocalStore::default(); + let key = put_watch(&host); + let watch = WatchRef::parse(&key).expect("well-formed key"); + host.set(&watch.refused_key(), &50_u64.to_le_bytes()) + .expect("marker writes"); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + + let report = run(keeper(Outcome::Submit(b"body".to_vec()), &venue).run(&host, &TICK)) + .expect("keeper runs"); + assert_eq!(report.submitted, 1); + assert!( + host.get(&watch.refused_key()) + .expect("marker reads") + .is_none(), + "acceptance must clear the first-refusal marker", + ); + } + + #[test] + fn refusal_marker_clear_fault_does_not_abort_a_journalled_acceptance() { + let host = MockLocalStore::default(); + put_watch(&host); + host.fail_on("refused:", Fault::Unavailable("store down".into())); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + + let report = run(keeper(Outcome::Submit(b"body".to_vec()), &venue).run(&host, &TICK)) + .expect("marker-clear fault must not abort the run"); + assert_eq!(report.submitted, 1); + let key = format!("stub:{}", hex::encode_prefixed(keccak256(b"body"))); + assert!( + Journal::submitted(&host) + .contains(&key) + .expect("journal reads"), + "acceptance must be journalled before the marker clear", + ); + } + + #[test] + fn a_changed_body_submits_afresh() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + // Polls pop from the back: `one` first, then `two`. + let source = SeqSource(RefCell::new(vec![ + Outcome::Submit(b"two".to_vec()), + Outcome::Submit(b"one".to_vec()), + ])); + let keeper = Keeper::new(source, &venue, "stub"); + + assert_eq!( + run(keeper.run(&host, &TICK)) + .expect("keeper runs") + .submitted, + 1 + ); + assert_eq!( + run(keeper.run(&host, &TICK)) + .expect("keeper runs") + .submitted, + 1 + ); + assert_eq!( + venue.submitted.borrow().as_slice(), + [b"one".to_vec(), b"two".to_vec()] + ); + } + + #[test] + fn requires_signing_hands_the_transaction_to_the_caller() { + let host = MockLocalStore::default(); + put_watch(&host); + let tx = UnsignedTx { + chain: 1, + to: vec![0x11; 20], + value: Vec::new(), + data: vec![0xFE], + }; + let venue = StubVenue::new(Ok(SubmitOutcome::RequiresSigning(tx.clone()))); + let keeper = keeper(Outcome::Submit(b"body".to_vec()), &venue); + + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.unsigned, vec![tx.clone()]); + assert_eq!(report.submitted, 0); + + // Nothing accepted, nothing journalled: the next run + // surfaces the same transaction again. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.unsigned, vec![tx]); + } + + #[test] + fn gated_watch_is_not_polled() { + let host = MockLocalStore::default(); + let key = put_watch(&host); + let watch = WatchRef::parse(&key).expect("well-formed key"); + Gates::new(&host) + .set_next_block(watch, TICK.block + 1) + .expect("gate writes"); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + + let report = run(keeper(Outcome::Submit(b"body".to_vec()), &venue).run(&host, &TICK)) + .expect("keeper runs"); + assert_eq!(report.gated, 1); + assert_eq!(report.polled, 0); + assert!(venue.submitted.borrow().is_empty()); + } + + #[test] + fn drop_outcome_removes_the_watch() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + + let report = run(keeper(Outcome::Drop, &venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.dropped, 1); + assert!(WatchSet::new(&host).list().expect("list reads").is_empty()); + } + + #[test] + fn backoff_outcome_gates_the_watch_on_the_epoch_clock() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + let keeper = keeper(Outcome::Backoff { seconds: 30 }, &venue); + + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.retried, 1); + + // Still inside the backoff window: gated, not polled. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.gated, 1); + + // At the threshold the gate opens again. + let later = Tick { + epoch_s: TICK.epoch_s + 30, + ..TICK + }; + let report = run(keeper.run(&host, &later)).expect("keeper runs"); + assert_eq!(report.polled, 1); + } + + #[test] + fn rate_limited_refusal_backs_off_by_the_venue_hint() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Err(VenueFault::RateLimited { + retry_after_ms: Some(2_500), + })); + let keeper = keeper(Outcome::Submit(b"body".to_vec()), &venue); + + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.retried, 1); + + // 2500 ms rounds up to a 3 s epoch gate. + let at_2s = Tick { + epoch_s: TICK.epoch_s + 2, + ..TICK + }; + assert_eq!( + run(keeper.run(&host, &at_2s)).expect("keeper runs").gated, + 1 + ); + let at_3s = Tick { + epoch_s: TICK.epoch_s + 3, + ..TICK + }; + assert_eq!( + run(keeper.run(&host, &at_3s)).expect("keeper runs").polled, + 1 + ); + } + + #[test] + fn non_retryable_refusal_drops_the_watch() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Err(VenueFault::Denied("blocked".into()))); + + let report = run(keeper(Outcome::Submit(b"body".to_vec()), &venue).run(&host, &TICK)) + .expect("keeper runs"); + assert_eq!(report.dropped, 1); + assert!(WatchSet::new(&host).list().expect("list reads").is_empty()); + } + + #[test] + fn transient_refusal_leaves_the_watch_for_the_next_tick() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Err(VenueFault::Unavailable("down".into()))); + let keeper = keeper(Outcome::Submit(b"body".to_vec()), &venue); + + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.retried, 1); + assert_eq!( + run(keeper.run(&host, &TICK)).expect("keeper runs").polled, + 1 + ); + } + + #[test] + fn empty_watch_set_reports_nothing() { + let host = MockLocalStore::default(); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + + let report = + run(keeper(Outcome::WaitBlock, &venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report, RunReport::default()); + } + + const STUB: &str = "stub"; + + /// The submit key the run reserves for `body` at the stub venue. + fn stub_key(body: &[u8]) -> String { + submission_key(&VenueId::from_static(STUB), body) + } + + /// Seed a stranded `RESERVED` marker, as a prior tick's reserve + /// whose submit outcome never landed. + fn seed_reserved(host: &MockLocalStore, body: &[u8]) { + Journal::submitted(host) + .reserve(&stub_key(body), body) + .expect("reserve writes"); + } + + /// Venue that counts POSTs and models re-POST idempotency: a held + /// body re-accepts, an unheld one gets the programmed `outcome` and + /// joins the held set on acceptance. + struct CountingVenue { + outcome: RefCell>, + posts: RefCell>>, + held: RefCell>>, + } + + impl CountingVenue { + fn new(outcome: Result) -> Self { + Self { + outcome: RefCell::new(outcome), + posts: RefCell::new(Vec::new()), + held: RefCell::new(HashSet::new()), + } + } + + fn accepting() -> Self { + Self::new(Ok(SubmitOutcome::Accepted(vec![0xAB]))) + } + + fn post_count(&self) -> usize { + self.posts.borrow().len() + } + + fn held_count(&self) -> usize { + self.held.borrow().len() + } + + /// Pre-seed a held body: a POST received before the caller lost + /// its outcome to a deadline-cancel. + fn preload(&self, body: &[u8]) { + self.held.borrow_mut().insert(body.to_vec()); + } + } + + impl crate::client::sealed::SealedTransport for &CountingVenue {} + + impl VenueTransport for &CountingVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit( + &self, + _venue: &VenueId, + body: Vec, + ) -> Result { + self.posts.borrow_mut().push(body.clone()); + if self.held.borrow().contains(&body) { + return Ok(SubmitOutcome::Accepted(vec![0xAB])); + } + let outcome = self.outcome.borrow().clone(); + if let Ok(SubmitOutcome::Accepted(_)) = &outcome { + self.held.borrow_mut().insert(body); + } + outcome + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + /// Wraps a store, faulting the first `COMMITTED` write to `submitted:` + /// once: models an accepted submit whose commit write faults, leaving + /// the `RESERVED` marker with no release. + struct FlakyCommit { + inner: MockLocalStore, + arm: Cell, + } + + impl FlakyCommit { + fn new() -> Self { + Self { + inner: MockLocalStore::default(), + arm: Cell::new(true), + } + } + } + + impl nexum_sdk::host::LocalStoreHost for FlakyCommit { + fn get(&self, key: &str) -> Result>, Fault> { + self.inner.get(key) + } + + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { + // 0x02 is the journal COMMITTED tag. + if self.arm.get() && key.starts_with("submitted:") && value.first() == Some(&0x02) { + self.arm.set(false); + return Err(Fault::Unavailable("commit write faulted".into())); + } + self.inner.set(key, value) + } + + fn delete(&self, key: &str) -> Result<(), Fault> { + self.inner.delete(key) + } + + fn list_keys(&self, prefix: &str) -> Result, Fault> { + self.inner.list_keys(prefix) + } + + fn contains(&self, key: &str) -> Result { + self.inner.contains(key) + } + + fn len(&self, key: &str) -> Result, Fault> { + nexum_sdk::host::LocalStoreHost::len(&self.inner, key) + } + + fn count(&self, prefix: &str) -> Result { + self.inner.count(prefix) + } + } + + /// A keeper whose source never submits: exercises the reconcile pass + /// alone. + fn idle_keeper(venue: &CountingVenue) -> Keeper { + Keeper::new(StubSource(Outcome::WaitBlock), venue, STUB) + } + + fn mark(host: &impl nexum_sdk::host::LocalStoreHost, body: &[u8]) -> Option { + Journal::submitted(host) + .mark(&stub_key(body)) + .expect("mark reads") + } + + #[test] + fn reserve_commit_happy_path_then_idempotent_skip() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = CountingVenue::accepting(); + let keeper = Keeper::new(StubSource(Outcome::Submit(b"body".to_vec())), &venue, STUB); + + // Tick A: None -> reserve -> Accepted -> commit. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.submitted, 1); + assert_eq!(venue.post_count(), 1); + assert_eq!(mark(&host, b"body"), Some(Mark::Committed)); + + // Tick B: the COMMITTED marker is an idempotent skip, zero calls. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.duplicates, 1); + assert_eq!(report.submitted, 0); + assert_eq!(venue.post_count(), 1); + } + + #[test] + fn w1_reserved_but_venue_never_saw_post_reconciles() { + let host = MockLocalStore::default(); + seed_reserved(&host, b"order"); + let venue = CountingVenue::accepting(); + + // Tick B: the reconcile pass resubmits the stranded reservation. + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_committed, 1); + assert_eq!(venue.post_count(), 1); + assert_eq!(venue.held_count(), 1, "exactly one held order"); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + } + + #[test] + fn w2_accepted_but_commit_faults_reconciles_without_double_holding() { + let host = FlakyCommit::new(); + WatchSet::new(&host) + .put(&Address::ZERO, &B256::ZERO, b"params") + .expect("watch writes"); + let venue = CountingVenue::accepting(); + let keeper = Keeper::new(StubSource(Outcome::Submit(b"order".to_vec())), &venue, STUB); + + // Tick A: venue accepts (POST #1) but the commit write faults; the + // RESERVED marker persists, no release runs. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.submitted, 1); + assert_eq!(mark(&host, b"order"), Some(Mark::Reserved)); + + // Tick B: reconcile resubmits (POST #2), the venue dedups, commit + // lands - two POSTs but one held order. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_committed, 1); + assert_eq!(venue.post_count(), 2); + assert_eq!(venue.held_count(), 1, "one held order despite two POSTs"); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + } + + #[test] + fn w3_cancelled_during_submit_reconciles_both_ways() { + // Sub-case (a): the venue DID receive it before the cancel. + let host = MockLocalStore::default(); + seed_reserved(&host, b"order"); + let venue = CountingVenue::accepting(); + venue.preload(b"order"); + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_committed, 1); + assert_eq!(venue.post_count(), 1); + assert_eq!(venue.held_count(), 1); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + + // Sub-case (b), the venue did NOT receive it, is W1 above: a fresh + // Accepted then commit, one held order. + } + + #[test] + fn reconcile_requires_signing_releases_and_never_re_enumerates() { + let host = MockLocalStore::default(); + seed_reserved(&host, b"order"); + let tx = UnsignedTx { + chain: 1, + to: vec![0x11; 20], + value: Vec::new(), + data: vec![0xFE], + }; + let venue = CountingVenue::new(Ok(SubmitOutcome::RequiresSigning(tx.clone()))); + + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_unsigned, 1); + assert_eq!(report.unsigned, vec![tx]); + assert_eq!(mark(&host, b"order"), None); + + // The reservation is gone; later ticks do not re-enumerate it. + for _ in 0..3 { + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_unsigned, 0); + assert!(Journal::submitted(&host).pending().unwrap().is_empty()); + } + assert_eq!(venue.post_count(), 1); + } + + #[test] + fn reconcile_rate_limit_parks_then_reposts_after_the_window() { + let host = MockLocalStore::default(); + seed_reserved(&host, b"order"); + let venue = CountingVenue::new(Err(VenueFault::RateLimited { + retry_after_ms: Some(2_000), + })); + + // T: parked with next_eligible = T + 2, one POST. + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_pending, 1); + assert_eq!(venue.post_count(), 1); + + // T + 1: inside the window, gated, no POST. + let at_1 = Tick { + epoch_s: TICK.epoch_s + 1, + ..TICK + }; + let report = run(idle_keeper(&venue).run(&host, &at_1)).expect("keeper runs"); + assert_eq!(report.reconciled_gated, 1); + assert_eq!(venue.post_count(), 1); + + // T + 2: the window elapsed, exactly one more POST. + let at_2 = Tick { + epoch_s: TICK.epoch_s + 2, + ..TICK + }; + run(idle_keeper(&venue).run(&host, &at_2)).expect("keeper runs"); + assert_eq!(venue.post_count(), 2); + } + + #[test] + fn reconcile_terminal_refusal_releases_and_stays_gone() { + let host = MockLocalStore::default(); + seed_reserved(&host, b"order"); + let venue = CountingVenue::new(Err(VenueFault::Denied("blocked".into()))); + + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_released, 1); + assert_eq!(mark(&host, b"order"), None); + assert_eq!(venue.post_count(), 1); + + // No later reconcile resurrects it. + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_released, 0); + assert_eq!(venue.post_count(), 1); + } + + #[test] + fn reconcile_transient_refusal_keeps_the_marker_reserved() { + let host = MockLocalStore::default(); + seed_reserved(&host, b"order"); + let venue = CountingVenue::new(Err(VenueFault::Unavailable("down".into()))); + + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_pending, 1); + assert_eq!(mark(&host, b"order"), Some(Mark::Reserved)); + assert_eq!(venue.post_count(), 1); + + // Still RESERVED next tick, reconciled again. + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_pending, 1); + assert_eq!(venue.post_count(), 2); + } + + #[test] + fn reconcile_budget_bounds_the_pass_but_not_the_fresh_watch() { + let host = MockLocalStore::default(); + for body in [b"o1".as_slice(), b"o2", b"o3"] { + seed_reserved(&host, body); + } + put_watch(&host); + let venue = CountingVenue::accepting(); + let keeper = Keeper::new(StubSource(Outcome::Submit(b"fresh".to_vec())), &venue, STUB) + .with_reconcile_budget(2); + + // At most two orphans reconciled, yet the fresh watch still + // submits its own order this tick. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_committed, 2); + assert_eq!(report.submitted, 1); + assert_eq!(Journal::submitted(&host).pending().unwrap().len(), 1); + + // The remaining orphan reconciles on a later tick. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_committed, 1); + assert!(Journal::submitted(&host).pending().unwrap().is_empty()); + } + + #[test] + fn anti_572_reserved_marker_drives_a_reconcile_post_not_a_duplicate_skip() { + let host = MockLocalStore::default(); + seed_reserved(&host, b"order"); + let venue = CountingVenue::accepting(); + + // The #572 design would skip a RESERVED marker as a duplicate and + // drop it forever; the reconcile pass MUST resubmit. + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.duplicates, 0, "a RESERVED marker is not a duplicate"); + assert!(venue.post_count() >= 1, "the reconcile pass must POST"); + assert_eq!(report.reconciled_committed, 1); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + } + + #[test] + fn anti_572_crash_between_submit_and_commit_leaves_reserved_not_none() { + let host = FlakyCommit::new(); + WatchSet::new(&host) + .put(&Address::ZERO, &B256::ZERO, b"params") + .expect("watch writes"); + let venue = CountingVenue::accepting(); + let keeper = Keeper::new(StubSource(Outcome::Submit(b"order".to_vec())), &venue, STUB); + + // The faulted commit is a proxy for a crash between submit and + // commit: the marker MUST be RESERVED, never released to None. + run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(mark(&host, b"order"), Some(Mark::Reserved)); + assert_ne!(mark(&host, b"order"), None); + } + + // + // Review rule the sweep enforces: no in-store invariant may span + // two `set` calls unless the intermediate state is self-healing or + // the writes ride the atomic `apply` batch verb (#609). The + // reserve/commit journal passes because each of its intermediate + // states - nothing, RESERVED, COMMITTED-sans-marker-clear - is one + // the next sweep's reconcile pass resolves on its own. + + /// Seed one watch on a trap store and pair it with an accepting + /// venue and a submitting keeper. + fn trap_rig( + venue: &CountingVenue, + ) -> ( + TrapStore, + Keeper, + ) { + let host = TrapStore::new(MockLocalStore::default()); + WatchSet::new(&host) + .put(&Address::ZERO, &B256::ZERO, b"params") + .expect("watch writes"); + let keeper = Keeper::new(StubSource(Outcome::Submit(b"order".to_vec())), venue, STUB); + (host, keeper) + } + + /// Writes one accepted submit tick performs, pinned by a dry run: + /// the reserve set, the commit set, and the refusal-marker delete. + fn accepted_tick_writes() -> u64 { + let venue = CountingVenue::accepting(); + let (host, keeper) = trap_rig(&venue); + let seeded = host.writes(); + run(keeper.run(&host, &TICK)).expect("dry run completes"); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + host.writes() - seeded + } + + #[test] + fn trap_at_every_write_prefix_reconciles_to_exactly_one_held_order() { + let total = accepted_tick_writes(); + assert_eq!(total, 3, "reserve set, commit set, refusal-marker delete"); + + // Trap the tick after each n of its writes: every torn prefix + // from nothing-landed through all-but-the-last. + for n in 0..total { + let venue = CountingVenue::accepting(); + let (host, keeper) = trap_rig(&venue); + host.arm_after(n); + let _ = run(keeper.run(&host, &TICK)); + assert!(host.tripped(), "prefix {n}: the trap must fire mid-tick"); + + // Restart from the torn store: the next sweep's reconcile + // pass plus the fresh-watch loop must converge. + host.disarm(); + run(keeper.run(&host, &TICK)).expect("recovery tick runs"); + assert_eq!( + mark(&host, b"order"), + Some(Mark::Committed), + "prefix {n}: the journal must end COMMITTED", + ); + assert_eq!( + venue.held_count(), + 1, + "prefix {n}: exactly one held order, whatever the POST count", + ); + assert!( + Journal::submitted(&host) + .pending() + .expect("journal reads") + .is_empty(), + "prefix {n}: no reservation may stay stranded", + ); + + // Steady state: a further tick is a pure idempotent skip. + let posts = venue.post_count(); + let report = run(keeper.run(&host, &TICK)).expect("steady tick runs"); + assert_eq!(report.duplicates, 1, "prefix {n}: COMMITTED skips"); + assert_eq!(venue.post_count(), posts, "prefix {n}: no further POST"); + assert_eq!(venue.held_count(), 1, "prefix {n}: still one held order"); + } + } + + /// One guarded submit of `body` through `venue`, mapping the venue + /// outcome onto the disposition the run inlines: accept commits, + /// requires-signing and terminal refusals release, a rate-limit + /// hint parks, anything else stays RESERVED for reconcile. + async fn guarded_submit( + journal: &Journal<'_, H>, + venue: &CountingVenue, + body: &[u8], + tick: &Tick, + ) -> Result>, Fault> { + let key = stub_key(body); + journal + .guard(&key, body, move || async move { + let outcome = venue + .submit(&VenueId::from_static(STUB), body.to_vec()) + .await; + let disposition = match &outcome { + Ok(SubmitOutcome::Accepted(_)) => Disposition::Commit, + Ok(SubmitOutcome::RequiresSigning(_)) => Disposition::Release, + Err(VenueFault::RateLimited { + retry_after_ms: Some(ms), + }) => Disposition::Park { + until: tick.epoch_s.saturating_add(ms.div_ceil(1000)), + }, + Err(fault) if is_terminal(fault) => Disposition::Release, + Err(_) => Disposition::Retain, + }; + (disposition, outcome) + }) + .await + } + + #[test] + fn guard_reserve_commit_round_trip_is_exactly_once() { + let host = MockLocalStore::default(); + let journal = Journal::submitted(&host); + let venue = CountingVenue::accepting(); + + let out = run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert!(matches!(out, Guarded::Ran(Ok(SubmitOutcome::Accepted(_))))); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + assert!(journal.pending().expect("journal reads").is_empty()); + + // A repeat is an idempotent skip: no second POST, one held order. + let out = run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert!(matches!(out, Guarded::Skipped(Mark::Committed))); + assert_eq!(venue.post_count(), 1); + assert_eq!(venue.held_count(), 1); + } + + #[test] + fn guard_commit_fault_strands_reserved_and_reconcile_heals_exactly_once() { + let host = FlakyCommit::new(); + let journal = Journal::submitted(&host); + let venue = CountingVenue::accepting(); + + let out = run(guarded_submit(&journal, &venue, b"order", &TICK)) + .expect("a commit fault must not abort the guard"); + assert!(matches!(out, Guarded::Ran(Ok(SubmitOutcome::Accepted(_))))); + // The commit write faulted: RESERVED stays, never released. + assert_eq!(mark(&host, b"order"), Some(Mark::Reserved)); + + // The next tick's reconcile pass re-posts, the venue dedups, + // the commit lands: one held order, nothing stranded. + let report = run(reconcile( + &VenueId::from_static(STUB), + &&venue, + &journal, + &TICK, + DEFAULT_RECONCILE_BUDGET, + )) + .expect("reconcile runs"); + assert_eq!(report.committed, 1); + assert_eq!(venue.post_count(), 2); + assert_eq!(venue.held_count(), 1, "one held order despite two POSTs"); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + assert!(journal.pending().expect("journal reads").is_empty()); + } + + #[test] + fn guard_requires_signing_releases_the_reservation() { + let host = MockLocalStore::default(); + let journal = Journal::submitted(&host); + let tx = UnsignedTx { + chain: 1, + to: vec![0x11; 20], + value: Vec::new(), + data: vec![0xFE], + }; + let venue = CountingVenue::new(Ok(SubmitOutcome::RequiresSigning(tx))); + + let out = run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert!(matches!( + out, + Guarded::Ran(Ok(SubmitOutcome::RequiresSigning(_))) + )); + assert_eq!(mark(&host, b"order"), None); + assert!(journal.pending().expect("journal reads").is_empty()); + + // Nothing journalled: a later guard re-poses the same submit. + let out = run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert!(matches!(out, Guarded::Ran(_))); + assert_eq!(venue.post_count(), 2); + } + + #[test] + fn guard_terminal_refusal_releases_the_reservation() { + let host = MockLocalStore::default(); + let journal = Journal::submitted(&host); + let venue = CountingVenue::new(Err(VenueFault::Denied("blocked".into()))); + + let out = run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert!(matches!(out, Guarded::Ran(Err(_)))); + assert_eq!(mark(&host, b"order"), None); + assert!(journal.pending().expect("journal reads").is_empty()); + } + + #[test] + fn guard_rate_limit_parks_until_the_hint_then_reconcile_reposts() { + let host = MockLocalStore::default(); + let journal = Journal::submitted(&host); + let venue = CountingVenue::new(Err(VenueFault::RateLimited { + retry_after_ms: Some(2_000), + })); + + run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert_eq!(mark(&host, b"order"), Some(Mark::Reserved)); + assert_eq!(venue.post_count(), 1); + + // Inside the window the reconcile pass gates the reservation. + let report = run(reconcile( + &VenueId::from_static(STUB), + &&venue, + &journal, + &TICK, + DEFAULT_RECONCILE_BUDGET, + )) + .expect("reconcile runs"); + assert_eq!(report.gated, 1); + assert_eq!(venue.post_count(), 1); + + // Past the window it re-posts; the venue accepts, commit lands. + *venue.outcome.borrow_mut() = Ok(SubmitOutcome::Accepted(vec![0xAB])); + let at_2 = Tick { + epoch_s: TICK.epoch_s + 2, + ..TICK + }; + let report = run(reconcile( + &VenueId::from_static(STUB), + &&venue, + &journal, + &at_2, + DEFAULT_RECONCILE_BUDGET, + )) + .expect("reconcile runs"); + assert_eq!(report.committed, 1); + assert_eq!(venue.post_count(), 2); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + } + + #[test] + fn guard_unknown_outcome_stays_reserved_for_reconcile() { + let host = MockLocalStore::default(); + let journal = Journal::submitted(&host); + let venue = CountingVenue::new(Err(VenueFault::Unavailable("down".into()))); + + run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert_eq!(mark(&host, b"order"), Some(Mark::Reserved)); + + // A second guard never double-posts: reconcile owns the marker. + let out = run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert!(matches!(out, Guarded::Skipped(Mark::Reserved))); + assert_eq!(venue.post_count(), 1); + } +} diff --git a/videre/crates/videre-sdk/src/lib.rs b/videre/crates/videre-sdk/src/lib.rs new file mode 100644 index 00000000..238c4b73 --- /dev/null +++ b/videre/crates/videre-sdk/src/lib.rs @@ -0,0 +1,73 @@ +//! Guest-side SDK for the videre venue personas: the venue author +//! (a venue's protocol speaker exporting the `venue-adapter` world) and +//! the keeper author driving venues through the client seam. +//! +//! Entry points: +//! - [`VenueAdapter`] plus [`venue`](macro@venue): the venue authoring path. +//! - [`IntentBody`] with [`BodyError`]: the versioned borsh body codec. +//! - [`client`]: [`VenueClient`] over the [`VenueTransport`] seam, plus +//! [`keeper`](macro@keeper) wiring and [`poll_once`](client::poll_once). +//! - [`keeper`](mod@keeper): [`Keeper::run`], the generic run assembler. +//! - [`transport`], [`faults`], [`event`], [`status_body`]. +//! +//! [`ChainHost`]: nexum_sdk::host::ChainHost +//! [`VenueClient`]: client::VenueClient +//! [`VenueTransport`]: client::VenueTransport + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] + +#[allow(missing_docs)] +pub mod bindings; + +pub mod adapter; +pub mod body; +pub mod client; +pub mod event; +pub mod faults; +pub mod keeper; +pub mod transport; + +pub use adapter::VenueAdapter; +pub use body::{BodyError, IntentBody}; +pub use client::{ + ClientError, HostVenues, Quoted, Venue, VenueClient, VenueId, VenueReconcile, VenueTransport, +}; +pub use faults::VenueFault; +pub use keeper::{ + DEFAULT_RECONCILE_BUDGET, Keeper, Outcome, ReconcileReport, RunReport, reconcile, retry_action, +}; +/// Derive [`IntentBody`] on the outer per-venue version enum. +pub use videre_macros::IntentBody; +/// Keeper authoring path. Apply to a handler impl: emits the per-cdylib +/// bindgen for a `module.toml`-derived world, drives a [`VenueClient`] +/// with shared type identity, dispatches events (async ones through +/// [`client::poll_once`]), and folds [`ClientError`] into the wire fault +/// so `?` works in handlers. +pub use videre_macros::keeper; +/// Venue authoring path. Apply to the `impl VenueAdapter for MyVenue` +/// block: emits the per-cdylib bindgen for a `module.toml`-derived world, +/// the `videre:venue/adapter` export glue, and `export!`. +pub use videre_macros::venue; + +/// The intent ontology the [`VenueAdapter`] face and client core speak. +pub use bindings::videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, +}; +/// The value-flow vocabulary intent headers are expressed in. +pub mod value_flow; +/// The venue status-body codec. +pub use videre_status_body as status_body; + +/// The intent-status transition a keeper recovers from a `custom` event +/// through [`event::intent_status_update`]. Wire form is a version tag +/// plus a borsh envelope; an unknown tag fails closed. +pub use videre_status_body::IntentStatusUpdate; + +/// The wire config table (`nexum:host/types.config`) `init` receives. +pub use bindings::nexum::host::types::Config; +/// The wire fault (`nexum:host/types.fault`) `init` returns; the +/// [`faults`] conversions bridge it to the SDK-neutral +/// [`nexum_sdk::host::Fault`] the transport seams speak. +pub use bindings::nexum::host::types::Fault; diff --git a/videre/crates/videre-sdk/src/transport.rs b/videre/crates/videre-sdk/src/transport.rs new file mode 100644 index 00000000..1a5640ba --- /dev/null +++ b/videre/crates/videre-sdk/src/transport.rs @@ -0,0 +1,224 @@ +//! Typed wrappers over the adapter world's scoped transport imports +//! (chain RPC, messaging, outbound wasi:http), adapting the bindgen +//! shims to the SDK-neutral `nexum_sdk::host` vocabulary. +//! +//! The wrappers only translate; scoping is the host's: chain to its +//! read-only surface, messaging to `messaging_topics`, HTTP to +//! `http_allow`, each refusal surfacing as a typed `denied`. + +use core::time::Duration; + +use nexum_sdk::host::{ChainError, ChainHost, Fault, RpcError}; +use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; + +use crate::bindings::nexum::host::{chain, messaging}; +use crate::faults::fault_into_sdk; + +/// Outbound HTTP for adapters: the SDK's wasi:http surface re-exported. +/// An off-allowlist request fails as [`FetchError::Denied`], which +/// converts into [`VenueError`](crate::VenueError) via `?`. +pub use nexum_sdk::http; + +/// Clamps every wasi:http phase timeout of the inner [`Fetch`] (connect, +/// first byte, between bytes) to at most `bound`, so a hung endpoint +/// errors rather than stalling the export call. A caller may ask for +/// less, never more. +#[derive(Clone, Copy, Debug)] +pub struct BoundedFetch { + inner: F, + bound: Duration, +} + +impl BoundedFetch { + /// Bound every phase (connect, first byte, between bytes) of every + /// request to at most `bound`. + pub const fn new(inner: F, bound: Duration) -> Self { + Self { inner, bound } + } +} + +impl Fetch for BoundedFetch { + fn fetch_with( + &self, + request: ::http::Request>, + options: FetchOptions, + ) -> Result<::http::Response>, FetchError> { + self.inner.fetch_with( + request, + FetchOptions { + connect_timeout: options.connect_timeout.min(self.bound), + first_byte_timeout: options.first_byte_timeout.min(self.bound), + between_bytes_timeout: options.between_bytes_timeout.min(self.bound), + }, + ) + } +} + +/// The adapter's `nexum:host/chain` import behind the SDK's [`ChainHost`] +/// seam. +#[derive(Clone, Copy, Debug, Default)] +pub struct HostChain; + +impl ChainHost for HostChain { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { + chain::request(chain_id, method, params).map_err(chain_error_into_sdk) + } +} + +impl HostChain { + /// Execute several JSON-RPC requests against one chain in a single + /// round trip. The outer error is the batch failing to execute at + /// all; the per-entry results carry each call's outcome, in request + /// order. + pub fn request_batch( + &self, + chain_id: u64, + requests: &[RpcRequest], + ) -> Result>, ChainError> { + let wire: Vec = requests + .iter() + .map(|req| chain::RpcRequest { + method: req.method.clone(), + params: req.params.clone(), + }) + .collect(); + let results = chain::request_batch(chain_id, &wire).map_err(chain_error_into_sdk)?; + Ok(results + .into_iter() + .map(|result| match result { + chain::RpcResult::Ok(value) => Ok(value), + chain::RpcResult::Err(err) => Err(chain_error_into_sdk(err)), + }) + .collect()) + } +} + +/// One JSON-RPC call inside a [`HostChain::request_batch`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RpcRequest { + /// JSON-RPC method, namespace prefix included. + pub method: String, + /// JSON-encoded params array. + pub params: String, +} + +/// Lift the wire chain error into the SDK-neutral [`ChainError`]. +fn chain_error_into_sdk(err: chain::ChainError) -> ChainError { + match err { + chain::ChainError::Fault(fault) => ChainError::Fault(fault_into_sdk(fault)), + chain::ChainError::Rpc(rpc) => ChainError::Rpc(RpcError { + code: rpc.code, + message: rpc.message, + data: rpc.data.map(Into::into), + }), + } +} + +/// The messaging seam and its message mirror, canonical in the module +/// SDK; [`HostMessaging`] is this crate's bound impl. +pub use nexum_sdk::host::{Message, MessagingHost}; + +/// The adapter's `nexum:host/messaging` import behind the +/// [`MessagingHost`] seam. +#[derive(Clone, Copy, Debug, Default)] +pub struct HostMessaging; + +impl MessagingHost for HostMessaging { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + messaging::publish(content_topic, payload).map_err(fault_into_sdk) + } + + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + let messages = + messaging::query(content_topic, start_time, end_time, limit).map_err(fault_into_sdk)?; + Ok(messages.into_iter().map(Message::from).collect()) + } +} + +impl From for Message { + fn from(message: crate::bindings::nexum::host::types::Message) -> Self { + Self { + content_topic: message.content_topic, + payload: message.payload, + timestamp: message.timestamp, + sender: message.sender, + } + } +} + +#[cfg(test)] +mod tests { + use core::cell::Cell; + use core::time::Duration; + + use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; + + use super::BoundedFetch; + + struct Spy { + seen: Cell>, + } + + impl Fetch for Spy { + fn fetch_with( + &self, + _request: http::Request>, + options: FetchOptions, + ) -> Result>, FetchError> { + self.seen.set(Some(options)); + Ok(http::Response::new(Vec::new())) + } + } + + fn request() -> http::Request> { + http::Request::get("https://api.cow.fi/") + .body(Vec::new()) + .expect("test request builds") + } + + #[test] + fn plain_fetch_is_bounded() { + let bound = Duration::from_secs(5); + let timed = BoundedFetch::new( + Spy { + seen: Cell::new(None), + }, + bound, + ); + timed.fetch(request()).expect("spy accepts"); + let seen = timed.inner.seen.get().expect("options recorded"); + assert_eq!(seen.connect_timeout, bound); + assert_eq!(seen.first_byte_timeout, bound); + assert_eq!(seen.between_bytes_timeout, bound); + } + + #[test] + fn caller_options_clamp_to_the_bound_but_tighter_ones_pass() { + let timed = BoundedFetch::new( + Spy { + seen: Cell::new(None), + }, + Duration::from_secs(5), + ); + timed + .fetch_with( + request(), + FetchOptions { + connect_timeout: Duration::from_secs(60), + first_byte_timeout: Duration::from_secs(1), + between_bytes_timeout: Duration::from_secs(60), + }, + ) + .expect("spy accepts"); + let seen = timed.inner.seen.get().expect("options recorded"); + assert_eq!(seen.connect_timeout, Duration::from_secs(5)); + assert_eq!(seen.first_byte_timeout, Duration::from_secs(1)); + assert_eq!(seen.between_bytes_timeout, Duration::from_secs(5)); + } +} diff --git a/videre/crates/videre-sdk/src/value_flow.rs b/videre/crates/videre-sdk/src/value_flow.rs new file mode 100644 index 00000000..d72a5e76 --- /dev/null +++ b/videre/crates/videre-sdk/src/value_flow.rs @@ -0,0 +1,19 @@ +//! The value-flow wire types, re-exported from +//! [`bindings`](crate::bindings) with constructors that own the `uint` +//! encoding so callers never hand-roll it. + +pub use crate::bindings::videre::value_flow::types::*; + +impl AssetAmount { + /// An ERC-20 amount. Encodes `amount` as the value-flow `uint`: + /// minimal big-endian, where zero is the empty list. + #[must_use] + pub fn erc20(token: nexum_sdk::prelude::Address, amount: nexum_sdk::prelude::U256) -> Self { + Self { + asset: Asset::Erc20(Erc20 { + token: token.as_slice().to_vec(), + }), + amount: amount.to_be_bytes_trimmed_vec(), + } + } +} diff --git a/videre/crates/videre-sdk/tests/adapter.rs b/videre/crates/videre-sdk/tests/adapter.rs new file mode 100644 index 00000000..3c995337 --- /dev/null +++ b/videre/crates/videre-sdk/tests/adapter.rs @@ -0,0 +1,330 @@ +//! Acceptance surface for the venue SDK: a hand-written adapter compiles +//! against [`VenueAdapter`], round-trips a versioned body through +//! `#[derive(IntentBody)]` (including the typed unknown-version failure), +//! and drives a [`VenueClient`] through the [`VenueTransport`] seam. + +use borsh::{BorshDeserialize, BorshSerialize}; +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{ + AuthScheme, BodyError, ClientError, Config, Fault, IntentBody, IntentHeader, IntentStatus, + Quotation, Settlement, SubmitOutcome, Venue, VenueAdapter, VenueClient, VenueError, VenueFault, + VenueId, VenueTransport, +}; + +/// Drive a client future on the test's synchronous boundary. +fn run(future: F) -> F::Output { + match videre_sdk::client::poll_once(future) { + std::task::Poll::Ready(output) => output, + std::task::Poll::Pending => panic!("client futures complete in one poll"), + } +} + +/// First published body version: a fixed-price quote. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +struct QuoteV1 { + amount_wei: u64, + memo: String, +} + +/// Second published version: v1 plus an expiry. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +struct QuoteV2 { + amount_wei: u64, + memo: String, + valid_until_ms: Option, +} + +/// The outer per-venue version enum: the schema the demo venue +/// publishes. Tag order is the schema; versions append. +#[derive(IntentBody, Clone, Debug, PartialEq, Eq)] +enum QuoteBody { + V1(QuoteV1), + V2(QuoteV2), +} + +/// The hand-written adapter: enough venue to exercise every trait +/// function without a live transport. +struct DemoAdapter; + +/// The receipt the demo venue issues for every accepted intent. +const RECEIPT: [u8; 4] = [0xA5, 0x5A, 0xC3, 0x3C]; + +impl DemoAdapter { + fn decode(body: &[u8]) -> Result<(u64, Option), VenueError> { + // `BodyError` converts through `?`: malformed and + // unknown-version bodies surface as `invalid-body`. + let body = QuoteBody::from_bytes(body)?; + Ok(match body { + QuoteBody::V1(quote) => (quote.amount_wei, None), + QuoteBody::V2(quote) => (quote.amount_wei, quote.valid_until_ms), + }) + } +} + +impl VenueAdapter for DemoAdapter { + fn init(_config: Config) -> Result<(), Fault> { + Ok(()) + } + + fn derive_header(body: Vec) -> Result { + let (amount_wei, _valid_until_ms) = Self::decode(&body)?; + Ok(IntentHeader { + gives: AssetAmount { + asset: Asset::Native, + amount: amount_wei.to_be_bytes().to_vec(), + }, + wants: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip712, + }) + } + + fn quote(body: Vec) -> Result { + let (amount_wei, valid_until_ms) = Self::decode(&body)?; + let zero = AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }; + Ok(Quotation { + gives: AssetAmount { + asset: Asset::Native, + amount: amount_wei.to_be_bytes().to_vec(), + }, + wants: zero.clone(), + fee: zero, + valid_until_ms: valid_until_ms.unwrap_or(u64::MAX), + }) + } + + fn submit(body: Vec) -> Result { + Self::decode(&body)?; + Ok(SubmitOutcome::Accepted(RECEIPT.to_vec())) + } + + fn status(receipt: Vec) -> Result { + if receipt == RECEIPT { + Ok(IntentStatus::Open) + } else { + // A receipt this venue never issued can never succeed, so the + // refusal is the non-retryable case. + Err(VenueError::Denied( + "receipt not issued by this venue".into(), + )) + } + } + + fn cancel(receipt: Vec) -> Result<(), VenueError> { + Self::status(receipt).map(|_| ()) + } +} + +/// The demo venue as a keeper types it. +struct DemoVenue; + +impl Venue for DemoVenue { + const ID: VenueId = VenueId::from_static("demo"); + type Body = QuoteBody; +} + +/// A venue id no adapter answers for, over the same body schema. +struct NowhereVenue; + +impl Venue for NowhereVenue { + const ID: VenueId = VenueId::from_static("nowhere"); + type Body = QuoteBody; +} + +/// In-process transport: routes the demo venue id straight into the +/// adapter, standing in for the host registry the keeper-side seam +/// binds. +struct InProcessClient; + +impl videre_sdk::client::sealed::SealedTransport for InProcessClient {} + +impl VenueTransport for InProcessClient { + async fn quote(&self, venue: &VenueId, body: Vec) -> Result { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); + } + DemoAdapter::quote(body).map_err(Into::into) + } + + async fn submit(&self, venue: &VenueId, body: Vec) -> Result { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); + } + DemoAdapter::submit(body).map_err(Into::into) + } + + async fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); + } + DemoAdapter::status(receipt.to_vec()).map_err(Into::into) + } + + async fn cancel(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + if venue.as_str() != "demo" { + return Err(VenueFault::UnknownVenue); + } + DemoAdapter::cancel(receipt.to_vec()).map_err(Into::into) + } +} + +fn v2_body() -> QuoteBody { + QuoteBody::V2(QuoteV2 { + amount_wei: 1_000_000, + memo: "two coffees".to_owned(), + valid_until_ms: Some(1_700_000_000_000), + }) +} + +#[test] +fn versioned_body_round_trips_through_the_derive() { + for body in [ + QuoteBody::V1(QuoteV1 { + amount_wei: 42, + memo: "one".to_owned(), + }), + v2_body(), + ] { + let bytes = body.to_bytes().expect("derived payloads encode"); + assert_eq!(QuoteBody::from_bytes(&bytes).unwrap(), body); + } +} + +#[test] +fn wire_tag_is_the_declaration_index() { + let v1 = QuoteBody::V1(QuoteV1 { + amount_wei: 1, + memo: String::new(), + }) + .to_bytes() + .unwrap(); + let v2 = v2_body().to_bytes().unwrap(); + assert_eq!(v1[0], 0); + assert_eq!(v2[0], 1); +} + +#[test] +fn unknown_version_fails_typedly() { + let mut bytes = v2_body().to_bytes().unwrap(); + bytes[0] = 9; + assert_eq!( + QuoteBody::from_bytes(&bytes), + Err(BodyError::UnknownVersion { version: 9 }) + ); +} + +#[test] +fn empty_and_malformed_bodies_fail_typedly() { + assert_eq!(QuoteBody::from_bytes(&[]), Err(BodyError::Empty)); + + // A known tag with a truncated payload. + let mut bytes = v2_body().to_bytes().unwrap(); + bytes.truncate(bytes.len() - 1); + assert!(matches!( + QuoteBody::from_bytes(&bytes), + Err(BodyError::Malformed { version: 1, .. }) + )); + + // A known tag with trailing bytes: borsh requires full consumption. + let mut bytes = v2_body().to_bytes().unwrap(); + bytes.push(0); + assert!(matches!( + QuoteBody::from_bytes(&bytes), + Err(BodyError::Malformed { version: 1, .. }) + )); +} + +#[test] +fn adapter_projects_the_header_from_a_versioned_body() { + let bytes = v2_body().to_bytes().unwrap(); + let header = DemoAdapter::derive_header(bytes).unwrap(); + assert_eq!(header.gives.asset, Asset::Native); + assert_eq!(header.gives.amount, 1_000_000u64.to_be_bytes().to_vec()); + assert_eq!(header.settlement, Settlement { chain: 1 }); + assert_eq!(header.authorisation, AuthScheme::Eip712); +} + +#[test] +fn adapter_reports_an_unknown_version_as_invalid_body() { + let mut bytes = v2_body().to_bytes().unwrap(); + bytes[0] = 7; + let err = DemoAdapter::derive_header(bytes).unwrap_err(); + match err { + VenueError::InvalidBody(detail) => assert!(detail.contains("unknown body version 7")), + other => panic!("expected invalid-body, got {other:?}"), + } +} + +#[test] +fn typed_client_round_trips_through_the_transport_seam() { + let client = VenueClient::::with_transport(InProcessClient); + assert_eq!(client.venue(), DemoVenue::ID); + + let outcome = run(client.submit(&v2_body())).unwrap(); + let SubmitOutcome::Accepted(receipt) = outcome else { + panic!("demo venue always accepts"); + }; + assert_eq!(receipt, RECEIPT.to_vec()); + + assert_eq!(run(client.status(&receipt)).unwrap(), IntentStatus::Open); + run(client.cancel(&receipt)).unwrap(); + + assert!(matches!( + run(client.status(&[0, 1])).unwrap_err(), + ClientError::Venue(VenueFault::Denied(_)) + )); +} + +#[test] +fn quote_typestate_prices_then_submits_the_quoted_body() { + async fn drive( + client: &VenueClient, + ) -> Result { + // The typestate chain under test: a quotation is the only path + // from a priced body to its submission. Static dispatch end to + // end: the transport is native AFIT, nothing boxes. + client.quote(&v2_body()).await?.submit().await + } + + let client = VenueClient::::with_transport(InProcessClient); + + let quoted = run(client.quote(&v2_body())).unwrap(); + assert_eq!( + quoted.quotation().gives.amount, + 1_000_000u64.to_be_bytes().to_vec() + ); + assert_eq!(quoted.quotation().valid_until_ms, 1_700_000_000_000); + + let outcome = run(drive(&client)).unwrap(); + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == RECEIPT.to_vec())); +} + +#[test] +fn empty_receipt_is_rejected_before_the_transport() { + // The unbound venue would report unknown-venue, so invalid-receipt + // proves the guard fires before the transport is consulted. + let client = VenueClient::::with_transport(InProcessClient); + assert!(matches!( + run(client.status(&[])).unwrap_err(), + ClientError::Venue(VenueFault::InvalidReceipt) + )); + assert!(matches!( + run(client.cancel(&[])).unwrap_err(), + ClientError::Venue(VenueFault::InvalidReceipt) + )); +} + +#[test] +fn unbound_venue_is_unknown_at_the_client() { + let client = VenueClient::::with_transport(InProcessClient); + assert!(matches!( + run(client.submit(&v2_body())).unwrap_err(), + ClientError::Venue(VenueFault::UnknownVenue) + )); +} diff --git a/videre/crates/videre-status-body/Cargo.toml b/videre/crates/videre-status-body/Cargo.toml new file mode 100644 index 00000000..67967b12 --- /dev/null +++ b/videre/crates/videre-status-body/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "videre-status-body" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Versioned codec for the opaque status body the host event stream carries: a leading version tag, then that version's borsh payload; unknown tags fail closed." + +[lints] +workspace = true + +[dependencies] +borsh.workspace = true +thiserror.workspace = true diff --git a/videre/crates/videre-status-body/src/lib.rs b/videre/crates/videre-status-body/src/lib.rs new file mode 100644 index 00000000..da621ce3 --- /dev/null +++ b/videre/crates/videre-status-body/src/lib.rs @@ -0,0 +1,424 @@ +//! Versioned opaque status-body codec. +//! +//! The host `event` stream carries an intent-status transition as opaque +//! bytes: a leading `u8` version tag, then that version's borsh payload. +//! An unknown tag fails closed; a body is never empty. +//! +//! v1 body: `0x01`, the [`IntentStatus`] discriminant, then borsh `option` +//! encodings of `proof` and `reason`. +//! +//! The [`IntentStatusUpdate`] envelope is tagged and fails closed on its +//! own version line, independent of the body it carries. v1 envelope: +//! `0x01`, then borsh `{venue, receipt, status}`. + +#![warn(missing_docs)] + +use borsh::{BorshDeserialize, BorshSerialize}; + +/// Wire tag of the v1 payload. +pub const VERSION_V1: u8 = 1; + +/// Wire tag of the v1 [`IntentStatusUpdate`] envelope; independent of +/// [`VERSION_V1`]. +pub const ENVELOPE_VERSION_V1: u8 = 1; + +/// The `custom-event.kind` an intent-status transition rides on, matched +/// by subscribing modules. +pub const INTENT_STATUS_KIND: &str = "intent-status"; + +/// Envelope an intent-status `custom` event carries: +/// [`ENVELOPE_VERSION_V1`], then borsh `{venue, receipt, status}`, where +/// `status` is a [`StatusBody`]-encoded body. +#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] +pub struct IntentStatusUpdate { + /// Venue id the receipt was issued by. + pub venue: String, + /// The venue-scoped intent identifier, opaque to the host. + pub receipt: Vec, + /// The [`StatusBody`]-encoded status body. + pub status: Vec, +} + +impl IntentStatusUpdate { + /// Encode as the envelope version tag plus the borsh payload. + pub fn encode(&self) -> Result, EncodeError> { + let mut out = vec![ENVELOPE_VERSION_V1]; + borsh::to_writer(&mut out, self).map_err(|err| EncodeError { + detail: err.to_string(), + })?; + Ok(out) + } + + /// Decode; fails on empty, an unknown tag (fail-closed), or a payload + /// that does not parse as the tagged version. + pub fn decode(bytes: &[u8]) -> Result { + match bytes { + [] => Err(EnvelopeError::Empty), + [ENVELOPE_VERSION_V1, payload @ ..] => { + borsh::from_slice(payload).map_err(|err| EnvelopeError::Malformed { + version: ENVELOPE_VERSION_V1, + detail: err.to_string(), + }) + } + [version, ..] => Err(EnvelopeError::UnknownVersion { version: *version }), + } + } +} + +/// Why bytes failed to decode as an [`IntentStatusUpdate`] envelope. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] +pub enum EnvelopeError { + /// No bytes at all: not even a version tag. + #[error("empty intent-status envelope: missing the version tag")] + Empty, + /// The tag names no published envelope version (fail-closed). + #[error("unknown intent-status envelope version {version}")] + UnknownVersion { + /// The unrecognised wire tag. + version: u8, + }, + /// The tag named a known version but its payload did not decode + /// (malformed borsh or trailing bytes). + #[error("malformed version {version} intent-status envelope: {detail}")] + Malformed { + /// The wire tag whose payload failed. + version: u8, + /// Borsh's decode failure detail. + detail: String, + }, +} + +/// Where an intent is in its life at the venue; the borsh discriminant is +/// wire form, so append new states, never reorder. +#[derive(BorshDeserialize, BorshSerialize, Clone, Copy, Debug, Eq, PartialEq)] +pub enum IntentStatus { + /// Accepted for processing but not yet live at the venue. + Pending, + /// Live at the venue and eligible for settlement. + Open, + /// Settled. + Fulfilled, + /// Withdrawn or terminally refused before settlement. + Cancelled, + /// Reached its expiry without settling. + Expired, +} + +/// Why an intent failed terminally, as reported by the venue. +#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] +pub struct FailReason { + /// Venue-scoped machine-readable code, stable enough to match on. + pub code: String, + /// Human-readable detail for logs and the consent surface. + pub detail: String, +} + +/// One decoded status body. There is no `failed` status: a terminal +/// failure reads as a non-[`Fulfilled`] terminal `status` plus a `reason`. +/// +/// [`Fulfilled`]: IntentStatus::Fulfilled +#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] +pub struct StatusBody { + /// Where the intent is in its life at the venue. + pub status: IntentStatus, + /// Venue-defined settlement proof. + pub proof: Option>, + /// Terminal-failure reason. + pub reason: Option, +} + +impl StatusBody { + /// Encode as the version tag plus the borsh payload; never empty. + pub fn encode(&self) -> Result, EncodeError> { + let mut out = vec![VERSION_V1]; + borsh::to_writer(&mut out, self).map_err(|err| EncodeError { + detail: err.to_string(), + })?; + Ok(out) + } + + /// Decode; fails on empty, an unknown tag (fail-closed), or a payload + /// that does not parse as the tagged version. + pub fn decode(bytes: &[u8]) -> Result { + match bytes { + [] => Err(DecodeError::Empty), + [VERSION_V1, payload @ ..] => { + borsh::from_slice(payload).map_err(|err| DecodeError::Malformed { + version: VERSION_V1, + detail: err.to_string(), + }) + } + [version, ..] => Err(DecodeError::UnknownVersion { version: *version }), + } + } +} + +/// A payload failed to encode. Only reachable when a field's length +/// exceeds the wire's `u32` bound. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[error("status body failed to encode: {detail}")] +pub struct EncodeError { + /// Borsh's encode failure detail. + pub detail: String, +} + +/// Why bytes failed to decode as a status body. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] +pub enum DecodeError { + /// No bytes at all: not even a version tag. + #[error("empty status body: missing the version tag")] + Empty, + /// The tag names no published version (fail-closed). + #[error("unknown status-body version {version}")] + UnknownVersion { + /// The unrecognised wire tag. + version: u8, + }, + /// The tag named a known version but its payload did not decode + /// (malformed borsh or trailing bytes). + #[error("malformed version {version} payload: {detail}")] + Malformed { + /// The wire tag whose payload failed. + version: u8, + /// Borsh's decode failure detail. + detail: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn body(status: IntentStatus) -> StatusBody { + StatusBody { + status, + proof: None, + reason: None, + } + } + + fn envelope(venue: &str) -> IntentStatusUpdate { + IntentStatusUpdate { + venue: venue.to_owned(), + receipt: b"receipt".to_vec(), + status: body(IntentStatus::Open).encode().expect("encode body"), + } + } + + #[test] + fn envelope_leads_with_its_version_tag() { + let encoded = envelope("cow").encode().expect("encode"); + assert_eq!(encoded[0], ENVELOPE_VERSION_V1); + } + + /// Pins the whole v1 envelope framing, not just the tag position. + #[test] + fn golden_envelope() { + let encoded = envelope("cow").encode().expect("encode"); + let expected = [ + &[ENVELOPE_VERSION_V1, 3, 0, 0, 0][..], + b"cow", + &[7, 0, 0, 0], + b"receipt", + &[4, 0, 0, 0, VERSION_V1, 1, 0, 0], + ] + .concat(); + assert_eq!(encoded, expected); + } + + #[test] + fn envelope_round_trips() { + let original = envelope("cow"); + let decoded = + IntentStatusUpdate::decode(&original.encode().expect("encode")).expect("decode"); + assert_eq!(decoded, original); + } + + #[test] + fn empty_envelope_fails_typedly() { + assert_eq!(IntentStatusUpdate::decode(&[]), Err(EnvelopeError::Empty)); + } + + /// A future envelope version is refused, not misparsed. + #[test] + fn future_envelope_version_fails_closed() { + let mut skewed = envelope("cow").encode().expect("encode"); + skewed[0] = ENVELOPE_VERSION_V1 + 1; + assert_eq!( + IntentStatusUpdate::decode(&skewed), + Err(EnvelopeError::UnknownVersion { + version: ENVELOPE_VERSION_V1 + 1, + }), + ); + } + + /// Bare borsh with no leading tag must never decode. + #[test] + fn untagged_envelope_never_decodes() { + for venue in ["c", "cow", "a longer venue id"] { + let mut untagged = Vec::new(); + borsh::to_writer(&mut untagged, &envelope(venue)).expect("encode"); + assert!( + IntentStatusUpdate::decode(&untagged).is_err(), + "untagged {venue} envelope decoded", + ); + } + } + + #[test] + fn envelope_trailing_bytes_are_malformed() { + let mut encoded = envelope("cow").encode().expect("encode"); + encoded.push(0); + assert!(matches!( + IntentStatusUpdate::decode(&encoded), + Err(EnvelopeError::Malformed { + version: ENVELOPE_VERSION_V1, + .. + }), + )); + } + + #[test] + fn truncated_envelope_is_malformed() { + let encoded = envelope("cow").encode().expect("encode"); + assert!(matches!( + IntentStatusUpdate::decode(&encoded[..encoded.len() - 1]), + Err(EnvelopeError::Malformed { + version: ENVELOPE_VERSION_V1, + .. + }), + )); + } + + /// Envelope and body tags are separate wire lines: the envelope + /// decodes even when its body version is refused. + #[test] + fn envelope_and_body_versions_are_independent() { + let mut update = envelope("cow"); + update.status[0] = VERSION_V1 + 1; + let decoded = + IntentStatusUpdate::decode(&update.encode().expect("encode")).expect("decode"); + assert_eq!( + StatusBody::decode(&decoded.status), + Err(DecodeError::UnknownVersion { + version: VERSION_V1 + 1, + }), + ); + } + + #[test] + fn golden_minimal_open() { + let encoded = body(IntentStatus::Open).encode().expect("encode"); + assert_eq!(encoded, [VERSION_V1, 1, 0, 0]); + } + + #[test] + fn golden_fulfilled_with_proof() { + let encoded = StatusBody { + status: IntentStatus::Fulfilled, + proof: Some(vec![0xaa, 0xbb]), + reason: None, + } + .encode() + .expect("encode"); + assert_eq!(encoded, [VERSION_V1, 2, 1, 2, 0, 0, 0, 0xaa, 0xbb, 0]); + } + + #[test] + fn golden_terminal_failure() { + let encoded = StatusBody { + status: IntentStatus::Cancelled, + proof: None, + reason: Some(FailReason { + code: "oc".into(), + detail: "od".into(), + }), + } + .encode() + .expect("encode"); + assert_eq!( + encoded, + [ + VERSION_V1, 3, 0, 1, 2, 0, 0, 0, b'o', b'c', 2, 0, 0, 0, b'o', b'd' + ], + ); + } + + #[test] + fn round_trips_every_status() { + for status in [ + IntentStatus::Pending, + IntentStatus::Open, + IntentStatus::Fulfilled, + IntentStatus::Cancelled, + IntentStatus::Expired, + ] { + let original = StatusBody { + status, + proof: Some(b"proof".to_vec()), + reason: Some(FailReason { + code: "code".into(), + detail: "detail".into(), + }), + }; + let decoded = StatusBody::decode(&original.encode().expect("encode")).expect("decode"); + assert_eq!(decoded, original); + } + } + + #[test] + fn a_body_is_never_empty() { + let encoded = body(IntentStatus::Pending).encode().expect("encode"); + assert!(encoded.len() >= 2, "at minimum the tag and the status"); + } + + #[test] + fn empty_bytes_fail_typedly() { + assert_eq!(StatusBody::decode(&[]), Err(DecodeError::Empty)); + } + + #[test] + fn unknown_version_fails_closed() { + assert_eq!( + StatusBody::decode(&[2, 1, 0, 0]), + Err(DecodeError::UnknownVersion { version: 2 }), + ); + } + + #[test] + fn unknown_status_discriminant_is_malformed() { + assert!(matches!( + StatusBody::decode(&[VERSION_V1, 5, 0, 0]), + Err(DecodeError::Malformed { + version: VERSION_V1, + .. + }), + )); + } + + #[test] + fn trailing_bytes_are_malformed() { + let mut encoded = body(IntentStatus::Open).encode().expect("encode"); + encoded.push(0); + assert!(matches!( + StatusBody::decode(&encoded), + Err(DecodeError::Malformed { + version: VERSION_V1, + .. + }), + )); + } + + #[test] + fn truncated_payload_is_malformed() { + assert!(matches!( + StatusBody::decode(&[VERSION_V1, 1, 0]), + Err(DecodeError::Malformed { + version: VERSION_V1, + .. + }), + )); + } +} diff --git a/videre/crates/videre-test/Cargo.toml b/videre/crates/videre-test/Cargo.toml new file mode 100644 index 00000000..d53a82a6 --- /dev/null +++ b/videre/crates/videre-test/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "videre-test" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Conformance kit for venue adapters: file-published borsh codec round-trip vectors, header-derivation golden fixtures, and an in-memory MockTransport for adapter unit tests." + +[lib] +# Plain library, host-only - adapter crates list this under +# [dev-dependencies] so it never ships in the wasm bundle. + +[lints] +workspace = true + +[dependencies] +# The reference schema's payload structs derive the borsh traits, the +# same way a real venue's payload types do. +borsh.workspace = true +# Vector and golden files carry byte fields as lowercase hex so a +# non-Rust author can read them without a borsh decoder. +hex.workspace = true +# `MockFetch` speaks the standard `http` request/response types the +# SDK's `Fetch` seam is expressed in. +http.workspace = true +# Transport seam vocabulary: `ChainHost`, `Fault`, and the `Fetch` seam +# the mocks implement. +nexum-sdk = { path = "../../../nexum/crates/nexum-sdk" } +# `MockChain` is composed rather than reimplemented: one chain mock +# serves both personas. +nexum-sdk-test = { path = "../../../nexum/crates/nexum-sdk-test" } +# The contract under test: `IntentBody`, `BodyError`, the intent +# header types, and the `MessagingHost` seam. +videre-sdk = { path = "../videre-sdk" } +serde = { workspace = true } +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/videre/crates/videre-test/goldens/reference-header.json b/videre/crates/videre-test/goldens/reference-header.json new file mode 100644 index 00000000..c56703fd --- /dev/null +++ b/videre/crates/videre-test/goldens/reference-header.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "venue": "videre-test/reference", + "goldens": [ + { + "name": "v1-small", + "body": "00010000000000000002000000676d", + "header": { + "gives": { + "asset": "native", + "amount": "01" + }, + "wants": { + "asset": "native", + "amount": "" + }, + "settlement": { + "chain": 1 + }, + "authorisation": "eip712" + }, + "notes": "gives chain-1 native token, minimal big-endian amount" + }, + { + "name": "v2-full", + "body": "0140420f00000000000b00000074776f20636f6666656573010068e5cf8b010000140000000102030405060708090a0b0c0d0e0f101112131401", + "header": { + "gives": { + "asset": "native", + "amount": "0f4240" + }, + "wants": { + "asset": { + "erc20": { + "token": "0102030405060708090a0b0c0d0e0f1011121314" + } + }, + "amount": "0f4240" + }, + "settlement": { + "chain": 1 + }, + "authorisation": "eip712" + }, + "notes": "v2 adds an erc20 want at the recipient token address" + } + ] +} diff --git a/videre/crates/videre-test/src/codec.rs b/videre/crates/videre-test/src/codec.rs new file mode 100644 index 00000000..ab13ce6c --- /dev/null +++ b/videre/crates/videre-test/src/codec.rs @@ -0,0 +1,391 @@ +//! Codec conformance vectors: the JSON file format publishing a venue's +//! `IntentBody` wire bytes, and the check holding a codec to them. +//! +//! A vector file carries a leading format version (unknown versions fail +//! closed), bytes as lowercase hex, one entry per body, never zero. +//! [`CodecVectors::assert_conforms`] checks a derived enum against them. +//! Failure vectors pin the typed error contract: empty, unknown-version, +//! and malformed bodies must fail as [`BodyError`] names, not decode. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use videre_sdk::{BodyError, IntentBody}; + +use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; +use crate::report::{ConformanceReport, Violation, settle}; + +/// A published set of codec vectors for one venue body schema. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodecVectors { + /// File-format discriminator; an unknown version fails to parse. + pub version: FormatVersion, + /// Body schema the vectors bind; informational, the check never reads it. + pub schema: String, + /// The vectors, in publication order; never empty in a parsed file. + #[serde(deserialize_with = "fixture::non_empty")] + pub vectors: Vec, +} + +/// One published wire body and the outcome its bytes must produce. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodecVector { + /// Stable name a violation is reported under. + pub name: String, + /// The wire bytes, lowercase hex in the file. + #[serde(with = "hex_bytes")] + pub bytes: Vec, + /// What a conforming codec does with the bytes. + pub expect: Expectation, + /// Optional prose for readers of the file; the check ignores it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +/// The outcome a vector demands of a codec; failure cases mirror +/// [`BodyError`] minus its free-text detail. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Expectation { + /// The bytes decode, and re-encoding the decoded value reproduces + /// them exactly. + RoundTrip, + /// Decoding fails: no version tag at all. + Empty, + /// Decoding fails: the tag names no published version. + UnknownVersion { + /// The unknown wire tag. + version: u8, + }, + /// Decoding fails: a known tag whose payload does not parse. + Malformed { + /// The wire tag whose payload is broken. + version: u8, + }, +} + +impl std::fmt::Display for Expectation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Expectation::RoundTrip => f.write_str("round-trip"), + Expectation::Empty => f.write_str("empty"), + Expectation::UnknownVersion { version } => write!(f, "unknown-version {version}"), + Expectation::Malformed { version } => write!(f, "malformed {version}"), + } + } +} + +impl CodecVectors { + /// An empty vector set for `schema`; push at least one before publishing. + pub fn new(schema: impl Into) -> Self { + Self { + version: FormatVersion, + schema: schema.into(), + vectors: Vec::new(), + } + } + + /// Append a round-trip vector encoding `body`; returns it so the + /// caller can attach [`notes`](CodecVector::notes). + pub fn push_round_trip( + &mut self, + name: impl Into, + body: &B, + ) -> Result<&mut CodecVector, BodyError> { + let bytes = body.to_bytes()?; + self.vectors.push(CodecVector { + name: name.into(), + bytes, + expect: Expectation::RoundTrip, + notes: None, + }); + Ok(self.vectors.last_mut().expect("vector was just pushed")) + } + + /// Append a failure vector: raw bytes plus the typed decode error + /// they must produce. + /// + /// # Panics + /// + /// On [`Expectation::RoundTrip`]; use [`push_round_trip`](Self::push_round_trip). + pub fn push_failure( + &mut self, + name: impl Into, + bytes: Vec, + expect: Expectation, + ) -> &mut CodecVector { + assert!( + expect != Expectation::RoundTrip, + "push_failure takes a failure expectation; use push_round_trip", + ); + self.vectors.push(CodecVector { + name: name.into(), + bytes, + expect, + notes: None, + }); + self.vectors.last_mut().expect("vector was just pushed") + } + + /// Parse a vector set from its JSON text. + pub fn from_json(json: &str) -> Result { + fixture::from_json(json) + } + + /// The canonical published form: pretty JSON, trailing newline. + pub fn to_json(&self) -> String { + fixture::to_json(self) + } + + /// Load a vector file from disk. + pub fn load(path: impl AsRef) -> Result { + fixture::load(path.as_ref()) + } + + /// Write the vector file in its canonical published form. + pub fn write(&self, path: impl AsRef) -> Result<(), FixtureError> { + fixture::write(path.as_ref(), self) + } + + /// Check a codec against every vector, collecting all violations. + /// + /// A `round-trip` vector must decode and re-encode to the exact + /// published bytes; a failure vector must produce the matching + /// [`BodyError`] case (detail not compared). An empty set is itself a + /// violation. + pub fn check(&self) -> Result<(), ConformanceReport> { + let mut violations = Vec::new(); + if self.vectors.is_empty() { + violations.push(Violation { + vector: "".to_owned(), + detail: "published vector set is empty".to_owned(), + }); + } + for vector in &self.vectors { + if let Err(detail) = vector.check::() { + violations.push(Violation { + vector: vector.name.clone(), + detail, + }); + } + } + settle(violations) + } + + /// [`check`](Self::check), panicking with the full report on any violation. + pub fn assert_conforms(&self) { + if let Err(report) = self.check::() { + panic!("codec does not conform to {}:\n{report}", self.schema); + } + } +} + +impl CodecVector { + /// Check one vector, returning the violation detail on divergence. + fn check(&self) -> Result<(), String> { + let decoded = B::from_bytes(&self.bytes); + match (&self.expect, decoded) { + (Expectation::RoundTrip, Ok(body)) => { + let reencoded = body + .to_bytes() + .map_err(|err| format!("re-encode failed: {err}"))?; + if reencoded == self.bytes { + Ok(()) + } else { + Err(format!( + "re-encoded bytes diverge from the published vector: published {}, re-encoded {}", + hex::encode(&self.bytes), + hex::encode(&reencoded), + )) + } + } + (Expectation::RoundTrip, Err(err)) => { + Err(format!("expected a round trip, decode failed: {err}")) + } + (expect, Ok(_)) => Err(format!("expected {expect}, decode succeeded")), + (expect, Err(err)) => { + let matches = match (expect, &err) { + (Expectation::Empty, BodyError::Empty) => true, + ( + Expectation::UnknownVersion { version }, + BodyError::UnknownVersion { version: got }, + ) => version == got, + ( + Expectation::Malformed { version }, + BodyError::Malformed { version: got, .. }, + ) => version == got, + _ => false, + }; + if matches { + Ok(()) + } else { + Err(format!("expected {expect}, got: {err}")) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use borsh::{BorshDeserialize, BorshSerialize}; + use videre_sdk::IntentBody; + + use super::*; + + #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] + struct PayloadV1 { + amount: u64, + memo: String, + } + + #[derive(IntentBody, Clone, Debug, PartialEq, Eq)] + enum Body { + V1(PayloadV1), + } + + /// A codec with a diverging payload layout for the same tag. + #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] + struct NarrowPayload { + amount: u32, + memo: String, + } + + #[derive(IntentBody, Clone, Debug, PartialEq, Eq)] + enum NarrowBody { + V1(NarrowPayload), + } + + fn published() -> CodecVectors { + let mut vectors = CodecVectors::new("test/body"); + vectors + .push_round_trip( + "v1", + &Body::V1(PayloadV1 { + amount: 7, + memo: "gm".to_owned(), + }), + ) + .unwrap(); + vectors.push_failure("empty", Vec::new(), Expectation::Empty); + vectors.push_failure( + "unknown-version", + vec![9, 0, 0], + Expectation::UnknownVersion { version: 9 }, + ); + vectors.push_failure( + "truncated", + vec![0, 7], + Expectation::Malformed { version: 0 }, + ); + vectors + } + + #[test] + fn conforming_codec_passes_every_vector() { + published().check::().unwrap(); + } + + #[test] + fn diverging_codec_fails_with_named_vectors() { + let report = published().check::().unwrap_err(); + // The v1 payload no longer parses (u32 vs u64 layout); the + // failure vectors still fail as published, so the report names + // exactly the diverging vector. + assert_eq!(report.violations.len(), 1, "violations: {report}"); + assert_eq!(report.violations[0].vector, "v1"); + assert!(report.violations[0].detail.contains("decode failed")); + } + + #[test] + #[should_panic(expected = "codec does not conform")] + fn assert_conforms_panics_with_the_report() { + published().assert_conforms::(); + } + + #[test] + #[should_panic(expected = "push_failure takes a failure expectation")] + fn push_failure_rejects_round_trip() { + CodecVectors::new("test/body").push_failure("bad", Vec::new(), Expectation::RoundTrip); + } + + #[test] + fn json_form_is_stable_and_round_trips() { + let mut vectors = published(); + vectors.vectors[0].notes = Some("first published body".to_owned()); + let json = vectors.to_json(); + assert_eq!(CodecVectors::from_json(&json).unwrap(), vectors); + // The wire spellings are the contract for non-Rust readers. + assert!(json.contains("\"version\": 1")); + assert!(json.contains("\"round-trip\"")); + assert!(json.contains("\"unknown-version\"")); + assert!(json.contains("\"notes\": \"first published body\"")); + assert!(!json.contains("null"), "absent notes are omitted: {json}"); + } + + #[test] + fn files_round_trip_through_disk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vectors.json"); + let vectors = published(); + vectors.write(&path).unwrap(); + assert_eq!(CodecVectors::load(&path).unwrap(), vectors); + } + + #[test] + fn malformed_file_fails_typedly() { + assert!(matches!( + CodecVectors::from_json("{"), + Err(FixtureError::Format(_)), + )); + assert!(matches!( + CodecVectors::load("/nonexistent/vectors.json"), + Err(FixtureError::Read { .. }), + )); + } + + #[test] + fn unknown_format_version_fails_closed() { + let json = published() + .to_json() + .replace("\"version\": 1", "\"version\": 2"); + let Err(FixtureError::Format(detail)) = CodecVectors::from_json(&json) else { + panic!("version 2 must not parse"); + }; + assert!(detail.contains("unknown fixture format version 2")); + } + + #[test] + fn missing_format_version_fails() { + let json = published().to_json().replace(" \"version\": 1,\n", ""); + assert!(matches!( + CodecVectors::from_json(&json), + Err(FixtureError::Format(_)), + )); + } + + #[test] + fn empty_vector_set_fails_the_check() { + let report = CodecVectors::new("test/body").check::().unwrap_err(); + assert_eq!(report.violations.len(), 1, "violations: {report}"); + assert_eq!(report.violations[0].vector, ""); + assert!(report.violations[0].detail.contains("empty")); + } + + #[test] + #[should_panic(expected = "codec does not conform")] + fn assert_conforms_rejects_an_empty_set() { + CodecVectors::new("test/body").assert_conforms::(); + } + + #[test] + fn empty_vector_set_fails_to_parse() { + let json = CodecVectors::new("test/body").to_json(); + let Err(FixtureError::Format(detail)) = CodecVectors::from_json(&json) else { + panic!("an empty set must not parse"); + }; + assert!(detail.contains("never empty")); + } +} diff --git a/videre/crates/videre-test/src/fixture.rs b/videre/crates/videre-test/src/fixture.rs new file mode 100644 index 00000000..06f295f1 --- /dev/null +++ b/videre/crates/videre-test/src/fixture.rs @@ -0,0 +1,120 @@ +//! The shared fixture-file plumbing: JSON on disk, a leading +//! [`FormatVersion`] (unknown versions fail closed), byte fields as +//! lowercase hex, non-empty entry lists, and the typed +//! [`FixtureError`] both file formats load and save through. + +use std::path::Path; + +use serde::de::{DeserializeOwned, Error as _}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// The one published fixture file-format version. +const FORMAT_VERSION: u32 = 1; + +/// Fixture file-format discriminator: serializes as the current version, +/// refuses any other on parse (fail-closed). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct FormatVersion; + +impl Serialize for FormatVersion { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u32(FORMAT_VERSION) + } +} + +impl<'de> Deserialize<'de> for FormatVersion { + fn deserialize>(deserializer: D) -> Result { + let version = u32::deserialize(deserializer)?; + if version == FORMAT_VERSION { + Ok(Self) + } else { + Err(D::Error::custom(format!( + "unknown fixture format version {version}; this reader speaks {FORMAT_VERSION}", + ))) + } + } +} + +/// Deserialize a fixture's entry list, refusing an empty one. +pub(crate) fn non_empty<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + let entries = Vec::::deserialize(deserializer)?; + if entries.is_empty() { + return Err(D::Error::custom("a published fixture set is never empty")); + } + Ok(entries) +} + +/// Why a fixture file failed to load or save. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum FixtureError { + /// The file could not be read. + #[error("failed to read {path}: {source}")] + Read { + /// Path the read targeted. + path: String, + /// The underlying io failure. + source: std::io::Error, + }, + /// The file could not be written. + #[error("failed to write {path}: {source}")] + Write { + /// Path the write targeted. + path: String, + /// The underlying io failure. + source: std::io::Error, + }, + /// The content did not parse as the fixture format. + #[error("malformed fixture json: {0}")] + Format(String), +} + +/// Render a fixture as canonical published form: pretty JSON, trailing newline. +pub(crate) fn to_json(value: &T) -> String { + let mut json = serde_json::to_string_pretty(value).expect("fixture types serialize infallibly"); + json.push('\n'); + json +} + +/// Parse a fixture from its JSON text. +pub(crate) fn from_json(json: &str) -> Result { + serde_json::from_str(json).map_err(|err| FixtureError::Format(err.to_string())) +} + +/// Load a fixture file from disk. +pub(crate) fn load(path: &Path) -> Result { + let json = std::fs::read_to_string(path).map_err(|source| FixtureError::Read { + path: path.display().to_string(), + source, + })?; + from_json(&json) +} + +/// Write a fixture file in its canonical published form. +pub(crate) fn write(path: &Path, value: &T) -> Result<(), FixtureError> { + std::fs::write(path, to_json(value)).map_err(|source| FixtureError::Write { + path: path.display().to_string(), + source, + }) +} + +/// Serde codec for byte fields: lowercase hex, no prefix. +pub(crate) mod hex_bytes { + use serde::de::Error as _; + use serde::{Deserialize, Deserializer, Serializer}; + + pub(crate) fn serialize(bytes: &[u8], serializer: S) -> Result { + serializer.serialize_str(&hex::encode(bytes)) + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let text = String::deserialize(deserializer)?; + hex::decode(&text).map_err(D::Error::custom) + } +} diff --git a/videre/crates/videre-test/src/header.rs b/videre/crates/videre-test/src/header.rs new file mode 100644 index 00000000..0b3dc352 --- /dev/null +++ b/videre/crates/videre-test/src/header.rs @@ -0,0 +1,407 @@ +//! Header-derivation goldens: the JSON file format publishing what +//! `derive-header` must project from each body, and the check holding an +//! adapter to it. +//! +//! A golden file pairs wire bodies with the derived header, in the mirror +//! types below (leading format version fails closed, kebab-case case names +//! matching the WIT, bytes as lowercase hex, never zero goldens). +//! [`GoldenHeader`] converts from the SDK's `IntentHeader`, so an +//! adapter's `derive_header` feeds the check directly. + +use std::fmt; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{AuthScheme, IntentHeader, Settlement}; + +use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; +use crate::report::{ConformanceReport, Violation, settle}; + +/// A published set of header goldens for one venue. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HeaderGoldens { + /// File-format discriminator; an unknown version fails to parse. + pub version: FormatVersion, + /// The venue the goldens bind; informational, the check never reads it. + pub venue: String, + /// The goldens, in publication order; never empty in a parsed file. + #[serde(deserialize_with = "fixture::non_empty")] + pub goldens: Vec, +} + +/// One wire body and the header a conforming adapter derives from it. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HeaderGolden { + /// Stable name a violation is reported under. + pub name: String, + /// The intent body, lowercase hex in the file. + #[serde(with = "hex_bytes")] + pub body: Vec, + /// The header `derive-header` must produce for the body. + pub header: GoldenHeader, + /// Optional prose for readers of the file; the check ignores it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +/// Serde mirror of the wire `intent-header`. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct GoldenHeader { + /// Value leaving the user's control. + pub gives: GoldenAssetAmount, + /// Value expected in return. Display-grade, not host-verified. + pub wants: GoldenAssetAmount, + /// Where the deal settles. + pub settlement: GoldenSettlement, + /// How the venue authorises the intent. + pub authorisation: GoldenAuthScheme, +} + +/// Serde mirror of the wire `asset-amount`. `amount` is big-endian +/// unsigned, minimal-length, hex in the file; an empty string is zero. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GoldenAssetAmount { + /// The asset moving. + pub asset: GoldenAsset, + /// Big-endian minimal-length unsigned amount bytes. + #[serde(with = "hex_bytes")] + pub amount: Vec, +} + +/// Serde mirror of the wire `settlement`. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GoldenSettlement { + /// EVM chain id the deal settles on. + pub chain: u64, +} + +/// Serde mirror of the wire `asset`. Token addresses are hex in the file. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde( + rename_all = "kebab-case", + rename_all_fields = "kebab-case", + deny_unknown_fields +)] +pub enum GoldenAsset { + /// The settlement chain's gas token. + Native, + /// An ERC-20 token on the settlement chain. + Erc20 { + /// 20-byte contract address. + #[serde(with = "hex_bytes")] + token: Vec, + }, +} + +/// Serde mirror of the wire `auth-scheme`. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum GoldenAuthScheme { + /// EIP-1271 contract signature. + Eip1271, + /// EIP-712 typed-data signature by host-held keys. + Eip712, +} + +impl From for GoldenHeader { + fn from(header: IntentHeader) -> Self { + Self { + gives: header.gives.into(), + wants: header.wants.into(), + settlement: header.settlement.into(), + authorisation: header.authorisation.into(), + } + } +} + +impl From for GoldenAssetAmount { + fn from(amount: AssetAmount) -> Self { + Self { + asset: amount.asset.into(), + amount: amount.amount, + } + } +} + +impl From for GoldenSettlement { + fn from(settlement: Settlement) -> Self { + Self { + chain: settlement.chain, + } + } +} + +impl From for GoldenAsset { + fn from(asset: Asset) -> Self { + match asset { + Asset::Native => GoldenAsset::Native, + Asset::Erc20(erc20) => GoldenAsset::Erc20 { token: erc20.token }, + } + } +} + +impl From for GoldenAuthScheme { + fn from(scheme: AuthScheme) -> Self { + match scheme { + AuthScheme::Eip1271 => GoldenAuthScheme::Eip1271, + AuthScheme::Eip712 => GoldenAuthScheme::Eip712, + } + } +} + +impl HeaderGoldens { + /// An empty golden set for `venue`; record at least one before publishing. + pub fn new(venue: impl Into) -> Self { + Self { + version: FormatVersion, + venue: venue.into(), + goldens: Vec::new(), + } + } + + /// Append a golden by running `derive` on `body`; returns it so the + /// caller can attach [`notes`](HeaderGolden::notes). + pub fn record( + &mut self, + name: impl Into, + body: Vec, + derive: impl FnOnce(Vec) -> Result, + ) -> Result<&mut HeaderGolden, E> + where + H: Into, + { + let header = derive(body.clone())?.into(); + self.goldens.push(HeaderGolden { + name: name.into(), + body, + header, + notes: None, + }); + Ok(self.goldens.last_mut().expect("golden was just pushed")) + } + + /// Parse a golden set from its JSON text. + pub fn from_json(json: &str) -> Result { + fixture::from_json(json) + } + + /// The canonical published form: pretty JSON, trailing newline. + pub fn to_json(&self) -> String { + fixture::to_json(self) + } + + /// Load a golden file from disk. + pub fn load(path: impl AsRef) -> Result { + fixture::load(path.as_ref()) + } + + /// Write the golden file in its canonical published form. + pub fn write(&self, path: impl AsRef) -> Result<(), FixtureError> { + fixture::write(path.as_ref(), self) + } + + /// Check `derive` against every golden, collecting all violations. + /// + /// A trait-based adapter passes `MyAdapter::derive_header` directly. + /// An empty set is itself a violation. + pub fn check( + &self, + mut derive: impl FnMut(Vec) -> Result, + ) -> Result<(), ConformanceReport> + where + H: Into, + E: fmt::Debug, + { + let mut violations = Vec::new(); + if self.goldens.is_empty() { + violations.push(Violation { + vector: "".to_owned(), + detail: "published golden set is empty".to_owned(), + }); + } + for golden in &self.goldens { + match derive(golden.body.clone()) { + Ok(header) => { + let derived: GoldenHeader = header.into(); + if derived != golden.header { + violations.push(Violation { + vector: golden.name.clone(), + detail: format!( + "derived header diverges from the golden: expected {:?}, derived {derived:?}", + golden.header, + ), + }); + } + } + Err(err) => violations.push(Violation { + vector: golden.name.clone(), + detail: format!("derive-header failed: {err:?}"), + }), + } + } + settle(violations) + } + + /// [`check`](Self::check), panicking with the full report on any violation. + pub fn assert_conforms(&self, derive: impl FnMut(Vec) -> Result) + where + H: Into, + E: fmt::Debug, + { + if let Err(report) = self.check(derive) { + panic!( + "derive-header does not conform to the {} goldens:\n{report}", + self.venue, + ); + } + } +} + +#[cfg(test)] +mod tests { + use videre_sdk::VenueError; + use videre_sdk::value_flow::Erc20; + + use super::*; + + fn wire_header() -> IntentHeader { + IntentHeader { + gives: AssetAmount { + asset: Asset::Native, + amount: vec![0x0d, 0xe0, 0xb6], + }, + wants: AssetAmount { + asset: Asset::Erc20(Erc20 { + token: vec![0xAA; 20], + }), + amount: vec![1, 0], + }, + settlement: Settlement { chain: 100 }, + authorisation: AuthScheme::Eip1271, + } + } + + #[test] + fn golden_mirror_covers_every_wire_case_and_round_trips_as_json() { + let golden: GoldenHeader = wire_header().into(); + let goldens = HeaderGoldens { + version: FormatVersion, + venue: "acme".to_owned(), + goldens: vec![HeaderGolden { + name: "kitchen-sink".to_owned(), + body: vec![0], + header: golden, + notes: None, + }], + }; + let json = goldens.to_json(); + assert_eq!(HeaderGoldens::from_json(&json).unwrap(), goldens); + // The wire spellings are the contract for non-Rust readers. + assert!(json.contains("\"version\": 1")); + assert!(json.contains("\"native\"")); + assert!(json.contains("\"erc20\"")); + assert!(json.contains("\"token\"")); + assert!(json.contains("\"chain\"")); + assert!(json.contains("\"eip1271\"")); + } + + #[test] + fn conforming_derivation_passes() { + let mut goldens = HeaderGoldens::new("acme"); + goldens + .record("kitchen-sink", vec![1, 2, 3], |_| { + Ok::<_, VenueError>(wire_header()) + }) + .unwrap(); + goldens + .check(|_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + } + + #[test] + fn diverging_derivation_and_failure_are_both_violations() { + let mut goldens = HeaderGoldens::new("acme"); + goldens + .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + goldens + .record("b", vec![2], |_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + + let mut calls = 0; + let report = goldens + .check(|_| { + calls += 1; + if calls == 1 { + let mut header = wire_header(); + header.authorisation = AuthScheme::Eip712; + Ok(header) + } else { + Err(VenueError::InvalidBody("nope".to_owned())) + } + }) + .unwrap_err(); + + assert_eq!(report.violations.len(), 2); + assert_eq!(report.violations[0].vector, "a"); + assert!(report.violations[0].detail.contains("diverges")); + assert_eq!(report.violations[1].vector, "b"); + assert!(report.violations[1].detail.contains("derive-header failed")); + } + + #[test] + #[should_panic(expected = "derive-header does not conform")] + fn assert_conforms_panics_with_the_report() { + let mut goldens = HeaderGoldens::new("acme"); + goldens + .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + goldens.assert_conforms(|_| Err::(VenueError::Timeout)); + } + + #[test] + fn unknown_format_version_fails_closed() { + let mut goldens = HeaderGoldens::new("acme"); + goldens + .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + let json = goldens + .to_json() + .replace("\"version\": 1", "\"version\": 7"); + let Err(FixtureError::Format(detail)) = HeaderGoldens::from_json(&json) else { + panic!("version 7 must not parse"); + }; + assert!(detail.contains("unknown fixture format version 7")); + } + + #[test] + fn empty_golden_set_fails_the_check() { + let report = HeaderGoldens::new("acme") + .check(|_| Ok::<_, VenueError>(wire_header())) + .unwrap_err(); + assert_eq!(report.violations.len(), 1, "violations: {report}"); + assert_eq!(report.violations[0].vector, ""); + assert!(report.violations[0].detail.contains("empty")); + } + + #[test] + #[should_panic(expected = "derive-header does not conform")] + fn assert_conforms_rejects_an_empty_set() { + HeaderGoldens::new("acme").assert_conforms(|_| Ok::<_, VenueError>(wire_header())); + } + + #[test] + fn empty_golden_set_fails_to_parse() { + let json = HeaderGoldens::new("acme").to_json(); + let Err(FixtureError::Format(detail)) = HeaderGoldens::from_json(&json) else { + panic!("an empty set must not parse"); + }; + assert!(detail.contains("never empty")); + } +} diff --git a/videre/crates/videre-test/src/lib.rs b/videre/crates/videre-test/src/lib.rs new file mode 100644 index 00000000..e9448c37 --- /dev/null +++ b/videre/crates/videre-test/src/lib.rs @@ -0,0 +1,47 @@ +//! Conformance kit for venue adapters: file-published codec vectors, +//! header-derivation goldens, and an in-memory transport mock. +//! +//! - [`CodecVectors`]: the venue's `IntentBody` wire bytes as JSON +//! (lowercase hex); [`CodecVectors::assert_conforms`] checks a derived +//! enum against them. +//! - [`HeaderGoldens`]: published bodies paired with the header a +//! conforming `derive-header` projects, in the [`GoldenHeader`] mirror +//! types. +//! - [`MockTransport`]: the chain, messaging, and outbound-HTTP +//! transports as programmable in-memory mocks behind the SDK seams. +//! +//! ```rust +//! use videre_test::reference::{ +//! CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, +//! }; +//! use videre_test::{CodecVectors, HeaderGoldens}; +//! +//! let vectors = CodecVectors::from_json(CODEC_VECTORS_JSON).unwrap(); +//! vectors.assert_conforms::(); +//! let goldens = HeaderGoldens::from_json(HEADER_GOLDENS_JSON).unwrap(); +//! goldens.assert_conforms(derive_reference_header); +//! ``` + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] + +pub mod codec; +pub mod fixture; +pub mod header; +pub mod reconcile; +pub mod reference; +pub mod report; +pub mod transport; + +pub use codec::{CodecVector, CodecVectors, Expectation}; +pub use fixture::{FixtureError, FormatVersion}; +pub use header::{ + GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, HeaderGolden, + HeaderGoldens, +}; +pub use reconcile::ReconcileFixture; +pub use report::{ConformanceReport, Violation}; +pub use transport::{ + ChainCall, Message, MessagingHost, MockChain, MockFetch, MockMessaging, MockTransport, + PublishRecord, RecordedRequest, +}; diff --git a/videre/crates/videre-test/src/reconcile.rs b/videre/crates/videre-test/src/reconcile.rs new file mode 100644 index 00000000..dd0a7dfc --- /dev/null +++ b/videre/crates/videre-test/src/reconcile.rs @@ -0,0 +1,104 @@ +//! The reconcile-contract compliance suite: an adapter proves it honours +//! [`VenueReconcile`](videre_sdk::VenueReconcile) by implementing +//! [`ReconcileFixture`] over its own mock transport and invoking +//! [`venue_reconcile_compliance!`](crate::venue_reconcile_compliance). + +use videre_sdk::{IntentStatus, SubmitOutcome, VenueFault}; + +use crate::MockFetch; + +/// The per-adapter fixtures the compliance suite drives: `program_*` hooks +/// arm a [`MockFetch`], `submit`/`status` run the adapter's paths lifting +/// its error into [`VenueFault`]. +pub trait ReconcileFixture { + /// A signed body the venue accepts and derives a receipt for. + fn signed_body() -> Vec; + /// A pre-sign body the venue accepts. + fn presign_body() -> Vec; + /// The body-derived receipt a held body resolves to. + fn receipt() -> Vec; + /// Arm the mock to accept a fresh submission. + fn program_accept(fetch: &MockFetch); + /// Arm the mock to reject a submission as already held. + fn program_already_held(fetch: &MockFetch); + /// Arm the mock to answer a status read as not-found. + fn program_absent(fetch: &MockFetch); + /// Submit a body through the adapter over `fetch`. + fn submit(fetch: &MockFetch, body: &[u8]) -> Result; + /// Read a receipt's status through the adapter over `fetch`. + fn status(fetch: &MockFetch, receipt: &[u8]) -> Result; +} + +/// A fresh submit and a re-POST of a held body resolve identically (re-POST idempotency). +pub fn assert_re_post_idempotent(body: &[u8]) { + let fresh = MockFetch::default(); + F::program_accept(&fresh); + let first = F::submit(&fresh, body).expect("a fresh submit is accepted"); + + let held = MockFetch::default(); + F::program_already_held(&held); + let again = F::submit(&held, body).expect("a held body folds to accepted, never a fault"); + + assert!( + first == again, + "a re-POST of a held body must resolve to the same outcome", + ); +} + +/// A held body never surfaces as a terminal fault, across both auth paths. +pub fn assert_held_never_faults() { + for body in [F::signed_body(), F::presign_body()] { + let held = MockFetch::default(); + F::program_already_held(&held); + assert!( + F::submit(&held, &body).is_ok(), + "a held body must fold to accepted, never a terminal fault", + ); + } +} + +/// An absent status read stays retryable (`unavailable`), never terminal. +pub fn assert_status_absent_is_retryable() { + let fetch = MockFetch::default(); + F::program_absent(&fetch); + assert!( + matches!( + F::status(&fetch, &F::receipt()), + Err(VenueFault::Unavailable(_)), + ), + "an absent status read must stay retryable", + ); +} + +/// Instantiate the [`VenueReconcile`](videre_sdk::VenueReconcile) +/// compliance suite for a [`ReconcileFixture`]: re-POST idempotency on +/// both auth paths, a held body never faulting, and an absent status read +/// staying retryable. +#[macro_export] +macro_rules! venue_reconcile_compliance { + ($fixture:ty) => { + #[test] + fn reconcile_signed_re_post_is_idempotent() { + $crate::reconcile::assert_re_post_idempotent::<$fixture>( + &<$fixture as $crate::reconcile::ReconcileFixture>::signed_body(), + ); + } + + #[test] + fn reconcile_presign_re_post_is_idempotent() { + $crate::reconcile::assert_re_post_idempotent::<$fixture>( + &<$fixture as $crate::reconcile::ReconcileFixture>::presign_body(), + ); + } + + #[test] + fn reconcile_held_body_never_faults() { + $crate::reconcile::assert_held_never_faults::<$fixture>(); + } + + #[test] + fn reconcile_status_absent_is_retryable() { + $crate::reconcile::assert_status_absent_is_retryable::<$fixture>(); + } + }; +} diff --git a/videre/crates/videre-test/src/reference.rs b/videre/crates/videre-test/src/reference.rs new file mode 100644 index 00000000..20ccb8f0 --- /dev/null +++ b/videre/crates/videre-test/src/reference.rs @@ -0,0 +1,267 @@ +//! The kit's reference venue: a published body schema, its codec vector +//! file, and its header golden file. +//! +//! The payloads exercise every borsh primitive a body schema is likely to +//! carry, so a non-Rust author can prove their implementation byte-exact +//! against [`CODEC_VECTORS_JSON`]. The published files are pinned by this +//! crate's tests: regeneration must reproduce them byte for byte. + +use borsh::{BorshDeserialize, BorshSerialize}; +use videre_sdk::value_flow::{Asset, AssetAmount, Erc20}; +use videre_sdk::{AuthScheme, IntentBody, IntentHeader, Settlement, VenueError}; + +/// The published codec vector file, verbatim. +pub const CODEC_VECTORS_JSON: &str = include_str!("../vectors/reference-body.json"); + +/// The published header golden file, verbatim. +pub const HEADER_GOLDENS_JSON: &str = include_str!("../goldens/reference-header.json"); + +/// First published version: a fixed-price quote. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, Eq, PartialEq)] +pub struct ReferenceV1 { + /// Amount in wei; borsh encodes it as 8 little-endian bytes. + pub amount_wei: u64, + /// Free text; borsh encodes a u32 little-endian byte length then + /// the UTF-8 bytes. + pub memo: String, +} + +/// Second published version: v1 plus an expiry, a recipient, and a +/// priority flag. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, Eq, PartialEq)] +pub struct ReferenceV2 { + /// Amount in wei. + pub amount_wei: u64, + /// Free text. + pub memo: String, + /// Expiry in ms since the Unix epoch, UTC; borsh encodes a one-byte + /// presence tag (0 absent, 1 present) then the payload. + pub valid_until_ms: Option, + /// 20-byte recipient address; borsh encodes a u32 little-endian + /// element count then the bytes. + pub recipient: Vec, + /// Priority flag; borsh encodes one byte (0 false, 1 true). + pub urgent: bool, +} + +/// The outer version enum; tag order is the schema, so append, never reorder. +#[derive(IntentBody, Clone, Debug, Eq, PartialEq)] +pub enum ReferenceBody { + /// Version 1, wire tag 0. + V1(ReferenceV1), + /// Version 2, wire tag 1. + V2(ReferenceV2), +} + +/// The reference header derivation the goldens pin: gives the amount as +/// native token, wants (v2) the same as an ERC-20 at the recipient +/// address, authorises EIP-712. V1 wants nothing, a zero native amount. +pub fn derive_reference_header(body: Vec) -> Result { + let (amount_wei, wants) = match ReferenceBody::from_bytes(&body)? { + ReferenceBody::V1(quote) => ( + quote.amount_wei, + AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + ), + ReferenceBody::V2(quote) => ( + quote.amount_wei, + AssetAmount { + asset: Asset::Erc20(Erc20 { + token: quote.recipient, + }), + amount: minimal_be(quote.amount_wei), + }, + ), + }; + Ok(IntentHeader { + gives: AssetAmount { + asset: Asset::Native, + amount: minimal_be(amount_wei), + }, + wants, + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip712, + }) +} + +/// Big-endian bytes, leading zeros trimmed; an empty list is zero. +fn minimal_be(value: u64) -> Vec { + let bytes = value.to_be_bytes(); + let first = bytes.iter().position(|byte| *byte != 0); + first.map_or(Vec::new(), |index| bytes[index..].to_vec()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use crate::codec::{CodecVectors, Expectation}; + use crate::header::HeaderGoldens; + + use super::*; + + fn v1_small() -> ReferenceBody { + ReferenceBody::V1(ReferenceV1 { + amount_wei: 1, + memo: "gm".to_owned(), + }) + } + + fn v2_full() -> ReferenceBody { + ReferenceBody::V2(ReferenceV2 { + amount_wei: 1_000_000, + memo: "two coffees".to_owned(), + valid_until_ms: Some(1_700_000_000_000), + recipient: (1..=20).collect(), + urgent: true, + }) + } + + /// Rebuild the published codec vectors from the reference schema. + fn build_codec_vectors() -> CodecVectors { + let mut vectors = CodecVectors::new("videre-test/reference-body"); + + vectors + .push_round_trip("v1-small", &v1_small()) + .unwrap() + .notes = Some( + "tag 0x00, amount_wei 1 as 8 little-endian bytes, memo as u32 \ + little-endian length then utf-8 bytes" + .to_owned(), + ); + vectors + .push_round_trip( + "v1-zero-and-empty", + &ReferenceBody::V1(ReferenceV1 { + amount_wei: 0, + memo: String::new(), + }), + ) + .unwrap() + .notes = Some("zero integer and zero-length string".to_owned()); + vectors + .push_round_trip( + "v1-max-amount", + &ReferenceBody::V1(ReferenceV1 { + amount_wei: u64::MAX, + memo: "max".to_owned(), + }), + ) + .unwrap() + .notes = Some("endianness proof: u64::MAX is eight 0xff bytes".to_owned()); + vectors + .push_round_trip("v2-full", &v2_full()) + .unwrap() + .notes = Some( + "tag 0x01; option present is 0x01 then the payload, vec is u32 \ + little-endian element count then bytes, bool true is 0x01" + .to_owned(), + ); + vectors + .push_round_trip( + "v2-no-expiry", + &ReferenceBody::V2(ReferenceV2 { + amount_wei: 5, + memo: "later".to_owned(), + valid_until_ms: None, + recipient: vec![0xAA; 20], + urgent: false, + }), + ) + .unwrap() + .notes = Some("option absent is a bare 0x00, bool false is 0x00".to_owned()); + + vectors + .push_failure("empty-body", Vec::new(), Expectation::Empty) + .notes = Some("no version tag at all".to_owned()); + let mut unknown = v1_small().to_bytes().unwrap(); + unknown[0] = 9; + vectors + .push_failure( + "unknown-version", + unknown, + Expectation::UnknownVersion { version: 9 }, + ) + .notes = Some("tag 0x09 names no published version".to_owned()); + let mut truncated = v2_full().to_bytes().unwrap(); + truncated.truncate(truncated.len() - 1); + vectors + .push_failure( + "truncated-payload", + truncated, + Expectation::Malformed { version: 1 }, + ) + .notes = Some("known tag, payload cut one byte short".to_owned()); + let mut trailing = v1_small().to_bytes().unwrap(); + trailing.push(0); + vectors + .push_failure( + "trailing-bytes", + trailing, + Expectation::Malformed { version: 0 }, + ) + .notes = Some("decoding must consume the payload exactly".to_owned()); + + vectors + } + + /// Rebuild the published header goldens from the reference + /// derivation. + fn build_header_goldens() -> HeaderGoldens { + let mut goldens = HeaderGoldens::new("videre-test/reference"); + goldens + .record( + "v1-small", + v1_small().to_bytes().unwrap(), + derive_reference_header, + ) + .unwrap() + .notes = Some("gives chain-1 native token, minimal big-endian amount".to_owned()); + goldens + .record( + "v2-full", + v2_full().to_bytes().unwrap(), + derive_reference_header, + ) + .unwrap() + .notes = Some("v2 adds an erc20 want at the recipient token address".to_owned()); + goldens + } + + #[test] + fn published_codec_vectors_match_regeneration() { + assert_eq!( + CODEC_VECTORS_JSON, + build_codec_vectors().to_json(), + "vectors/reference-body.json has drifted; run the ignored \ + regenerate_reference_fixtures test and commit the result", + ); + } + + #[test] + fn published_header_goldens_match_regeneration() { + assert_eq!( + HEADER_GOLDENS_JSON, + build_header_goldens().to_json(), + "goldens/reference-header.json has drifted; run the ignored \ + regenerate_reference_fixtures test and commit the result", + ); + } + + /// Rewrite the published files from the reference schema. Run with + /// `cargo test -p videre-test -- --ignored regenerate` after a + /// deliberate schema change, then commit the diff. + #[test] + #[ignore = "writes the published fixture files in place"] + fn regenerate_reference_fixtures() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + build_codec_vectors() + .write(root.join("vectors/reference-body.json")) + .unwrap(); + build_header_goldens() + .write(root.join("goldens/reference-header.json")) + .unwrap(); + } +} diff --git a/videre/crates/videre-test/src/report.rs b/videre/crates/videre-test/src/report.rs new file mode 100644 index 00000000..c40ea198 --- /dev/null +++ b/videre/crates/videre-test/src/report.rs @@ -0,0 +1,49 @@ +//! The conformance verdict: every check in this crate either passes or +//! returns a [`ConformanceReport`] naming each vector that failed. + +use std::error::Error; +use std::fmt; + +/// One vector or golden the subject failed, with the detail to fix it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Violation { + /// The `name` of the failing vector or golden. + pub vector: String, + /// What diverged: the expected and observed outcome. + pub detail: String, +} + +impl fmt::Display for Violation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.vector, self.detail) + } +} + +/// Every violation a conformance check found, one per failing vector; +/// checks never stop at the first. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConformanceReport { + /// The violations, in vector order. + pub violations: Vec, +} + +impl fmt::Display for ConformanceReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{} conformance violation(s):", self.violations.len())?; + for violation in &self.violations { + writeln!(f, " {violation}")?; + } + Ok(()) + } +} + +impl Error for ConformanceReport {} + +/// Fold collected violations into the check's verdict. +pub(crate) fn settle(violations: Vec) -> Result<(), ConformanceReport> { + if violations.is_empty() { + Ok(()) + } else { + Err(ConformanceReport { violations }) + } +} diff --git a/videre/crates/videre-test/src/transport.rs b/videre/crates/videre-test/src/transport.rs new file mode 100644 index 00000000..56902e7b --- /dev/null +++ b/videre/crates/videre-test/src/transport.rs @@ -0,0 +1,336 @@ +//! In-memory mocks for the three transports a venue adapter is granted: +//! chain RPC, messaging, and outbound HTTP. +//! +//! [`MockTransport`] composes them behind the SDK seams ([`ChainHost`], +//! [`MessagingHost`], [`Fetch`]). [`MockMessaging::scope_topics`] plays +//! the `messaging_topics` grant and [`MockFetch::scope_hosts`] the +//! `[capabilities.http].allow` list; both refuse off-grant calls as a +//! typed `denied`, as the host would. + +use std::cell::RefCell; +use std::collections::HashMap; + +use nexum_sdk::host::{ChainError, ChainHost, Fault}; +use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; +pub use nexum_sdk_test::{ChainCall, MockChain, MockMessaging, PublishRecord}; +pub use videre_sdk::transport::{Message, MessagingHost}; + +/// Composed in-memory transport; each field is the per-seam mock. +#[derive(Default)] +pub struct MockTransport { + /// `nexum:host/chain` mock. + pub chain: MockChain, + /// `nexum:host/messaging` mock. + pub messaging: MockMessaging, + /// Outbound wasi:http mock. + pub http: MockFetch, +} + +impl MockTransport { + /// Fresh empty transport. + pub fn new() -> Self { + Self::default() + } +} + +impl ChainHost for MockTransport { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { + self.chain.request(chain_id, method, params) + } +} + +impl MessagingHost for MockTransport { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + self.messaging.publish(content_topic, payload) + } + + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + self.messaging + .query(content_topic, start_time, end_time, limit) + } +} + +impl Fetch for MockTransport { + fn fetch_with( + &self, + request: http::Request>, + options: FetchOptions, + ) -> Result>, FetchError> { + self.http.fetch_with(request, options) + } +} + +/// One recorded [`Fetch::fetch_with`] invocation. +#[derive(Clone, Debug)] +pub struct RecordedRequest { + /// HTTP method. + pub method: http::Method, + /// Full request URI, verbatim. + pub uri: String, + /// Request body bytes. + pub body: Vec, + /// The per-phase timeouts the caller applied. + pub options: FetchOptions, +} + +/// A programmed response, rebuilt per call since `http::Response` is not `Clone`. +#[derive(Clone, Debug)] +struct StoredResponse { + status: http::StatusCode, + body: Vec, +} + +/// In-memory [`Fetch`] over a `(method, uri)` response map; records every +/// request. An optional host scope plays the `[capabilities.http].allow` +/// grant ([`scope_hosts`](Self::scope_hosts)). +#[derive(Default)] +pub struct MockFetch { + responses: RefCell>>, + requests: RefCell>, + scope: RefCell>>, +} + +impl MockFetch { + /// Confine the mock to `hosts`, mirroring the `[capabilities.http].allow` + /// grant: case-insensitive, an entry is an exact hostname or `*.suffix` + /// wildcard, off-grant fails [`FetchError::Denied`]. An empty grant + /// denies every host. + pub fn scope_hosts(&self, hosts: impl IntoIterator>) { + *self.scope.borrow_mut() = Some(hosts.into_iter().map(Into::into).collect()); + } + + /// Program the response for `(method, uri)`; overwrites any prior entry. + /// + /// # Panics + /// + /// On a `status` outside the valid HTTP range. + pub fn respond_to( + &self, + method: http::Method, + uri: impl Into, + status: u16, + body: impl Into>, + ) { + let status = + http::StatusCode::from_u16(status).expect("MockFetch: status must be a valid code"); + self.responses.borrow_mut().insert( + (method, uri.into()), + Ok(StoredResponse { + status, + body: body.into(), + }), + ); + } + + /// Program a failure for `(method, uri)`; overwrites any prior entry. + pub fn fail_with(&self, method: http::Method, uri: impl Into, error: FetchError) { + self.responses + .borrow_mut() + .insert((method, uri.into()), Err(error)); + } + + /// All requests received, in arrival order. + pub fn requests(&self) -> Vec { + self.requests.borrow().clone() + } + + /// Last request received, if any. + pub fn last_request(&self) -> Option { + self.requests.borrow().last().cloned() + } + + /// Total request count. + pub fn request_count(&self) -> usize { + self.requests.borrow().len() + } +} + +impl Fetch for MockFetch { + fn fetch_with( + &self, + request: http::Request>, + options: FetchOptions, + ) -> Result>, FetchError> { + let method = request.method().clone(); + let uri = request.uri().to_string(); + self.requests.borrow_mut().push(RecordedRequest { + method: method.clone(), + uri: uri.clone(), + body: request.body().clone(), + options, + }); + if let Some(scope) = self.scope.borrow().as_ref() + && !request + .uri() + .host() + .is_some_and(|host| host_allowed(host, scope)) + { + return Err(FetchError::Denied); + } + match self.responses.borrow().get(&(method.clone(), uri.clone())) { + Some(Ok(stored)) => Ok(http::Response::builder() + .status(stored.status) + .body(stored.body.clone()) + .expect("a stored response always rebuilds")), + Some(Err(err)) => Err(err.clone()), + None => Err(FetchError::Transport(format!( + "MockFetch: no response configured for {method} {uri}" + ))), + } + } +} + +/// Grant matching: case-insensitive, an entry admits its exact hostname +/// or, as `*.suffix`, any strict subdomain. +fn host_allowed(host: &str, allowlist: &[String]) -> bool { + let host = host.to_ascii_lowercase(); + allowlist.iter().any(|pat| { + let pat = pat.to_ascii_lowercase(); + if let Some(suffix) = pat.strip_prefix("*.") { + host.ends_with(&format!(".{suffix}")) + } else { + host == pat + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fetch_returns_programmed_response_and_records_the_request() { + let fetch = MockFetch::default(); + fetch.respond_to( + http::Method::GET, + "https://venue.example/api/v1/quote", + 200, + br#"{"price":"1"}"#.to_vec(), + ); + + let request = http::Request::builder() + .method(http::Method::GET) + .uri("https://venue.example/api/v1/quote") + .body(Vec::new()) + .unwrap(); + let response = fetch.fetch(request).unwrap(); + assert_eq!(response.status(), http::StatusCode::OK); + assert_eq!(response.body(), br#"{"price":"1"}"#); + + assert_eq!(fetch.request_count(), 1); + let recorded = fetch.last_request().unwrap(); + assert_eq!(recorded.method, http::Method::GET); + assert_eq!(recorded.uri, "https://venue.example/api/v1/quote"); + assert_eq!(recorded.options, FetchOptions::default()); + } + + #[test] + fn fetch_unconfigured_and_programmed_failures() { + let fetch = MockFetch::default(); + fetch.fail_with( + http::Method::POST, + "https://venue.example/api/v1/orders", + FetchError::Denied, + ); + + let denied = http::Request::builder() + .method(http::Method::POST) + .uri("https://venue.example/api/v1/orders") + .body(b"order".to_vec()) + .unwrap(); + assert_eq!(fetch.fetch(denied).unwrap_err(), FetchError::Denied); + + let stray = http::Request::builder() + .uri("https://nowhere.example/") + .body(Vec::new()) + .unwrap(); + let err = fetch.fetch(stray).unwrap_err(); + assert!(matches!(err, FetchError::Transport(msg) if msg.contains("MockFetch"))); + // Refused and unconfigured requests are still recorded. + assert_eq!(fetch.request_count(), 2); + } + + #[test] + fn fetch_scope_matches_the_host_grant() { + let fetch = MockFetch::default(); + fetch.scope_hosts(["api.acme.example", "*.discord.com"]); + fetch.respond_to(http::Method::GET, "https://api.acme.example/v1", 200, "ok"); + fetch.respond_to(http::Method::GET, "https://API.ACME.EXAMPLE/v1", 200, "ok"); + fetch.respond_to(http::Method::GET, "https://a.b.discord.com/", 200, "ok"); + + // Exact entry, case-insensitively; a wildcard admits strict + // subdomains only. + let get = |uri: &str| { + fetch.fetch( + http::Request::builder() + .uri(uri) + .body(Vec::new()) + .expect("test request builds"), + ) + }; + assert!(get("https://api.acme.example/v1").is_ok()); + assert!(get("https://API.ACME.EXAMPLE/v1").is_ok()); + assert!(get("https://a.b.discord.com/").is_ok()); + assert_eq!( + get("https://evil.api.acme.example/").unwrap_err(), + FetchError::Denied, + ); + assert_eq!(get("https://discord.com/").unwrap_err(), FetchError::Denied); + + // Refused requests are still recorded. + assert_eq!(fetch.request_count(), 5); + + // An empty grant denies every host, the host's posture for an + // absent allow list. + let sealed = MockFetch::default(); + sealed.scope_hosts(Vec::::new()); + sealed.respond_to(http::Method::GET, "https://anywhere.example/", 200, ""); + let denied = sealed.fetch( + http::Request::builder() + .uri("https://anywhere.example/") + .body(Vec::new()) + .expect("test request builds"), + ); + assert_eq!(denied.unwrap_err(), FetchError::Denied); + } + + #[test] + fn transport_dispatches_through_every_seam() { + let transport = MockTransport::new(); + transport + .chain + .respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".to_owned())); + transport.messaging.seed_payload("/t", b"m".to_vec(), 1); + transport + .http + .respond_to(http::Method::GET, "https://venue.example/", 204, Vec::new()); + + // Through the seams an adapter's logic is written against. + let chain: &dyn ChainHost = &transport; + assert_eq!( + chain.request(1, "eth_blockNumber", "[]").unwrap(), + "\"0x1\"" + ); + + let messaging: &dyn MessagingHost = &transport; + messaging.publish("/t", b"out").unwrap(); + assert_eq!(messaging.query("/t", None, None, None).unwrap().len(), 1); + + let request = http::Request::builder() + .uri("https://venue.example/") + .body(Vec::new()) + .unwrap(); + let response = transport.fetch(request).unwrap(); + assert_eq!(response.status(), http::StatusCode::NO_CONTENT); + + assert_eq!(transport.chain.call_count(), 1); + assert_eq!(transport.messaging.publish_count(), 1); + assert_eq!(transport.http.request_count(), 1); + } +} diff --git a/videre/crates/videre-test/tests/conformance.rs b/videre/crates/videre-test/tests/conformance.rs new file mode 100644 index 00000000..c2bac25a --- /dev/null +++ b/videre/crates/videre-test/tests/conformance.rs @@ -0,0 +1,138 @@ +//! Acceptance surface for the conformance kit: a reference adapter is held +//! to the published vector and golden files, and a divergent one is caught. + +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{ + AuthScheme, Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, + VenueError, +}; +use videre_test::reference::{ + CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, +}; +use videre_test::{CodecVectors, HeaderGoldens, MessagingHost, MockTransport}; + +/// The reference venue implemented through the SDK trait, driven by the kit's mocks. +struct ReferenceAdapter; + +impl VenueAdapter for ReferenceAdapter { + fn init(_config: Config) -> Result<(), Fault> { + Ok(()) + } + + fn derive_header(body: Vec) -> Result { + derive_reference_header(body) + } + + fn quote(body: Vec) -> Result { + let header = derive_reference_header(body)?; + Ok(Quotation { + gives: header.gives, + wants: header.wants, + fee: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + valid_until_ms: u64::MAX, + }) + } + + fn submit(body: Vec) -> Result { + Ok(SubmitOutcome::Accepted(body)) + } + + fn status(_receipt: Vec) -> Result { + Ok(IntentStatus::Open) + } + + fn cancel(_receipt: Vec) -> Result<(), VenueError> { + Ok(()) + } +} + +#[test] +fn adapter_codec_conforms_to_the_published_vectors() { + CodecVectors::from_json(CODEC_VECTORS_JSON) + .expect("the published vector file parses") + .assert_conforms::(); +} + +#[test] +fn adapter_derive_header_conforms_to_the_published_goldens() { + HeaderGoldens::from_json(HEADER_GOLDENS_JSON) + .expect("the published golden file parses") + .assert_conforms(ReferenceAdapter::derive_header); +} + +#[test] +fn divergent_derivation_is_caught_by_the_published_goldens() { + // The classic byte-order bug: little-endian amounts. + let derive = |body: Vec| -> Result { + let mut header = derive_reference_header(body)?; + header.gives.amount.reverse(); + Ok(header) + }; + let report = HeaderGoldens::from_json(HEADER_GOLDENS_JSON) + .unwrap() + .check(derive) + .unwrap_err(); + assert!(!report.violations.is_empty()); + assert!(report.violations[0].detail.contains("diverges")); +} + +#[test] +fn mock_transport_drives_seam_shaped_adapter_logic() { + // A slice of adapter logic written against the seams: announce a + // submission over messaging, confirm via the venue's HTTP API. + fn announce(messaging: &M, receipt: &[u8]) -> Result<(), VenueError> { + messaging + .publish("/reference/1/receipts/proto", receipt) + .map_err(VenueError::from) + } + + let transport = MockTransport::new(); + transport + .messaging + .scope_topics(["/reference/1/receipts/proto"]); + + let SubmitOutcome::Accepted(receipt) = ReferenceAdapter::submit(vec![1, 2, 3]).unwrap() else { + panic!("the reference venue accepts directly"); + }; + announce(&transport, &receipt).unwrap(); + assert_eq!( + transport.messaging.last_published().unwrap().payload, + receipt, + ); + + // An off-scope topic surfaces as the typed policy refusal. + let denied = transport + .messaging + .publish("/elsewhere", &receipt) + .map_err(VenueError::from) + .unwrap_err(); + assert!(matches!(denied, VenueError::Denied(_))); +} + +#[test] +fn published_files_document_the_wire_format_in_hex() { + // Non-Rust authors consume the files directly: every byte field is + // lowercase hex, and the first round-trip vector carries prose. + let vectors = CodecVectors::from_json(CODEC_VECTORS_JSON).unwrap(); + assert!(vectors.vectors.iter().any(|vector| vector.notes.is_some())); + + let goldens = HeaderGoldens::from_json(HEADER_GOLDENS_JSON).unwrap(); + let golden = &goldens.goldens[0]; + // The golden's body is a codec vector's bytes: the two files pin + // the same wire form from both sides. + assert!( + vectors + .vectors + .iter() + .any(|vector| vector.bytes == golden.body), + "header goldens reuse published codec bodies", + ); + // And the expected header speaks the value-flow vocabulary. + let derived = derive_reference_header(golden.body.clone()).unwrap(); + assert_eq!(derived.gives.asset, Asset::Native); + assert_eq!(derived.authorisation, AuthScheme::Eip712); + let _: &AssetAmount = &derived.gives; +} diff --git a/videre/crates/videre-test/vectors/reference-body.json b/videre/crates/videre-test/vectors/reference-body.json new file mode 100644 index 00000000..4bc46024 --- /dev/null +++ b/videre/crates/videre-test/vectors/reference-body.json @@ -0,0 +1,72 @@ +{ + "version": 1, + "schema": "videre-test/reference-body", + "vectors": [ + { + "name": "v1-small", + "bytes": "00010000000000000002000000676d", + "expect": "round-trip", + "notes": "tag 0x00, amount_wei 1 as 8 little-endian bytes, memo as u32 little-endian length then utf-8 bytes" + }, + { + "name": "v1-zero-and-empty", + "bytes": "00000000000000000000000000", + "expect": "round-trip", + "notes": "zero integer and zero-length string" + }, + { + "name": "v1-max-amount", + "bytes": "00ffffffffffffffff030000006d6178", + "expect": "round-trip", + "notes": "endianness proof: u64::MAX is eight 0xff bytes" + }, + { + "name": "v2-full", + "bytes": "0140420f00000000000b00000074776f20636f6666656573010068e5cf8b010000140000000102030405060708090a0b0c0d0e0f101112131401", + "expect": "round-trip", + "notes": "tag 0x01; option present is 0x01 then the payload, vec is u32 little-endian element count then bytes, bool true is 0x01" + }, + { + "name": "v2-no-expiry", + "bytes": "010500000000000000050000006c617465720014000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa00", + "expect": "round-trip", + "notes": "option absent is a bare 0x00, bool false is 0x00" + }, + { + "name": "empty-body", + "bytes": "", + "expect": "empty", + "notes": "no version tag at all" + }, + { + "name": "unknown-version", + "bytes": "09010000000000000002000000676d", + "expect": { + "unknown-version": { + "version": 9 + } + }, + "notes": "tag 0x09 names no published version" + }, + { + "name": "truncated-payload", + "bytes": "0140420f00000000000b00000074776f20636f6666656573010068e5cf8b010000140000000102030405060708090a0b0c0d0e0f1011121314", + "expect": { + "malformed": { + "version": 1 + } + }, + "notes": "known tag, payload cut one byte short" + }, + { + "name": "trailing-bytes", + "bytes": "00010000000000000002000000676d00", + "expect": { + "malformed": { + "version": 0 + } + }, + "notes": "decoding must consume the payload exactly" + } + ] +} diff --git a/videre/modules/examples/echo-client/Cargo.toml b/videre/modules/examples/echo-client/Cargo.toml new file mode 100644 index 00000000..c2288cc6 --- /dev/null +++ b/videre/modules/examples/echo-client/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "echo-client" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Shepherd example module paired with the echo-venue adapter: submits an opaque body through videre:venue/client on every block and logs the intent-status transitions the registry fans back." + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +nexum-sdk = { path = "../../../../nexum/crates/nexum-sdk" } +# The venue status-body codec the `on_custom` handler decodes an +# intent-status transition through, reached via `videre_sdk::status_body`. +videre-sdk = { path = "../../../crates/videre-sdk" } +wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } diff --git a/videre/modules/examples/echo-client/module.toml b/videre/modules/examples/echo-client/module.toml new file mode 100644 index 00000000..9dee6673 --- /dev/null +++ b/videre/modules/examples/echo-client/module.toml @@ -0,0 +1,38 @@ +# echo-client module manifest - the keeper half of the echo pair. It +# submits through videre:venue/client and observes intent-status, so it +# declares the `client` capability alongside `logging`; the per-module world +# the macro derives imports exactly videre:venue/client and +# nexum:host/logging. + +[module] +name = "echo-client" +version = "0.1.0" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +# `client` grants the videre:venue/client import; `logging` the log sink. +required = ["client", "logging"] +optional = [] + +[capabilities.http] +allow = [] + +# Submit on every chain-1 block. +[[subscription]] +kind = "block" +chain_id = 1 + +# Observe the status transitions the registry polls from the echo-venue adapter. +[[subscription]] +kind = "intent-status" +venue = "echo-venue" + +[config] +name = "echo-client" + +# The one body-schema version this keeper encodes; install refuses the +# keeper unless every installed adapter's [venue] body_versions +# contains it. +[venue] +body_version = 1 diff --git a/videre/modules/examples/echo-client/src/lib.rs b/videre/modules/examples/echo-client/src/lib.rs new file mode 100644 index 00000000..e4e13fc6 --- /dev/null +++ b/videre/modules/examples/echo-client/src/lib.rs @@ -0,0 +1,85 @@ +//! # echo-client (reference Shepherd intent module) +//! +//! The keeper half of the echo pair. On every chain-1 block it quotes +//! and submits an opaque body through the raw `videre:venue/client` +//! import to the `echo-venue` adapter, logs the receipt, and logs each +//! `intent-status` transition the registry fans back. The smallest +//! demonstration of the intent core: module -> registry -> adapter and +//! the status event back. + +// wit_bindgen::generate! expands to host-import shims whose arity matches +// the WIT signatures, which can exceed clippy's too-many-arguments threshold. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +use nexum::host::{logging, types}; +use videre::types::types::SubmitOutcome; +use videre::venue::client; + +/// Venue id the paired echo-venue adapter answers for. +const ECHO_VENUE: &str = "echo-venue"; + +struct EchoClient; + +#[nexum_sdk::module] +impl EchoClient { + fn on_block(block: types::Block) -> Result<(), Fault> { + // The echo venue accepts any bytes and hands them back as the + // receipt, so the body content is immaterial; the block number keeps + // it non-empty and legible in the logs. + let body = block.number.to_be_bytes().to_vec(); + match client::quote(ECHO_VENUE, &body) { + Ok(quotation) => logging::log( + logging::Level::Info, + &format!( + "quoted {} bytes at {ECHO_VENUE}: gives {} amount bytes", + body.len(), + quotation.gives.amount.len(), + ), + ), + Err(_) => logging::log( + logging::Level::Warn, + &format!("quote at {ECHO_VENUE} was refused"), + ), + } + match client::submit(ECHO_VENUE, &body) { + Ok(SubmitOutcome::Accepted(receipt)) => logging::log( + logging::Level::Info, + &format!( + "submitted {} bytes to {ECHO_VENUE}, receipt {} bytes", + body.len(), + receipt.len(), + ), + ), + Ok(SubmitOutcome::RequiresSigning(_)) => logging::log( + logging::Level::Warn, + &format!("{ECHO_VENUE} unexpectedly asked for a signature"), + ), + Err(_) => logging::log( + logging::Level::Warn, + &format!("submit to {ECHO_VENUE} was refused"), + ), + } + Ok(()) + } + + fn on_custom(event: types::CustomEvent) -> Result<(), Fault> { + if event.kind != videre_sdk::status_body::INTENT_STATUS_KIND { + return Ok(()); + } + let update = videre_sdk::status_body::IntentStatusUpdate::decode(&event.payload) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; + let body = videre_sdk::status_body::StatusBody::decode(&update.status) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; + logging::log( + logging::Level::Info, + &format!( + "intent status from venue {}: {:?} ({} receipt bytes)", + update.venue, + body.status, + update.receipt.len(), + ), + ); + Ok(()) + } +} diff --git a/videre/modules/examples/echo-keeper/Cargo.toml b/videre/modules/examples/echo-keeper/Cargo.toml new file mode 100644 index 00000000..de13587b --- /dev/null +++ b/videre/modules/examples/echo-keeper/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "echo-keeper" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Shepherd example keeper paired with the echo-venue adapter: drives it through the typed VenueClient emitted by #[videre_sdk::keeper] - quote, submit, status, cancel - and logs the intent-status transitions the registry fans back." + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +nexum-sdk = { path = "../../../../nexum/crates/nexum-sdk" } +videre-sdk = { path = "../../../crates/videre-sdk" } +wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/videre/modules/examples/echo-keeper/module.toml b/videre/modules/examples/echo-keeper/module.toml new file mode 100644 index 00000000..0620286c --- /dev/null +++ b/videre/modules/examples/echo-keeper/module.toml @@ -0,0 +1,40 @@ +# echo-keeper module manifest - the blessed keeper half of the echo +# pair. It drives the echo-venue adapter through the typed client, so it +# declares the `client` capability alongside `logging`; the per-module +# world the macro derives imports exactly videre:venue/client and +# nexum:host/logging. + +[module] +name = "echo-keeper" +version = "0.1.0" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +# `client` grants the videre:venue/client import (required by +# #[videre_sdk::keeper]); `logging` the log sink. +required = ["client", "logging"] +optional = [] + +[capabilities.http] +allow = [] + +# Drive the venue on every chain-1 block. +[[subscription]] +kind = "block" +chain_id = 1 + +# Observe the status transitions the registry polls from the echo-venue +# adapter. +[[subscription]] +kind = "intent-status" +venue = "echo-venue" + +[config] +name = "echo-keeper" + +# The one body-schema version this keeper encodes; install refuses the +# keeper unless every installed adapter's [venue] body_versions +# contains it. +[venue] +body_version = 1 diff --git a/videre/modules/examples/echo-keeper/src/lib.rs b/videre/modules/examples/echo-keeper/src/lib.rs new file mode 100644 index 00000000..3978e5cd --- /dev/null +++ b/videre/modules/examples/echo-keeper/src/lib.rs @@ -0,0 +1,100 @@ +//! # echo-keeper (reference videre keeper module) +//! +//! The keeper half of the echo pair. On every chain-1 block it drives +//! the echo-venue adapter through the typed `VenueClient` +//! (quote, submit, status, cancel, all with a typed body) and logs each +//! `intent-status` transition the registry fans back. The +//! `#[videre_sdk::keeper]` counterpart to echo-client: the macro wires +//! the world and client import, so the author never sees wire bytes. + +// wit_bindgen::generate! expands to host-import shims whose arity matches +// the WIT signatures, which can exceed clippy's too-many-arguments threshold. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +use nexum::host::{logging, types}; +use videre_sdk::{SubmitOutcome, Venue, VenueClient, VenueId}; + +/// The echo venue as this keeper types it: the adapter's id and the +/// body schema below. +struct EchoVenue; + +impl Venue for EchoVenue { + const ID: VenueId = VenueId::from_static("echo-venue"); + type Body = EchoBody; +} + +/// The keeper's published body schema; v1 is just the block number. +#[derive(videre_sdk::IntentBody)] +enum EchoBody { + V1(u64), +} + +struct EchoKeeper; + +#[videre_sdk::keeper] +impl EchoKeeper { + async fn on_block(block: types::Block) -> Result<(), Fault> { + let venue = VenueClient::::new(); + let body = EchoBody::V1(block.number); + + // Quote-then-submit through the typestate: the venue prices + // exactly the bytes it is later handed. ClientError folds into + // the wire fault, so `?` applies throughout. + let quoted = venue.quote(&body).await?; + logging::log( + logging::Level::Info, + &format!( + "quoted at {}: gives {} amount bytes", + EchoVenue::ID, + quoted.quotation().gives.amount.len(), + ), + ); + let receipt = match quoted.submit().await? { + SubmitOutcome::Accepted(receipt) => receipt, + SubmitOutcome::RequiresSigning(_) => { + logging::log( + logging::Level::Warn, + &format!("{} unexpectedly asked for a signature", EchoVenue::ID), + ); + return Ok(()); + } + }; + logging::log( + logging::Level::Info, + &format!( + "submitted to {}: receipt {} bytes", + EchoVenue::ID, + receipt.len(), + ), + ); + + let status = venue.status(&receipt).await?; + logging::log( + logging::Level::Info, + &format!("status at {}: {status:?}", EchoVenue::ID), + ); + + venue.cancel(&receipt).await?; + logging::log( + logging::Level::Info, + &format!("cancelled at {}", EchoVenue::ID), + ); + Ok(()) + } + + fn on_intent_status(update: videre_sdk::IntentStatusUpdate) -> Result<(), Fault> { + let body = videre_sdk::status_body::StatusBody::decode(&update.status) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; + logging::log( + logging::Level::Info, + &format!( + "intent status from venue {}: {:?} ({} receipt bytes)", + update.venue, + body.status, + update.receipt.len(), + ), + ); + Ok(()) + } +} diff --git a/videre/modules/examples/echo-venue/Cargo.toml b/videre/modules/examples/echo-venue/Cargo.toml new file mode 100644 index 00000000..a1d818f1 --- /dev/null +++ b/videre/modules/examples/echo-venue/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "echo-venue" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +videre-sdk = { path = "../../../crates/videre-sdk" } +wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } + +[dev-dependencies] +# The conformance kit: holds this adapter's header derivation to the kit's +# golden mirror types, so echo-venue is both the tutorial artefact and the +# kit's worked test target. +videre-test = { path = "../../../crates/videre-test" } diff --git a/videre/modules/examples/echo-venue/module.toml b/videre/modules/examples/echo-venue/module.toml new file mode 100644 index 00000000..bd17af64 --- /dev/null +++ b/videre/modules/examples/echo-venue/module.toml @@ -0,0 +1,29 @@ +# echo-venue adapter manifest - the reference #[videre_sdk::venue] +# component. Declares a single scoped-transport capability (chain), so the +# per-component world the macro derives imports nexum:host/chain and +# nothing else. + +[module] +name = "echo-venue" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +# A venue adapter may declare only scoped transport (chain, messaging) +# plus the HTTP allowlist. echo-venue reads chain state to derive its +# header and needs nothing else. +required = ["chain"] +optional = [] + +[capabilities.http] +allow = [] + +[config] +name = "echo-venue" + +# Body-schema versions this adapter decodes: the handshake authority. +# Install asserts the adapter's body-versions export equals it. +[venue] +body_versions = [1] diff --git a/videre/modules/examples/echo-venue/src/lib.rs b/videre/modules/examples/echo-venue/src/lib.rs new file mode 100644 index 00000000..085a250c --- /dev/null +++ b/videre/modules/examples/echo-venue/src/lib.rs @@ -0,0 +1,171 @@ +//! # echo-venue (reference Shepherd venue adapter) +//! +//! Minimal reference venue adapter: accepts any body, echoes it back as +//! the receipt, and settles instantly (every receipt reports +//! `fulfilled`). The smallest demonstration of `#[videre_sdk::venue]` +//! and the `videre-test` conformance target (see the tests below). + +// wit_bindgen::generate! expands to host-import shims whose arity matches +// the WIT signatures, which can exceed clippy's too-many-arguments threshold. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +// `Config` and `Fault` come from the macro's world bindgen at the crate +// root: aliases of the SDK types, so the trait impl lines up. +use nexum::host::chain; +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueAdapter, + VenueError, +}; + +struct EchoVenue; + +#[videre_sdk::venue] +impl VenueAdapter for EchoVenue { + fn init(_config: Config) -> Result<(), Fault> { + Ok(()) + } + + fn body_versions() -> Vec { + // Must equal the manifest `[venue] body_versions`; install + // asserts it. + vec![1] + } + + fn derive_header(body: Vec) -> Result { + // The echo venue gives back exactly the bytes handed to it, so the + // header's `gives` amount is the body length: enough to exercise + // the value-flow vocabulary without a real schema. Wants nothing, + // spelled as a zero native amount. + Ok(IntentHeader { + gives: AssetAmount { + asset: Asset::Native, + amount: minimal_be(body.len() as u64), + }, + wants: zero_native(), + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip1271, + }) + } + + fn quote(body: Vec) -> Result { + // Echo pricing mirrors the header: gives the body length, wants + // nothing, charges no fee, and the quote never expires. + Ok(Quotation { + gives: AssetAmount { + asset: Asset::Native, + amount: minimal_be(body.len() as u64), + }, + wants: zero_native(), + fee: zero_native(), + valid_until_ms: u64::MAX, + }) + } + + fn submit(body: Vec) -> Result { + // Reading chain state on submit is what justifies the declared + // `chain` capability; the block height is discarded, the point is + // the scoped transport import the manifest declares. + let _ = chain::request(1, "eth_blockNumber", "[]") + .map_err(|_| VenueError::Unavailable("chain read failed".into()))?; + Ok(SubmitOutcome::Accepted(body)) + } + + fn status(_receipt: Vec) -> Result { + // Settles instantly: the intent reaches a terminal state on the + // first status poll. + Ok(IntentStatus::Fulfilled) + } + + fn cancel(_receipt: Vec) -> Result<(), VenueError> { + Ok(()) + } +} + +/// A zero native amount: the venue's spelling of "nothing". +fn zero_native() -> AssetAmount { + AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + } +} + +/// Big-endian bytes with leading zeros trimmed: the minimal `uint` +/// spelling, where an empty list is zero. +fn minimal_be(value: u64) -> Vec { + let bytes = value.to_be_bytes(); + let first = bytes.iter().position(|byte| *byte != 0); + first.map_or(Vec::new(), |index| bytes[index..].to_vec()) +} + +/// echo-venue as the `videre-test` conformance target: the pure header +/// derivation is held to a hand-written golden. +#[cfg(test)] +mod conformance { + use super::*; + use videre_test::{ + FormatVersion, GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, + GoldenSettlement, HeaderGolden, HeaderGoldens, + }; + + fn zero_native() -> GoldenAssetAmount { + GoldenAssetAmount { + asset: GoldenAsset::Native, + amount: Vec::new(), + } + } + + #[test] + fn derive_header_conforms_to_the_published_golden() { + // The echo contract: gives chain-1 native token whose amount is the + // body length in minimal big-endian bytes, wants zero native, and + // authorises via EIP-1271. A conforming adapter reproduces this + // exactly. + let golden = HeaderGolden { + name: "four-byte-body".to_owned(), + body: vec![1, 2, 3, 4], + header: GoldenHeader { + gives: GoldenAssetAmount { + asset: GoldenAsset::Native, + amount: vec![4], + }, + wants: zero_native(), + settlement: GoldenSettlement { chain: 1 }, + authorisation: GoldenAuthScheme::Eip1271, + }, + notes: Some("amount is the minimal big-endian body length".to_owned()), + }; + let goldens = HeaderGoldens { + version: FormatVersion, + venue: "echo-venue".to_owned(), + goldens: vec![golden], + }; + goldens.assert_conforms(EchoVenue::derive_header); + } + + #[test] + fn divergent_derivation_is_caught_by_the_golden() { + // A non-minimal amount is the classic uint bug; the golden must + // reject it, proving the check has teeth on echo-venue. + let goldens = HeaderGoldens { + version: FormatVersion, + venue: "echo-venue".to_owned(), + goldens: vec![HeaderGolden { + name: "four-byte-body".to_owned(), + body: vec![1, 2, 3, 4], + header: GoldenHeader { + gives: GoldenAssetAmount { + asset: GoldenAsset::Native, + amount: 4u64.to_be_bytes().to_vec(), + }, + wants: zero_native(), + settlement: GoldenSettlement { chain: 1 }, + authorisation: GoldenAuthScheme::Eip1271, + }, + notes: None, + }], + }; + assert!(goldens.check(EchoVenue::derive_header).is_err()); + } +} diff --git a/videre/modules/fixtures/flaky-venue/Cargo.toml b/videre/modules/fixtures/flaky-venue/Cargo.toml new file mode 100644 index 00000000..4987897e --- /dev/null +++ b/videre/modules/fixtures/flaky-venue/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "flaky-venue" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Evil-by-design venue adapter fixture: submit panics while the chain head reads as the poison sentinel and accepts once it stops. Drives the supervisor's provider trap-to-recovery and poison sweeps." + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +videre-sdk = { path = "../../../crates/videre-sdk" } +wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/videre/modules/fixtures/flaky-venue/module.toml b/videre/modules/fixtures/flaky-venue/module.toml new file mode 100644 index 00000000..c7667b9d --- /dev/null +++ b/videre/modules/fixtures/flaky-venue/module.toml @@ -0,0 +1,19 @@ +# flaky-venue adapter manifest - the trap-to-recovery test fixture. +# Declares the chain capability its poison-sentinel read requires. + +[module] +name = "flaky-venue" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["chain"] +optional = [] + +[capabilities.http] +allow = [] + +[config] +name = "flaky-venue" diff --git a/videre/modules/fixtures/flaky-venue/src/lib.rs b/videre/modules/fixtures/flaky-venue/src/lib.rs new file mode 100644 index 00000000..dee87818 --- /dev/null +++ b/videre/modules/fixtures/flaky-venue/src/lib.rs @@ -0,0 +1,75 @@ +//! # flaky-venue (test fixture) +//! +//! Venue adapter whose `submit` panics (traps the store) while the chain +//! head reads the poison sentinel `0xdead`, and accepts once it moves +//! on. The test drives recovery: the supervisor's sweep must +//! reinstantiate the adapter before a submit succeeds. Test-only. + +// wit_bindgen::generate! expands to host-import shims whose arity matches +// the WIT signatures, which can exceed clippy's too-many-arguments threshold. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +// `Config` and `Fault` come from the macro's world bindgen at the crate +// root: aliases of the SDK types, so the trait impl lines up. +use nexum::host::chain; +use videre_sdk::value_flow::{Asset, AssetAmount}; +use videre_sdk::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueAdapter, + VenueError, +}; + +/// The chain-head response that detonates `submit`. +const POISON_HEAD: &str = "0xdead"; + +struct FlakyVenue; + +#[videre_sdk::venue] +impl VenueAdapter for FlakyVenue { + fn init(_config: Config) -> Result<(), Fault> { + Ok(()) + } + + fn derive_header(_body: Vec) -> Result { + Ok(IntentHeader { + gives: zero_native(), + wants: zero_native(), + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip1271, + }) + } + + fn quote(_body: Vec) -> Result { + Ok(Quotation { + gives: zero_native(), + wants: zero_native(), + fee: zero_native(), + valid_until_ms: u64::MAX, + }) + } + + fn submit(body: Vec) -> Result { + let head = chain::request(1, "eth_blockNumber", "[]") + .map_err(|_| VenueError::Unavailable("chain read failed".into()))?; + // The sentinel detonates the fixture: a guest panic traps the + // store, which is what the sweep under test must recover from. + assert!(!head.contains(POISON_HEAD), "flaky-venue poison head"); + Ok(SubmitOutcome::Accepted(body)) + } + + fn status(_receipt: Vec) -> Result { + Ok(IntentStatus::Open) + } + + fn cancel(_receipt: Vec) -> Result<(), VenueError> { + Ok(()) + } +} + +/// A zero native amount. +fn zero_native() -> AssetAmount { + AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + } +} diff --git a/wit/nexum-host/chain.wit b/wit/nexum-host/chain.wit index 97055e5a..1d812dc6 100644 --- a/wit/nexum-host/chain.wit +++ b/wit/nexum-host/chain.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface chain { use types.{chain-id, fault}; diff --git a/wit/nexum-host/event-module.wit b/wit/nexum-host/event-module.wit index db8d7f5a..277134a9 100644 --- a/wit/nexum-host/event-module.wit +++ b/wit/nexum-host/event-module.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; /// Event-driven module — automation, background processing. /// No UI capabilities. Runs on any conforming host. diff --git a/wit/nexum-host/identity.wit b/wit/nexum-host/identity.wit index 09dac970..2e82c9fe 100644 --- a/wit/nexum-host/identity.wit +++ b/wit/nexum-host/identity.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; /// Identity / signing capability. /// diff --git a/wit/nexum-host/local-store.wit b/wit/nexum-host/local-store.wit index 6c5a22b3..814ef724 100644 --- a/wit/nexum-host/local-store.wit +++ b/wit/nexum-host/local-store.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface local-store { use types.{fault}; @@ -15,4 +15,33 @@ interface local-store { /// List all keys matching a prefix. Empty prefix returns all keys. list-keys: func(prefix: string) -> result, fault>; + + /// Whether the key exists, without transferring the value. + contains: func(key: string) -> result; + + /// Value byte length, none if the key is absent, without + /// transferring the value. On some backends this may be a scan. + len: func(key: string) -> result, fault>; + + /// Number of keys matching a prefix, without materialising the key + /// list. On some backends this may be a scan. + count: func(prefix: string) -> result; + + /// One pair in an apply batch. + record key-value { + key: string, + value: list, + } + + /// One write in an apply batch. + variant write-op { + set(key-value), + delete(string), + } + + /// Apply a batch of writes atomically: every op lands or none + /// does. Later ops on a key supersede earlier ones. Quota is + /// charged on the net whole-batch footprint; the host caps the op + /// count and total value bytes per batch. + apply: func(ops: list) -> result<_, fault>; } diff --git a/wit/nexum-host/logging.wit b/wit/nexum-host/logging.wit index 37e9193f..8cd98140 100644 --- a/wit/nexum-host/logging.wit +++ b/wit/nexum-host/logging.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface logging { enum level { diff --git a/wit/nexum-host/messaging.wit b/wit/nexum-host/messaging.wit index 7f8e4bf6..30777ca7 100644 --- a/wit/nexum-host/messaging.wit +++ b/wit/nexum-host/messaging.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface messaging { use types.{fault, message}; diff --git a/wit/nexum-host/query-module.wit b/wit/nexum-host/query-module.wit index 7dd52600..ffb29b87 100644 --- a/wit/nexum-host/query-module.wit +++ b/wit/nexum-host/query-module.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; /// Query module — synchronous, side-effect-free evaluation. /// diff --git a/wit/nexum-host/remote-store.wit b/wit/nexum-host/remote-store.wit index 731ae370..39b36bae 100644 --- a/wit/nexum-host/remote-store.wit +++ b/wit/nexum-host/remote-store.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface remote-store { use types.{fault}; diff --git a/wit/nexum-host/types.wit b/wit/nexum-host/types.wit index 2cd5c7e2..0142f119 100644 --- a/wit/nexum-host/types.wit +++ b/wit/nexum-host/types.wit @@ -1,4 +1,4 @@ -package nexum:host@0.2.0; +package nexum:host@0.1.0; /// Common types shared across all runtime interfaces. /// @@ -58,11 +58,25 @@ interface types { fired-at: u64, } + /// The generic extension event: a domain extension's own event kind + /// and its opaque payload. The core routes by `kind` and never reads + /// `payload`; the subscribing module decodes it against the extension + /// that emitted it. + record custom-event { + /// Extension-scoped event kind, matched against a module's + /// `[[subscription]]` kind. + kind: string, + /// Opaque bytes the emitting extension defines and the module + /// decodes. + payload: list, + } + variant event { block(block), chain-logs(chain-logs), tick(tick), message(message), + custom(custom-event), } /// Opaque config from module.toml [config] section. diff --git a/wit/shepherd-cow/cow-api.wit b/wit/shepherd-cow/cow-api.wit deleted file mode 100644 index 5c7e379d..00000000 --- a/wit/shepherd-cow/cow-api.wit +++ /dev/null @@ -1,60 +0,0 @@ -package shepherd:cow@0.2.0; - -interface cow-api { - use nexum:host/types@0.2.0.{chain-id, fault}; - - /// A non-2xx reply from the orderbook that carries no typed - /// rejection envelope. `body` is the raw response text, foreign - /// orderbook JSON kept as a string deliberately: the host does not - /// parse it, so a caller matches on `status` (e.g. 404 for a - /// resource the orderbook has not indexed yet) and reads `body` - /// only for diagnostics. - record http-failure { - status: u16, - body: option, - } - - /// A typed orderbook rejection of a submitted order. Parsed once, - /// host-side, from the orderbook's `{errorType, description, data}` - /// envelope so the guest dispatches on `error-type` without a - /// second JSON decode. `data` is the envelope's optional structured - /// payload (e.g. a minimum-fee quote), re-encoded as a JSON string. - record order-rejection { - status: u16, - error-type: string, - description: string, - data: option, - } - - /// A cow-api call failure: a shared host `fault`, a raw HTTP - /// failure, or a typed order rejection. - variant cow-api-error { - fault(fault), - http(http-failure), - rejected(order-rejection), - } - - /// HTTP-style request to the CoW Protocol API. - /// - /// The host routes to the correct CoW API base URL for the given chain - /// (e.g. https://api.cow.fi/mainnet for chain 1). - /// - /// method: "GET" | "POST" | "PUT" | "DELETE" - /// path: relative API path, e.g. "/api/v1/orders" - /// body: optional JSON request body - /// - /// Returns the response body as a JSON string. - request: func( - chain-id: chain-id, - method: string, - path: string, - body: option, - ) -> result; - - /// Submit an order to the CoW Protocol. - /// - /// `order-data`: the serialised order payload. - /// Returns the order UID on success. - submit-order: func(chain-id: chain-id, order-data: list) - -> result; -} diff --git a/wit/shepherd-cow/cow-events.wit b/wit/shepherd-cow/cow-events.wit new file mode 100644 index 00000000..3b5505c1 --- /dev/null +++ b/wit/shepherd-cow/cow-events.wit @@ -0,0 +1,24 @@ +package shepherd:cow@0.1.0; + +/// CoW on-chain event surfaces the keepers decode. Package of record +/// for the canonical event signatures and their keccak256 topic-0 +/// hashes; guest constants and module manifests are parity-tested +/// against this file. +interface cow-events { + /// A decoded CoW on-chain event surface. Each variant doc pins the + /// canonical Solidity signature and its topic-0. + enum cow-event { + /// ComposableCoW registration. + /// signature: ConditionalOrderCreated(address,(address,bytes32,bytes)) + /// topic0: 0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361 + conditional-order-created, + /// ComposableCoW v2 single-order removal. + /// signature: ConditionalOrderRemoved(address,bytes32) + /// topic0: 0x67e0f2b23e842ce65d7edff49765689c9c0931f911fc5971d09bb598cc1af4a9 + conditional-order-removed, + /// CoWSwapOnchainOrders (EthFlow) placement. + /// signature: OrderPlacement(address,(address,address,address,uint256,uint256,uint32,bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes) + /// topic0: 0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9 + order-placement, + } +} diff --git a/wit/shepherd-cow/cow-ext.wit b/wit/shepherd-cow/cow-ext.wit deleted file mode 100644 index ed71da3b..00000000 --- a/wit/shepherd-cow/cow-ext.wit +++ /dev/null @@ -1,8 +0,0 @@ -package shepherd:cow@0.2.0; - -/// Extension world: the cow-api interface alone, wired into a module -/// linker by the cow extension. Kept separate from `shepherd` so the -/// extension contributes only its own import, never the core interfaces. -world cow-ext { - import cow-api; -} diff --git a/wit/shepherd-cow/shepherd.wit b/wit/shepherd-cow/shepherd.wit deleted file mode 100644 index 88aff143..00000000 --- a/wit/shepherd-cow/shepherd.wit +++ /dev/null @@ -1,7 +0,0 @@ -package shepherd:cow@0.2.0; - -/// Shepherd module — event-driven Nexum module with CoW Protocol extensions. -world shepherd { - include nexum:host/event-module@0.2.0; - import cow-api; -} diff --git a/wit/videre-types/types.wit b/wit/videre-types/types.wit new file mode 100644 index 00000000..d77d5f61 --- /dev/null +++ b/wit/videre-types/types.wit @@ -0,0 +1,87 @@ +package videre:types@0.1.0; + +/// The venue-neutral intent ontology. Depends only on value-flow; never on +/// nexum:host, so the venue-error transport cases are its own. +interface types { + use videre:value-flow/types@0.1.0.{asset-amount}; + + /// How an intent is authorised at its venue. Non-EVM schemes are 0.2+. + variant auth-scheme { + eip1271, + eip712, + } + + /// Where a deal settles. EVM-only in 0.1. + record settlement { + chain: u64, + } + + /// Adapter-derived description of an intent body: the ontology guard policy + /// runs on. Policy has teeth on `gives`; `wants` is display-grade. + record intent-header { + gives: asset-amount, + wants: asset-amount, + settlement: settlement, + authorisation: auth-scheme, + } + + /// Venue-scoped stable id for a submitted intent. Opaque to host and policy. + type receipt = list; + + /// An EVM call the host must sign and send. The adapter only describes it; + /// the host fills gas/fee and signs, so adapters cannot move value. Always + /// a call to existing code. + record unsigned-tx { + chain: u64, + /// 20-byte contract address. + to: list, + /// Native value, big-endian minimal; empty is zero. + value: list, + /// ABI-encoded calldata. + data: list, + } + + /// What a successful submit produced. + variant submit-outcome { + accepted(receipt), + requires-signing(unsigned-tx), + } + + /// Lifecycle state. Coarse and portable; proof and failure reason ride the + /// opaque status body (docs/design/videre-wit-pinned-0.1.0.md). + enum intent-status { + pending, + open, + fulfilled, + cancelled, + expired, + } + + /// Failure of a client or adapter call. `denied` and `rate-limited` are the + /// only guard/transport shapes; `denied` MUST NOT be retried. + variant venue-error { + unknown-venue, + invalid-body(string), + unsupported, + denied(string), + rate-limited(rate-limit), + unavailable(string), + timeout, + /// An empty or structurally invalid receipt. + invalid-receipt, + /// The venue-returned identifier disagrees with the locally derived one. + receipt-mismatch, + } + + record rate-limit { + retry-after-ms: option, + } + + /// An indicative quotation for a body. Firm/RFQ maker-side offers are 0.2+. + record quotation { + gives: asset-amount, + wants: asset-amount, + fee: asset-amount, + valid-until-ms: u64, + } +} diff --git a/wit/videre-value-flow/types.wit b/wit/videre-value-flow/types.wit new file mode 100644 index 00000000..c3aa9de9 --- /dev/null +++ b/wit/videre-value-flow/types.wit @@ -0,0 +1,32 @@ +package videre:value-flow@0.1.0; + +/// Egress-neutral vocabulary for value in motion. Carries no dependency so it +/// outlives any contract built on it. EVM-only in 0.1. +interface types { + /// 20-byte EVM address, big-endian. + type address = list; + + /// Unsigned integer, big-endian, minimal-length: no leading zero bytes, + /// zero is the empty list. Decoders MUST compare by integer value, not by + /// byte equality. + type uint = list; + + /// An ERC-20 token on the intent's settlement chain. + record erc20 { + token: address, + } + + /// A kind of value that can move. erc721/erc1155/service/offchain are 0.2+. + variant asset { + /// The settlement chain's gas token. + native, + erc20(erc20), + } + + /// An amount of one asset. Never negative; direction lives in the field + /// that holds the pair (`gives` vs `wants`). + record asset-amount { + asset: asset, + amount: uint, + } +} diff --git a/wit/videre-venue/venue.wit b/wit/videre-venue/venue.wit new file mode 100644 index 00000000..8a9685f2 --- /dev/null +++ b/wit/videre-venue/venue.wit @@ -0,0 +1,49 @@ +package videre:venue@0.1.0; + +/// Worker (keeper) face. The host holds the venue registry; the keeper names +/// a venue by string. +interface client { + use videre:types/types@0.1.0.{quotation, receipt, intent-status, submit-outcome, venue-error}; + + quote: func(venue: string, body: list) -> result; + submit: func(venue: string, body: list) -> result; + /// Put an externally-obtained receipt (e.g. an on-chain placement) + /// under the host's status watch; an accepted submit is watched + /// implicitly. Idempotent. + observe: func(venue: string, receipt: receipt) -> result<_, venue-error>; + status: func(venue: string, receipt: receipt) -> result; + cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; +} + +/// Provider (venue) face. Mirrors `client` without the venue selector: one +/// installed adapter answers for exactly one venue, so the registry resolves +/// a venue id to its adapter and calls it directly. +interface adapter { + use videre:types/types@0.1.0.{intent-header, quotation, receipt, intent-status, submit-outcome, venue-error}; + + /// Body-schema versions this adapter decodes. Must equal the + /// manifest `[venue] body_versions` set; install asserts it. + body-versions: func() -> list; + + /// Pure: derive the guard-facing header from a body. No I/O. + derive-header: func(body: list) -> result; + quote: func(body: list) -> result; + submit: func(body: list) -> result; + status: func(receipt: receipt) -> result; + cancel: func(receipt: receipt) -> result<_, venue-error>; +} + +/// A venue adapter component: the provider face over scoped transport only. +/// No local-store, remote-store, identity, or logging import, so an adapter +/// structurally cannot touch host key material or persistent state. Outbound +/// HTTP is wasi:http, linked separately and allowlisted per adapter. +world venue-adapter { + use nexum:host/types@0.1.0.{config, fault}; + + import nexum:host/chain@0.1.0; + import nexum:host/messaging@0.1.0; + + /// Configure the adapter from its `[config]` before any submission. + export init: func(config: config) -> result<_, fault>; + export adapter; +}