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/workflows/ci.yml b/.github/workflows/ci.yml index e0f2788e..9e0f3b9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ env: # 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 + # compiler input, and the sole build.rs (cow-venue) + the macro crates 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). @@ -78,7 +78,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 18 guest wasms ONCE (17 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 +88,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 stop-loss -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 +149,30 @@ 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 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..6733e983 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,23 @@ 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", "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 +3716,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 +3860,7 @@ dependencies = [ "axum", "clap", "rand 0.10.2", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "tokio", @@ -4424,44 +4538,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 +4619,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 +5140,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]] @@ -5239,13 +5264,12 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", + "cow-venue", "cowprotocol", "nexum-sdk", "nexum-sdk-test", - "serde_json", - "shepherd-sdk", - "shepherd-sdk-test", "tracing", + "videre-sdk", "wit-bindgen 0.59.0", ] @@ -5789,15 +5813,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 +5944,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 +6165,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 +6917,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 +6957,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 +6984,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 +7015,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 +7052,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..37235e3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,22 +1,34 @@ [workspace] members = [ + "crates/composable-cow", + "crates/cow-venue", "crates/nexum-cli", + "crates/nexum-launch", + "crates/nexum-module-macros", "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", + "crates/nexum-world", + "crates/no-std-probe", + "crates/shepherd", + "crates/videre-host", + "crates/videre-macros", + "crates/videre-sdk", + "crates/videre-status-body", + "crates/videre-test", "modules/ethflow-watcher", "modules/example", "modules/examples/balance-tracker", + "modules/examples/echo-client", + "modules/examples/echo-keeper", + "modules/examples/echo-venue", "modules/examples/http-probe", "modules/examples/price-alert", "modules/examples/stop-loss", "modules/fixtures/clock-reader", "modules/fixtures/flaky-bomb", + "modules/fixtures/flaky-venue", "modules/fixtures/fuel-bomb", "modules/fixtures/memory-bomb", "modules/fixtures/panic-bomb", @@ -48,11 +60,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 +101,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 +110,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 +120,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 +133,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 +167,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 +185,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..fa19d90f 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,31 @@ 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 -p stop-loss --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 +# Five 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 stop-loss --target wasm32-wasip2 --release --locked \ + && cargo build -p cow-venue --target wasm32-wasip2 --release --locked --features adapter # ----------------------------------------------------------------- runtime @@ -108,7 +113,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. @@ -123,6 +128,13 @@ COPY --from=build /src/modules/examples/price-alert/module.toml /opt/shepher 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 +# 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/crates/cow-venue/module.toml /opt/shepherd/manifests/cow-venue.toml +COPY --from=build /src/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 # binds 127.0.0.1:9100 inside the container. @@ -138,5 +150,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/README.md b/README.md index 91026dd0..21aa9c19 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,12 @@ Looking for the org? See **[github.com/nullislabs](https://github.com/nullislabs | 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-launch/` | The generic launcher library - shared CLI, config load, tracing, and the preset-driven launch. | +| `crates/nexum-cli/` | The bare `nexum` binary - the core lattice with no extension payload. | +| `crates/shepherd/` | The `shepherd` binary - the cow composition root registering the videre venue platform. | | `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`. | +| `wit/shepherd-cow/` | The `shepherd:cow` WIT package - the CoW event ABIs of record. | | `modules/` | Guest modules - TWAP and EthFlow watch-towers, examples, and test fixtures. | | `docs/` | Architecture and design notes. Start with [`docs/00-overview.md`](docs/00-overview.md). | @@ -82,7 +83,7 @@ name = "twap-monitor" version = "0.1.0" [capabilities] -required = ["chain", "local-store", "cow-api"] +required = ["chain", "local-store", "client"] optional = ["http"] [[subscription]] diff --git a/crates/composable-cow/Cargo.toml b/crates/composable-cow/Cargo.toml new file mode 100644 index 00000000..e785b2cb --- /dev/null +++ b/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 sweep 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-sdk" } +# `sweep` 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-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. +sweep = ["dep:cow-venue", "dep:videre-sdk", "dep:tracing"] + +[dev-dependencies] +proptest.workspace = true +nexum-sdk-test = { path = "../nexum-sdk-test" } + +[[test]] +name = "sweep" +required-features = ["sweep"] diff --git a/crates/composable-cow/src/body.rs b/crates/composable-cow/src/body.rs new file mode 100644 index 00000000..1ca8466c --- /dev/null +++ b/crates/composable-cow/src/body.rs @@ -0,0 +1,83 @@ +//! The composable (conditional) order body. +//! +//! ComposableCoW expresses a conditional order as the +//! `ConditionalOrderParams` tuple: the handler contract that mints the +//! tradeable order, a salt that distinguishes otherwise-identical +//! conditional orders, and the opaque handler-specific static input. +//! This body type is that tuple in wire form. The one non-obvious +//! invariant: `static_input` is opaque; only the named handler parses +//! it, so this crate never inspects its bytes. +//! +//! Borsh comes from `alloy-primitives`' own `borsh` feature, so no +//! adapter is needed: `Address` and `B256` encode as their bare bytes +//! and `Bytes` length-prefixed, leaving the wire byte-identical to the +//! `[u8; 20]`/`[u8; 32]`/`Vec` these fields replaced. + +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 + ); + } + + /// The alloy types must encode exactly as the raw arrays did: 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/crates/composable-cow/src/lib.rs b/crates/composable-cow/src/lib.rs new file mode 100644 index 00000000..d4dc3680 --- /dev/null +++ b/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 `sweep` 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 = "sweep")] +pub mod sweep; + +pub use body::ComposableBody; +pub use poll::{IConditionalOrder, LegacyRevertAdapter, Verdict}; +#[cfg(feature = "sweep")] +pub use sweep::run; diff --git a/crates/composable-cow/src/poll.rs b/crates/composable-cow/src/poll.rs new file mode 100644 index 00000000..590549cc --- /dev/null +++ b/crates/composable-cow/src/poll.rs @@ -0,0 +1,372 @@ +//! ComposableCoW poll seam: the structured [`Verdict`] and the +//! quarantined [`LegacyRevertAdapter`]. +//! +//! Every strategy poll resolves to a [`Verdict`] - the structured +//! outcome mirroring the composable-cow fork's structured generator. +//! The keeper run and each strategy module +//! dispatch on the `Verdict` variants alone; nothing downstream knows +//! how the outcome was produced. +//! +//! The deployed ComposableCoW 1.x contract does not speak that +//! structured vocabulary. Its `getTradeableOrderWithSignature` reverts +//! with one of five custom errors when the conditional order is not +//! ready, expired, or otherwise non-tradeable. That reverting wire is +//! frozen: this module keeps decoding it, but the decode is quarantined +//! behind [`LegacyRevertAdapter`], which maps each legacy revert onto a +//! [`Verdict`]. When the fork's structured generator ships, the adapter +//! is the single seam that retires - the `Verdict` surface and every +//! dispatch site stay put. +//! +//! 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; +use nexum_sdk::host::ChainError; + +sol! { + /// Five custom errors `IConditionalOrder.verify` reverts with - + /// the deployed ComposableCoW 1.x error surface. Selector source + /// for [`LegacyRevertAdapter::decode`]. 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); + } +} + +/// Structured outcome of a single watch poll, mirroring the +/// composable-cow fork's structured generator. +/// +/// Every variant except `Post` carries a `reason`: the raw 4-byte +/// selector the outcome was derived from, for logging only - no +/// behaviour keys off it. It is `[0; 4]` when the outcome is synthetic +/// (no selector available, e.g. a transport fault). `Post` is the only +/// variant [`LegacyRevertAdapter`] never produces; it comes from the +/// successful return path each strategy constructs at the call site. +#[derive(Debug)] +pub enum Verdict { + /// 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. a few for the other + /// variants). + Post { + /// 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, + /// Advisory Unix timestamp (seconds) the fork's generator hints + /// the next poll at. `0` when synthetic - the legacy adapter + /// has no such hint, so the submit path ignores it. + next_poll_timestamp: u64, + }, + /// Retry once the wall clock (Unix seconds, UTC) reaches + /// `wait_until`. + WaitTimestamp { + /// Unix timestamp (seconds) to re-poll at or after. + wait_until: u64, + /// Source selector, log only. + reason: [u8; 4], + }, + /// Retry once the block number reaches `wait_until`. + WaitBlock { + /// Block number to re-poll at or after. + wait_until: u64, + /// Source selector, log only. + reason: [u8; 4], + }, + /// Retry on the very next block - typical for time-sliced TWAP + /// schedules and other handlers that re-check on every tick. + TryNextBlock { + /// Source selector, log only. + reason: [u8; 4], + }, + /// Order is dead - drop the watch. Aggregates the legacy + /// `OrderNotValid` and `PollNever` reverts and any unrecognised + /// contract-level rejection. + Invalid { + /// Source selector, log only. + reason: [u8; 4], + }, + /// The generator needs off-chain input before it can produce an + /// order. Never produced by [`LegacyRevertAdapter`]; the + /// keeper run parks the watch untouched. + NeedsInput { + /// Source selector, log only. + reason: [u8; 4], + }, +} + +/// Quarantined decoder for the deployed ComposableCoW 1.x reverting +/// wire. Maps each legacy `getTradeableOrderWithSignature` revert onto +/// a [`Verdict`]; this is the single seam that retires when the fork's +/// structured generator ships. +#[derive(Debug, Clone, Copy)] +pub struct LegacyRevertAdapter; + +impl LegacyRevertAdapter { + /// Decode a `getTradeableOrderWithSignature` revert payload into a + /// [`Verdict`]. + /// + /// Returns `None` when the selector is not one of the five + /// [`IConditionalOrder`] errors - including a bare `Error(string)` + /// require-revert. [`classify`](Self::classify) is the lifecycle + /// policy on top: it treats any such foreign selector as a + /// permanent contract-level rejection. + #[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 revert payload big enough to carry a selector that + /// [`decode`](Self::decode) does not recognise maps to `Invalid`: + /// it is a contract-level rejection outside the `IConditionalOrder` + /// vocabulary (a handler-specific error, typically permanent), and + /// retrying it on every block loops forever. Only payload-free + /// failures - transport faults and reverts whose `data` is absent + /// or shorter than a selector - 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); + } + + // ---- LegacyRevertAdapter::classify ---- + + 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 handler-specific selector outside the `IConditionalOrder` + /// vocabulary is a permanent contract-level rejection: it must map + /// to `Invalid`, not re-poll every block 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` on arbitrary revert bytes never panics and returns + /// `None` for inputs shorter than the 4-byte EVM selector. + #[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/crates/composable-cow/src/sweep.rs b/crates/composable-cow/src/sweep.rs new file mode 100644 index 00000000..59fab5b4 --- /dev/null +++ b/crates/composable-cow/src/sweep.rs @@ -0,0 +1,213 @@ +//! Keeper sweep: the poll-loop composition conditional- +//! commitment modules share. +//! +//! [`run`] walks the keeper watch set, polls each gate-ready +//! watch through a [`ConditionalSource`], and runs the +//! [`Verdict`]'s effect: lifecycle outcomes update the gate and +//! watch stores, `Post` drives one submission through the typed +//! [`CowClient`] onto the `videre:venue/client` seam with the +//! `submitted:` journal as the idempotency guard - keyed on the +//! venue-and-body [`intent_id`] - and the keeper [`Retrier`] +//! as the failure dispatch. +//! +//! Store faults abort the sweep (the next tick replays it); +//! submission failures never do - they fold into a +//! [`RetryAction`] through the videre +//! [`retry_action`] table, a `denied` refusal re-entering the CoW +//! classification through its errorType prefix +//! ([`classify_denied`]) so a one-shot row survives the coarse +//! collapse, the ledger applies the effect, and the +//! sweep moves on. Diagnostics go through the guest `tracing` facade - +//! the same channel strategy code logs on - so module tests observe +//! the composed behaviour with one capture. + +use alloy_primitives::{Address, Bytes, hex}; +use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; +use cow_venue::{CowClient, CowIntent, CowIntentBody, SignedOrder, classify_denied, intent_id}; +use cowprotocol::GPv2OrderData; +use nexum_sdk::host::{Fault, LocalStoreHost}; +use nexum_sdk::keeper::{ + ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, +}; +use videre_sdk::keeper::retry_action; +use videre_sdk::{ClientError, SubmitOutcome, VenueFault, VenueTransport, rt}; + +use crate::Verdict; + +/// Poll every gate-ready watch once at `tick` and run each outcome's +/// effect. One source poll per ready watch; a `Post` outcome makes at +/// most one venue submit through `venue`. +pub fn run(host: &H, venue: &CowClient, source: &S, tick: &Tick) -> Result<(), Fault> +where + H: LocalStoreHost, + S: ConditionalSource, + T: VenueTransport, +{ + 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 freshly-polled `Ready` order through the typed client, +/// guarding on the `submitted:` journal and dispatching any venue +/// refusal through the retry ledger. +/// +/// The journal keys on the deterministic venue-and-body [`intent_id`], +/// derived before any network work from the same body bytes the venue +/// submit carries, so the guard is independent of where assembly +/// happens. The id is reserved before the submit and committed on +/// acceptance, so a record write that faults after acceptance still +/// leaves a marker and the next tick does not re-post; a non-accepting +/// outcome releases the reservation. The venue's receipt rides the log +/// only. +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 { + // An unknown enum marker means the SDK cannot express this + // payload yet; skip rather than drop so an SDK upgrade can + // still pick the watch 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: owner.into_array(), + signature: signature.to_vec(), + })); + let intent_id = match intent_id(&intent) { + Ok(id) => id, + Err(err) => { + tracing::error!("intent body encode failed: {err}"); + return Ok(()); + } + }; + let journal = Journal::submitted(host); + if journal.contains(&intent_id)? { + tracing::info!("{label} {intent_id} already submitted; skipping re-submit"); + return Ok(()); + } + // Reserve before the venue call: a record write that faults after + // acceptance still leaves this marker, so no next tick re-posts an + // accepted body. A non-accepting outcome releases it. + journal.reserve(&intent_id)?; + + let Some(outcome) = rt::complete(venue.submit(&intent)) else { + // Guest transports never suspend; a pending future means a + // foreign transport misbehaved. The watch stays for the next + // tick. + journal.release(&intent_id)?; + tracing::error!("{label} submit future suspended; retrying next tick"); + return Ok(()); + }; + match outcome { + Ok(SubmitOutcome::Accepted(receipt)) => { + // An acceptance ends any refusal episode: clear the + // first-refusal marker so a later independent refusal + // earns a fresh one-block grace. + if let Err(fault) = Retrier::new(host).clear_refusal(watch) { + tracing::error!("submitted {intent_id} but refusal-marker clear failed: {fault}"); + } + // Commit best-effort: the reservation already guards the + // re-post, so a record fault here must not abort the sweep + // or unwind the accepted order. + if let Err(fault) = journal.record(&intent_id) { + tracing::error!("submitted {intent_id} but journal write failed: {fault}"); + } + tracing::info!( + "submitted {intent_id} (receipt {})", + hex::encode_prefixed(&receipt), + ); + } + Ok(SubmitOutcome::RequiresSigning(_)) => { + // A sweep cannot sign; the reservation is released, so the + // next tick surfaces the same ask afresh. + journal.release(&intent_id)?; + tracing::warn!("{label} submit for {owner:#x} requires signing; not journalled"); + } + Err(ClientError::Body(err)) => { + journal.release(&intent_id)?; + tracing::error!("intent body encode failed: {err}"); + } + Err(ClientError::Venue(fault)) => { + journal.release(&intent_id)?; + 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}"), + // `RetryAction` is non-exhaustive; the ledger already + // ran the effect, so the log needs only the name. + other => { + let action_label: &'static str = other.into(); + tracing::warn!("submit retry action {action_label}: {fault}"); + } + } + } + // `ClientError` is non-exhaustive; a future case releases the + // reservation and leaves the watch for the next tick. + Err(err) => { + journal.release(&intent_id)?; + tracing::error!("submit failed: {err}"); + } + } + Ok(()) +} diff --git a/crates/composable-cow/tests/sweep.rs b/crates/composable-cow/tests/sweep.rs new file mode 100644 index 00000000..78db820d --- /dev/null +++ b/crates/composable-cow/tests/sweep.rs @@ -0,0 +1,833 @@ +//! Sweep 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::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::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; +use nexum_sdk_test::{MockHost, 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 sweep +/// 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 ConditionalSource 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 higher-ranked source signature at the +/// construction site so inference never guesses a too-narrow 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: 0, + } +} + +fn seed_watch(host: &MockHost) -> String { + WatchSet::new(host) + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap() +} + +/// The encoded intent body the sweep 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().into_array(), + signature: hex!("c0ffeec0ffeec0ffee").to_vec(), + })) + .to_bytes() + .expect("body encodes") +} + +/// The intent-id the sweep journals for `order`: the venue-and-body +/// key over the same signed body `run` derives pre-submit. +fn intent_id(order: &GPv2OrderData) -> String { + submission_key(&CowVenue::ID, &intent_bytes(order)) +} + +fn accepted() -> Result { + Ok(SubmitOutcome::Accepted(vec![0xAA])) +} + +// ---- lifecycle outcomes ---- + +#[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"); +} + +// ---- gating and skipping ---- + +#[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); +} + +// ---- ready -> submission ---- + +#[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 sweep cannot sign: a `requires-signing` outcome is surfaced, not +/// journalled, so the next tick re-poses the same ask. +#[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"))); +} + +// ---- submission failure dispatch ---- + +#[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 with server guidance backs the watch off on the +/// epoch clock - `RetryAction::Backoff` reached through the ledger. +#[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 adapter's projection of the same-block wiring+create race: a +/// first-time user's Safe wiring and `create()` land in one block, so +/// 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(), + )) +} + +/// Same-block wiring+create race: the first rejection gates the watch +/// to the next block instead of dropping it, re-polls within the block +/// stay gated, and the retried submission 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 that repeats on a later block is a genuinely broken +/// signature: the watch and every derived key go. +#[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 accepted submission ends the refusal episode: a later tranche's +/// own first rejection earns a fresh one-block grace instead of an +/// immediate drop on the stale marker from an earlier tranche. +#[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 keeper that posted, journalled, and then +/// restarted over the same persistent local store must not post the +/// same order again - 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(), + ); +} + +/// Wraps a host and faults the commit write (the empty marker), letting +/// the non-empty reserve through: the post-accept record fault the +/// reservation is meant to survive. +struct FailCommit(MockHost); + +impl LocalStoreHost for FailCommit { + fn get(&self, key: &str) -> Result>, Fault> { + self.0.get(key) + } + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { + if value.is_empty() { + return Err(Fault::Unavailable("commit store down".into())); + } + self.0.set(key, value) + } + fn delete(&self, key: &str) -> Result<(), Fault> { + self.0.delete(key) + } + fn list_keys(&self, prefix: &str) -> Result, Fault> { + self.0.list_keys(prefix) + } +} + +/// A record write that faults after an accepted submit leaves the +/// reservation, so the next tick does not re-post - the p1 defect. +#[test] +fn record_fault_after_acceptance_does_not_repost() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(accepted()); + + let fail = FailCommit(host); + let source = { + let order = order.clone(); + FnSource(move |_: &FailCommit, _: WatchRef<'_>, _: &[u8], _: &Tick| ready_outcome(&order)) + }; + + // Reserve lands, submit is accepted, the commit write faults and is + // swallowed - the reservation persists. + run(&fail, &client(&venue), &source, &sample_tick()).unwrap(); + assert_eq!(venue.submit_count(), 1); + assert!( + Journal::submitted(&fail) + .contains(&intent_id(&order)) + .unwrap(), + "the reservation survives a faulted commit", + ); + + // The next tick re-polls but the reservation blocks the re-post. + run(&fail, &client(&venue), &source, &sample_tick()).unwrap(); + assert_eq!( + venue.submit_count(), + 1, + "an accepted body is never re-posted" + ); +} + +/// A retryable submit fault releases the reservation, so the body +/// reaches the venue again rather than being skipped for good. +#[test] +fn retryable_fault_releases_the_reservation_and_retries() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(Err(VenueFault::Unavailable("orderbook http 502".into()))); + venue.enqueue_submit(accepted()); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); + assert!( + !host + .store + .snapshot() + .keys() + .any(|k| k.starts_with("submitted:")), + "a released reservation leaves no marker", + ); + + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); + assert_eq!( + venue.submit_count(), + 2, + "a retryable fault must not permanently skip the watch", + ); + assert!( + Journal::submitted(&host) + .contains(&intent_id(&order)) + .unwrap(), + ); +} + +// ---- the generic seam ---- + +/// The seam proof: a `Post` verdict reaches the venue transport as the +/// encoded `CowIntentBody` under the CoW venue id, and the journal +/// keys 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", + ); +} diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml new file mode 100644 index 00000000..d718ad35 --- /dev/null +++ b/crates/cow-venue/Cargo.toml @@ -0,0 +1,89 @@ +[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 strategy 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-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-sdk", optional = true } +# `assembly` slice: the chain-edge order projections and orderbook +# submission bodies. 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 } +alloy-primitives = { workspace = true, 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] +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-test" } +# Parity tests only: the upstream errorType enum and `retry_hint()` the +# shipped table is reconciled against. Never a runtime dependency. +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"] +client = ["body", "dep:nexum-sdk"] +# 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-primitives", "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/crates/cow-venue/build.rs b/crates/cow-venue/build.rs new file mode 100644 index 00000000..f619f788 --- /dev/null +++ b/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/crates/cow-venue/data/classification.toml b/crates/cow-venue/data/classification.toml new file mode 100644 index 00000000..2d236248 --- /dev/null +++ b/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/crates/cow-venue/module.load.toml b/crates/cow-venue/module.load.toml new file mode 100644 index 00000000..b28b4a4a --- /dev/null +++ b/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/crates/cow-venue/module.sepolia.toml b/crates/cow-venue/module.sepolia.toml new file mode 100644 index 00000000..35635f09 --- /dev/null +++ b/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/crates/cow-venue/module.toml b/crates/cow-venue/module.toml new file mode 100644 index 00000000..0e5b555c --- /dev/null +++ b/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/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs new file mode 100644 index 00000000..693154ee --- /dev/null +++ b/crates/cow-venue/src/adapter.rs @@ -0,0 +1,948 @@ +//! 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, every +//! request bounded by the configured per-request timeout +//! ([`BoundedFetch`](videre_sdk::transport::BoundedFetch)). Orderbook +//! `errorType` rejections project onto +//! `venue-error` through the shipped classification table so the retry +//! hint survives the collapse: transient rows are `unavailable`, +//! throttles are `rate-limited`, permanent rows are `denied` (never +//! retried). An unsigned order submits as pre-sign: success is +//! `requires-signing` carrying the `setPreSignature` call the host +//! signs and sends. +//! +//! `[config]` keys: `chain` (required, decimal chain id), and optional +//! `orderbook-url` (base URL override), `owner` (hex address enabling +//! the pre-sign path), `http-timeout-ms` (per-request bound, +//! defaulting to the SDK's per-phase timeout). + +use core::time::Duration; +use std::sync::{PoisonError, RwLock}; + +use alloy_primitives::{Address, U256}; +use cowprotocol::{ + ApiError, Chain, OrderCreation, OrderData, OrderKind, OrderStatus, QuoteAppData, QuoteRequest, +}; +use nexum_sdk::keeper::RetryAction; +use serde::Deserialize; +use url::Url; +use videre_sdk::transport::http::Fetch; +use videre_sdk::value_flow::{Asset, AssetAmount, Erc20}; +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 protocol speaker: the `venue-adapter` export type. +/// The component face is supplied by `#[videre_sdk::venue]` on the +/// [`VenueAdapter`](videre_sdk::VenueAdapter) impl. +pub struct 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, + }) + } +} + +/// The configured adapter state; `init` replaces it whole, so a +/// supervisor restart re-configures cleanly. +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 the typed refusal for an uninitialised call. +/// The world contract calls `init` before any submission, so this is +/// only reachable on a host driving the exports out of order. +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) +} + +/// Pure header derivation for `chain`: gives the sell side, wants the +/// buy side, authorisation by intent kind (a signed order carries its +/// EIP-1271 signature; an unsigned order is authorised by host-held +/// keys through the pre-sign flow). +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: erc20(order.sell_token, minimal_be(&order.sell_amount)), + wants: erc20(order.buy_token, minimal_be(&order.buy_amount)), + settlement: Settlement { chain }, + authorisation, + }) +} + +/// Submit one intent to the orderbook. A signed order posts EIP-1271 +/// and its receipt is the canonical 56-byte UID; an unsigned order +/// posts pre-sign and success is `requires-signing` carrying the +/// `setPreSignature` call. Either way an already-held rejection is +/// success wearing an error status: the UID is derived client-side, so +/// the outcome is identical to a fresh accept. +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 = Address::from(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) => uid, + 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) => uid, + 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), + })) + } + } +} + +/// 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::InvalidBody("receipt is not a 56-byte order uid".to_owned()))?; + 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(&response).into_error()); + } + /// 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, Address::from(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(&response).into_error()); + } + let quoted: cowprotocol::OrderQuoteResponse = serde_json::from_slice(response.body()) + .map_err(|e| VenueError::Unavailable(format!("quote decode failed: {e}")))?; + Ok(Quotation { + gives: erc20( + order.sell_token.into_array(), + minimal_be_u256(quoted.quote.sell_amount), + ), + wants: erc20( + order.buy_token.into_array(), + minimal_be_u256(quoted.quote.buy_amount), + ), + fee: erc20( + order.sell_token.into_array(), + minimal_be_u256(quoted.quote.fee_amount), + ), + valid_until_ms: u64::from(quoted.quote.valid_to).saturating_mul(1000), + }) +} + +/// The quote request pinned to the body's own terms, so the indicative +/// price answers for exactly the order the keeper would place. +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(&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; every transport failure arrives as a typed +/// [`VenueError`] through the fetch conversion. +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 orderbook reply, projected for the wire. +enum Refusal { + /// The structured already-held rejection: success wearing an error + /// status. + AlreadyHeld, + /// Everything else, as the `venue-error` the caller reports. + Error(VenueError), +} + +impl Refusal { + /// Collapse for call sites where already-held is not a success + /// shape (reads); the orderbook only emits it on submission. + fn into_error(self) -> VenueError { + match self { + Refusal::AlreadyHeld => VenueError::Unavailable("order already held".to_owned()), + Refusal::Error(err) => err, + } + } +} + +/// Project a non-2xx reply: throttles first, server failures stay +/// retryable, and only a structured 4xx envelope reaches the +/// classification table. +fn refusal(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_type) => Refusal::AlreadyHeld, + Ok(api) => Refusal::Error(classified(&api)), + Err(_) => Refusal::Error(VenueError::Unavailable(format!( + "orderbook status {status}" + ))), + } +} + +/// `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); + match classification::classify(&api.error_type) { + 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), + } +} + +// ── value projections ──────────────────────────────────────────────── + +/// Big-endian bytes with leading zeros trimmed: the minimal `uint` +/// spelling, where an empty list is zero. +fn minimal_be(bytes: &[u8; 32]) -> Vec { + let first = bytes.iter().position(|byte| *byte != 0); + first.map_or(Vec::new(), |index| bytes[index..].to_vec()) +} + +fn minimal_be_u256(value: U256) -> Vec { + minimal_be(&value.to_be_bytes::<32>()) +} + +fn erc20(token: [u8; 20], amount: Vec) -> AssetAmount { + AssetAmount { + asset: Asset::Erc20(Erc20 { + token: token.to_vec(), + }), + amount, + } +} + +// 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` and wires the trait through it. The real transport is +/// wasi:http behind the configured +/// [`BoundedFetch`](videre_sdk::transport::BoundedFetch) bound. +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 videre_sdk::IntentBody as _; + use videre_sdk::transport::BoundedFetch; + use videre_sdk::transport::http::FetchError; + use videre_test::MockFetch; + + 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([0x11; 20]), amount(42)) + .for_at_least(BuyToken([0x22; 20]), amount(41)) + .valid_to(1_700_000_000) + .app_data([0x44; 32]) + .build() + } + + fn amount(value: u8) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[31] = value; + bytes + } + + fn signed_bytes() -> Vec { + CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_body(), + owner: owner().into_array(), + 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 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(&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::InvalidBody(_)) + )); + + 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); + } +} diff --git a/crates/cow-venue/src/assembly.rs b/crates/cow-venue/src/assembly.rs new file mode 100644 index 00000000..6e04db1c --- /dev/null +++ b/crates/cow-venue/src/assembly.rs @@ -0,0 +1,386 @@ +//! Chain-edge order assembly: the projections between the on-chain +//! `GPv2OrderData` tuple, the typed `OrderData`, and the venue wire +//! [`OrderBody`], plus the orderbook submission bodies built from them. +//! +//! This is the venue's protocol knowledge: `kind` / +//! `sellTokenBalance` / `buyTokenBalance` ride the chain as 32-byte +//! keccak markers and the orderbook signs against the typed shape, so +//! the adapter, not the keeper, owns every projection across that +//! edge. + +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; + +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; + +/// Convert a freshly-polled / freshly-placed [`GPv2OrderData`] into the +/// typed [`OrderData`] shape `OrderCreation::new` 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::new` does the same downstream, but doing it here +/// keeps the EIP-712 hash inputs verbatim if a caller bypasses that +/// constructor later. +/// +/// # Example +/// +/// ``` +/// use cow_venue::assembly::gpv2_to_order_data; +/// use cowprotocol::{ +/// BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource, +/// }; +/// use alloy_primitives::{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)?, + }) +} + +/// Orderbook UID hex (`0x` + 112 hex chars) for the given on-chain +/// (order, owner, chain) tuple - the same value the orderbook derives +/// server-side from the signed payload, so a client can key +/// idempotency state before any network work. +/// +/// `None` when the chain id has no settlement domain or the order +/// carries an unknown enum marker. Only the unknown-marker case also +/// stops the submit path downstream ([`gpv2_to_order_data`] fails the +/// same way there); an unsupported chain id does not, so a caller +/// keying idempotency on this value alone re-submits until `validTo` +/// on such a chain - bounded, but callers adding new chains should +/// teach `cowprotocol::Chain` about them first. +#[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))) +} + +/// The canonical 56-byte orderbook UID for `order` under `chain`'s +/// settlement domain: what an accepted submit's receipt carries. +#[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`] a +/// keeper emits. Total: every typed field has exactly one wire form. +#[must_use] +pub fn order_data_to_body(order: &OrderData) -> OrderBody { + OrderBody { + sell_token: order.sell_token.into_array(), + buy_token: order.buy_token.into_array(), + receiver: order.receiver.map(Address::into_array), + sell_amount: order.sell_amount.to_be_bytes(), + buy_amount: order.buy_amount.to_be_bytes(), + valid_to: order.valid_to, + app_data: order.app_data.0, + fee_amount: order.fee_amount.to_be_bytes(), + 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, + }, + } +} + +/// Project a venue wire [`OrderBody`] back onto the typed [`OrderData`] +/// the orderbook signs against: [`order_data_to_body`]'s total inverse. +#[must_use] +pub fn body_to_order_data(body: &OrderBody) -> OrderData { + OrderData { + sell_token: Address::from(body.sell_token), + buy_token: Address::from(body.buy_token), + receiver: body.receiver.map(Address::from), + sell_amount: alloy_primitives::U256::from_be_bytes(body.sell_amount), + buy_amount: alloy_primitives::U256::from_be_bytes(body.buy_amount), + valid_to: body.valid_to, + app_data: body.app_data.into(), + fee_amount: alloy_primitives::U256::from_be_bytes(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 `OrderCreation` body the orderbook expects from a +/// polled conditional order. The signed `appData` digest goes out +/// verbatim in the hash-only wire shape (watch-tower parity), and the +/// signature is EIP-1271 - the conditional-order contract is the +/// verifier. +/// +/// An `Err` is a client-side precondition failure that would recur on +/// every retry of the same payload; 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` for an unsigned order: the +/// orderbook holds it as signature-pending until `from` settles the +/// authorisation on chain 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 `GPv2Settlement::setPreSignature(uid, true)` calldata: +/// the call the order's owner must sign and send 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()); + } + + // ---- order_data_to_body / body_to_order_data ---- + + #[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.into_array()); + assert_eq!(body.buy_token, g.buyToken.into_array()); + assert_eq!(body.receiver, Some(g.receiver.into_array())); + assert_eq!(body.sell_amount, g.sellAmount.to_be_bytes::<32>()); + assert_eq!(body.buy_amount, g.buyAmount.to_be_bytes::<32>()); + assert_eq!(body.valid_to, g.validTo); + assert_eq!(body.app_data, g.appData.0); + assert_eq!(body.fee_amount, g.feeAmount.to_be_bytes::<32>()); + 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); + } + } + + // ---- order_uid_hex ---- + + 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()); + } + + // ---- submission bodies ---- + + #[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/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs new file mode 100644 index 00000000..6bb39165 --- /dev/null +++ b/crates/cow-venue/src/body.rs @@ -0,0 +1,124 @@ +//! The CoW intent body and its versioned `IntentBody` codec. +//! +//! A CoW intent is an order for the orderbook; [`CowIntent`] is +//! that sum, open for future intent kinds. [`CowIntentBody`] is the +//! outer per-venue version enum the venue publishes, and +//! `#[derive(IntentBody)]` gives it the borsh codec: a one-byte version +//! tag plus the borsh payload, with an unknown tag failing as a typed +//! [`BodyError`](videre_sdk::BodyError) rather than a stringly borsh +//! error. The one non-obvious invariant: the tag order is the schema, so +//! new versions append at the end and no variant is ever reordered or +//! removed. + +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: what a + /// conditional-order keeper emits after a poll. + 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 super::*; + use videre_test::{CodecVectors, Expectation}; + + use crate::order::{BuyToken, SellToken}; + + fn order_body() -> OrderBody { + OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1_700_000_000) + .app_data([0x44; 32]) + .partially_fillable() + .build() + } + + /// The codec conformance set: the v1 intent as a round-trip vector + /// plus the typed failure contract, in the kit's published form. + 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: [0x55; 20], + 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/crates/cow-venue/src/classification.rs b/crates/cow-venue/src/classification.rs new file mode 100644 index 00000000..a3c6861b --- /dev/null +++ b/crates/cow-venue/src/classification.rs @@ -0,0 +1,356 @@ +//! Table-driven CoW retry classification. +//! +//! The `errorType -> {try-next-block, backoff, drop}` policy is shipped +//! as data in `data/classification.toml`. `build.rs` parses and +//! validates that file at build time and emits a static lookup table, so +//! [`classify`] and [`is_already_submitted`] read generated data rather +//! than a hand-coded `match` and no TOML parser reaches the guest. A +//! non-Rust author edits the TOML; a parity test re-parses the same file +//! and asserts the generated table agrees. +//! +//! The one non-obvious invariant: an `errorType` absent from the table +//! classifies as [`RetryAction::Drop`]. An unrecognised structured +//! rejection is a permanent contract-level refusal, not a transient +//! transport error, so it must not be retried every block forever. + +use nexum_sdk::keeper::RetryAction; + +/// The shipped classification data, embedded verbatim so a parity test +/// can re-parse the exact bytes `build.rs` generated the table from. +pub const CLASSIFICATION_TOML: &str = include_str!("../data/classification.toml"); + +/// The retry action a generated row selects, mirroring the TOML `action` +/// field. Turned into a keeper [`RetryAction`] by [`GeneratedRow`]. +/// +/// The variants are constructed only by the build-generated table, so a +/// shipped `classification.toml` that happens to carry no row of a given +/// action leaves that variant unconstructed. `allow(dead_code)` keeps +/// such a data edit from tripping the `-D warnings` build: which actions +/// appear is a property of the data, not the 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 { + fn row(&self, error_type: &str) -> Option<&GeneratedRow> { + self.rows.iter().find(|r| r.error_type == error_type) + } + + /// The retry action for an orderbook `errorType`. Unlisted types are + /// permanent: [`RetryAction::Drop`]. + pub fn classify(&self, error_type: &str) -> RetryAction { + self.row(error_type) + .map_or(RetryAction::Drop, GeneratedRow::retry_action) + } + + /// Whether the orderbook is reporting that it already holds this + /// exact order. Such a rejection keeps the watch and records the + /// receipt rather than retrying a fresh submission. + pub fn is_already_submitted(&self, error_type: &str) -> bool { + self.row(error_type).is_some_and(|r| r.already_submitted) + } + + /// Number of classified `errorType`s, for tests and diagnostics. + 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` into a keeper [`RetryAction`] via +/// the shipped table. Unlisted types are permanent ([`RetryAction::Drop`]). +pub fn classify(error_type: &str) -> RetryAction { + table().classify(error_type) +} + +/// Whether an orderbook `errorType` means the order is already held. +pub fn is_already_submitted(error_type: &str) -> bool { + table().is_already_submitted(error_type) +} + +/// Retry action for a coarse `denied` refusal. The adapter spells a +/// classified rejection as `{errorType}: {description}`; the prefix +/// re-enters the table so a [`RetryAction::DropOnRepeat`] row survives +/// the collapse to the wire `venue-error`. 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(error_type) { + RetryAction::DropOnRepeat => RetryAction::DropOnRepeat, + _ => RetryAction::Drop, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::classification_data::{Action, ClassificationError, parse_and_validate}; + + /// The generated table is non-empty. + #[test] + fn shipped_data_parses() { + assert!(!table().is_empty()); + } + + /// Data-vs-code parity: re-parse the shipped file independently and + /// assert the generated table (code) agrees with it (data) on every + /// entry. This catches a data edit the generated table missed and a + /// generator bug that drifts from the file. + #[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(&entry.error_type), + expected, + "classify {}", + entry.error_type, + ); + assert_eq!( + is_already_submitted(&entry.error_type), + entry.already_submitted, + "already-submitted {}", + entry.error_type, + ); + } + } + + /// A spot check of the contract in code, independent of the parse: + /// the exemplar rows the slice must carry, including the `Backoff` + /// producer the hand-coded classifier lacked. + #[test] + fn known_rows_classify_as_documented() { + assert_eq!(classify("InsufficientFee"), RetryAction::TryNextBlock); + assert_eq!( + classify("TooManyLimitOrders"), + RetryAction::Backoff { seconds: 30 }, + ); + assert_eq!( + classify("InvalidEip1271Signature"), + RetryAction::DropOnRepeat, + ); + assert_eq!(classify("InvalidSignature"), RetryAction::Drop); + assert!(is_already_submitted("DuplicatedOrder")); + assert!(is_already_submitted("DuplicateOrder")); + } + + /// Unlisted (including newly minted) types are permanent, so a + /// contract-level rejection is never retried every block forever. + #[test] + fn unlisted_type_drops() { + assert_eq!(classify("NewlyMintedErrorType"), RetryAction::Drop); + assert!(!is_already_submitted("NewlyMintedErrorType")); + } + + /// Every retry arm is reachable from the table alone. + #[test] + fn table_reaches_every_arm() { + assert_eq!(classify("InsufficientFee"), RetryAction::TryNextBlock); + assert!(matches!( + classify("TooManyLimitOrders"), + RetryAction::Backoff { .. } + )); + assert_eq!( + classify("InvalidEip1271Signature"), + RetryAction::DropOnRepeat, + ); + assert_eq!(classify("InvalidSignature"), RetryAction::Drop); + } + + /// A denied detail re-enters the table by its `errorType` prefix: + /// only a drop-on-repeat row escapes the permanent default, and a + /// transient row cannot be smuggled through a denial. + #[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 member of the upstream + /// orderbook errorType enum, in its exact wire spelling: no phantom + /// rows. + #[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 upstream `retry_hint()` is exactly + /// the ratified set in the data header. A data edit or an upstream + /// policy change lands here and 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(&entry.error_type), + is_already_submitted(&entry.error_type), + ); + if upstream != Some(shepherd) { + divergent.push(&entry.error_type); + } + } + divergent.sort_unstable(); + assert_eq!(divergent, RATIFIED); + } + + /// A non-Rust reader sees the same file as plain data: parsing it + /// with the untyped TOML value model (no Rust schema) exposes the + /// entries and their fields, proving 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/crates/cow-venue/src/classification_data.rs b/crates/cow-venue/src/classification_data.rs new file mode 100644 index 00000000..bb96cc3f --- /dev/null +++ b/crates/cow-venue/src/classification_data.rs @@ -0,0 +1,91 @@ +//! Parse and validate the shipped classification data. +//! +//! This module is the single source of the TOML schema and the table +//! invariants. It is compiled twice, never into a guest: `build.rs` +//! includes it to turn `data/classification.toml` into a generated +//! lookup table at build time, and the crate's own tests include it to +//! re-parse the same file and assert the generated table agrees. The +//! runtime `client` slice carries only the generated table, so no TOML +//! parser reaches the wasm 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/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs new file mode 100644 index 00000000..9dd86963 --- /dev/null +++ b/crates/cow-venue/src/client.rs @@ -0,0 +1,159 @@ +//! The CoW venue as a keeper types it. +//! +//! [`CowVenue`] names the venue once - the id its adapter registers +//! under and the [`CowIntentBody`] schema it decodes - so keeper code +//! drives it through [`VenueClient`] with typed bodies, never wire +//! bytes. The classification API +//! ([`classify`](crate::classification::classify)) travels in the same +//! slice so the client that submits an order and the table that +//! classifies its rejection version together. + +use alloc::string::String; + +use videre_sdk::client::{HostVenues, Venue, VenueClient, VenueId}; +use videre_sdk::keeper::submission_key; +use videre_sdk::{BodyError, IntentBody as _}; + +use crate::body::CowIntentBody; + +/// The CoW venue marker: every [`CowClient`] call routes to +/// [`Venue::ID`] and encodes a [`CowIntentBody`]. An accepted submit's +/// receipt is the canonical [`OrderUid`](crate::OrderUid) in wire form. +#[derive(Clone, Copy, Debug)] +pub struct CowVenue; + +impl Venue for CowVenue { + const ID: VenueId = VenueId::from_static("cow"); + type Body = CowIntentBody; +} + +/// A typed client pre-bound to the CoW venue: callers cannot mis-route +/// or submit a foreign body. +pub type CowClient = VenueClient; + +/// Deterministic intent-id for `body`: the sweep's +/// [`submission_key`] bound to [`CowVenue::ID`]. Derivable before any +/// network work, so a keeper journals the same key whether it submits +/// through the sweep or directly. +/// +/// The key covers the encoded body, so a signed payload +/// ([`CowIntent::Signed`](crate::CowIntent::Signed)) keys on its +/// signature: it scopes to that exact payload, not to the economic +/// order, which dedups only through the venue's duplicate response. +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::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 crate::body::CowIntent; + use crate::order::{BuyToken, OrderBody, SellToken}; + CowIntentBody::V1(CowIntent::Order( + OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1_700_000_000) + .app_data([0x44; 32]) + .partially_fillable() + .build(), + )) + } + + #[test] + fn intent_id_is_deterministic_and_body_scoped() { + 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 sweep journals", + ); + assert!(id.starts_with("cow:0x")); + + let other = CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1_700_000_000) + .build(), + owner: [0x55; 20], + 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); + videre_sdk::rt::complete(client.submit(&body)) + .expect("guest futures complete in one poll") + .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/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs new file mode 100644 index 00000000..990c4363 --- /dev/null +++ b/crates/cow-venue/src/lib.rs @@ -0,0 +1,86 @@ +//! # cow-venue +//! +//! The CoW venue, staged as a crate of feature slices: the orderbook +//! and nothing else. The default [`body`] slice carries the +//! venue-neutral order intent body types and the borsh `IntentBody` +//! codec over them; conditional-order keeper machinery lives in its +//! own crate and never here. +//! +//! The body slice is dependency-light on purpose. It links only the +//! venue SDK (for the [`IntentBody`](videre_sdk::IntentBody) derive) +//! and borsh, so a venue adapter component or a strategy module can carry +//! the body types and codec without dragging in the host-side CoW +//! machinery. The crate is `#![no_std]` (tests and the `adapter` slice +//! aside): the derive's generated code reaches `alloc` through the +//! venue SDK re-export, never `::std`. +//! +//! With `--no-default-features` the slice drops out entirely and the +//! crate compiles empty, so a consumer can depend on a single slice +//! without pulling the codec transitively. +//! +//! The `client` slice layers on top: a typed [`CowClient`] bound to the +//! CoW venue, the deterministic [`intent_id`] journal key, and the +//! table-driven retry [`classification`] generated at +//! build time from the shipped `data/classification.toml` (the TOML +//! parser stays a build-time dependency, off the guest). It links the +//! strategy keeper (for the retry action type) and is off by default, +//! so an adapter or a module that wants only the body types stays +//! dependency-light. +//! +//! The `assembly` slice carries the chain-edge order projections and +//! orderbook submission bodies; the `adapter` slice on top of it is +//! the venue-adapter component itself (`CowAdapter` under +//! `#[videre_sdk::venue]`), built as the cdylib for wasm32-wasip2 and +//! never linked by a keeper module (linking it would export the +//! adapter face). + +#![cfg_attr(not(any(test, feature = "adapter")), no_std)] +#![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")] +extern crate alloc; + +#[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/crates/cow-venue/src/order.rs b/crates/cow-venue/src/order.rs new file mode 100644 index 00000000..8bb1d725 --- /dev/null +++ b/crates/cow-venue/src/order.rs @@ -0,0 +1,468 @@ +//! The venue-neutral CoW order body. +//! +//! On the wire a CoW order is the 12-field `GPv2Order` tuple. The +//! host-side path speaks it through the on-chain alloy types; this body +//! type is the same shape reduced to plain wire primitives (byte arrays +//! for addresses and 256-bit amounts, small enums for the balance and +//! kind markers) so it borsh-encodes and links without the on-chain +//! stack. The one non-obvious invariant: `amount`, `receiver`, and the +//! marker enums are canonical wire forms, not on-chain keccak markers, +//! so the adapter, not this type, owns the projection to and from chain. + +use alloc::vec::Vec; +use core::fmt; +use core::marker::PhantomData; + +use borsh::{BorshDeserialize, BorshSerialize}; + +/// A 20-byte EVM address in wire form. +pub type Address = [u8; 20]; + +/// A 256-bit amount as its 32-byte big-endian representation. +pub type U256 = [u8; 32]; + +/// The token an order sells, typed so a builder call cannot swap +/// sides with the buy token. +#[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 so a builder call cannot swap sides +/// with the sell token. +#[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. +/// +/// `receiver` is `None` for the self-receive default the orderbook +/// normalises the zero address to; the adapter round-trips that +/// normalisation on the chain edge. +#[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 { + /// Start a sell order: `amount` of `token` is the fixed side. + #[must_use] + pub const fn sell(token: SellToken, amount: U256) -> OrderBuilder { + OrderBuilder::start(OrderKind::Sell, token.0, amount, [0; 20], [0; 32]) + } + + /// Start a buy order: `amount` of `token` is the fixed side. + #[must_use] + pub const fn buy(token: BuyToken, amount: U256) -> OrderBuilder { + OrderBuilder::start(OrderKind::Buy, [0; 20], [0; 32], token.0, amount) + } +} + +/// Builder state: the buy-side limit is unset. +pub enum NeedsBuy {} +/// Builder state: the sell-side limit is unset. +pub enum NeedsSell {} +/// Builder state: the expiry is unset. +pub enum NeedsValidTo {} +/// Builder state: every required field is set. +pub enum Ready {} + +/// Typestate builder for [`OrderBody`]: [`OrderBody::sell`] or +/// [`OrderBody::buy`] fixes the kind and its side, the counter-side +/// limit and the expiry are compile-time required, and the optionals +/// default (self-receive, zero `app_data` and `fee_amount`, +/// fill-or-kill, ERC-20 balances). +#[derive(Clone, Debug)] +pub struct OrderBuilder { + body: OrderBody, + state: PhantomData, +} + +impl OrderBuilder { + const fn start( + kind: OrderKind, + sell_token: Address, + sell_amount: U256, + buy_token: Address, + buy_amount: U256, + ) -> Self { + Self { + body: OrderBody { + sell_token, + buy_token, + receiver: None, + sell_amount, + buy_amount, + valid_to: 0, + app_data: [0; 32], + fee_amount: [0; 32], + kind, + partially_fillable: false, + sell_token_balance: SellTokenSource::Erc20, + buy_token_balance: BuyTokenDestination::Erc20, + }, + state: PhantomData, + } + } + + const fn into_state(self) -> OrderBuilder { + OrderBuilder { + body: self.body, + state: PhantomData, + } + } +} + +impl OrderBuilder { + /// Demand at least `amount` of `token` in return. + #[must_use] + pub const fn for_at_least( + mut self, + token: BuyToken, + amount: U256, + ) -> OrderBuilder { + self.body.buy_token = token.0; + self.body.buy_amount = amount; + self.into_state() + } +} + +impl OrderBuilder { + /// Spend at most `amount` of `token`. + #[must_use] + pub const fn for_at_most( + mut self, + token: SellToken, + amount: U256, + ) -> OrderBuilder { + self.body.sell_token = token.0; + self.body.sell_amount = amount; + self.into_state() + } +} + +impl OrderBuilder { + /// Expire at `valid_to` (Unix seconds). + #[must_use] + pub const fn valid_to(mut self, valid_to: u32) -> OrderBuilder { + self.body.valid_to = valid_to; + self.into_state() + } +} + +impl OrderBuilder { + /// 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: what a +/// conditional-order keeper emits after a poll. +#[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 order UID (order digest, owner, +/// `valid_to`) in wire form: the receipt bytes an accepted CoW submit +/// carries. +#[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: [0x11; 20], + buy_token: [0x22; 20], + receiver: Some([0x33; 20]), + sell_amount: { + let mut a = [0u8; 32]; + a[31] = 0x2a; + a + }, + buy_amount: [0xff; 32], + valid_to: 0xffff_ffff, + app_data: [0x44; 32], + fee_amount: [0u8; 32], + 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([0x11; 20]), sample().sell_amount) + .for_at_least(BuyToken([0x22; 20]), [0xff; 32]) + .valid_to(0xffff_ffff) + .receiver([0x33; 20]) + .app_data([0x44; 32]) + .build(); + assert_eq!(built, sample()); + } + + #[test] + fn buy_builder_fixes_the_buy_side() { + let built = OrderBody::buy(BuyToken([0x22; 20]), [0xff; 32]) + .for_at_most(SellToken([0x11; 20]), [0x01; 32]) + .valid_to(100) + .partially_fillable() + .sell_token_balance(SellTokenSource::External) + .buy_token_balance(BuyTokenDestination::Internal) + .fee_amount([0x05; 32]) + .build(); + assert_eq!(built.kind, OrderKind::Buy); + assert_eq!(built.sell_token, [0x11; 20]); + assert_eq!(built.buy_token, [0x22; 20]); + assert_eq!(built.sell_amount, [0x01; 32]); + assert_eq!(built.buy_amount, [0xff; 32]); + 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, [0x05; 32]); + assert_eq!(built.receiver, None); + } + + #[test] + fn builder_defaults_are_the_wire_defaults() { + let built = OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1) + .build(); + assert_eq!(built.receiver, None); + assert_eq!(built.app_data, [0; 32]); + assert_eq!(built.fee_amount, [0; 32]); + 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 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: [0x55; 20], + 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/crates/cow-venue/tests/conformance.rs b/crates/cow-venue/tests/conformance.rs new file mode 100644 index 00000000..4c176300 --- /dev/null +++ b/crates/cow-venue/tests/conformance.rs @@ -0,0 +1,29 @@ +//! 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 cow_venue::{CowAdapter, CowIntentBody}; +use videre_sdk::VenueAdapter; +use videre_test::{CodecVectors, HeaderGoldens}; + +fn fixture(name: &str) -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/vectors") + .join(name) +} + +#[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); +} diff --git a/crates/cow-venue/tests/vectors/cow-header-goldens.json b/crates/cow-venue/tests/vectors/cow-header-goldens.json new file mode 100644 index 00000000..d482f6a2 --- /dev/null +++ b/crates/cow-venue/tests/vectors/cow-header-goldens.json @@ -0,0 +1,60 @@ +{ + "version": 1, + "venue": "cow", + "goldens": [ + { + "name": "v1-order-presign", + "body": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "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": "00011111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000555555555555555555555555555555555555555503000000c0ffee", + "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/crates/cow-venue/tests/vectors/cow-intent-body.json b/crates/cow-venue/tests/vectors/cow-intent-body.json new file mode 100644 index 00000000..651231e7 --- /dev/null +++ b/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": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "expect": "round-trip" + }, + { + "name": "v1-signed", + "bytes": "00011111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000555555555555555555555555555555555555555503000000c0ffee", + "expect": "round-trip" + }, + { + "name": "unknown-version", + "bytes": "09001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "expect": { + "unknown-version": { + "version": 9 + } + } + }, + { + "name": "empty", + "bytes": "", + "expect": "empty" + }, + { + "name": "truncated-payload", + "bytes": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f1536544444444444444444444444444444444444444444444444444444444444444440000000000000000000000000000000000000000000000000000000000000000000000", + "expect": { + "malformed": { + "version": 0 + } + } + }, + { + "name": "trailing-bytes", + "bytes": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f15365444444444444444444444444444444444444444444444444444444444444444400000000000000000000000000000000000000000000000000000000000000000000000000", + "expect": { + "malformed": { + "version": 0 + } + } + } + ] +} diff --git a/crates/cow-venue/tests/wit_layering.rs b/crates/cow-venue/tests/wit_layering.rs new file mode 100644 index 00000000..4ccf59ed --- /dev/null +++ b/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/crates/nexum-cli/Cargo.toml b/crates/nexum-cli/Cargo.toml index cc727db9..05bab22d 100644 --- a/crates/nexum-cli/Cargo.toml +++ b/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/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 index f2ce5f46..c7be67c3 100644 --- a/crates/nexum-cli/src/main.rs +++ b/crates/nexum-cli/src/main.rs @@ -1,48 +1,11 @@ -#![cfg_attr(not(test), warn(unused_crate_dependencies))] - -mod cli; -mod launch; +//! The bare `nexum` engine binary: the core lattice with no extension +//! payload, composed over the generic launcher. -use clap::Parser; -use tracing::info; -use tracing_subscriber::EnvFilter; +#![cfg_attr(not(test), warn(unused_crate_dependencies))] -use crate::cli::Cli; -use nexum_runtime::engine_config; +use nexum_runtime::preset::CoreRuntime; #[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 + nexum_launch::run("nexum", CoreRuntime).await } diff --git a/crates/nexum-launch/Cargo.toml b/crates/nexum-launch/Cargo.toml new file mode 100644 index 00000000..aff18adc --- /dev/null +++ b/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/crates/nexum-launch/src/cli.rs b/crates/nexum-launch/src/cli.rs new file mode 100644 index 00000000..b9af9baa --- /dev/null +++ b/crates/nexum-launch/src/cli.rs @@ -0,0 +1,85 @@ +//! 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 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, +} + +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/crates/nexum-launch/src/lib.rs b/crates/nexum-launch/src/lib.rs new file mode 100644 index 00000000..6994ca29 --- /dev/null +++ b/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 (machine-readable +/// for production), the human-readable formatter behind `--pretty-logs`. +/// The same [`EnvFilter`] applies to both, so `RUST_LOG` works identically. +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/crates/nexum-module-macros/Cargo.toml b/crates/nexum-module-macros/Cargo.toml new file mode 100644 index 00000000..96c35dc9 --- /dev/null +++ b/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/crates/nexum-module-macros/src/lib.rs b/crates/nexum-module-macros/src/lib.rs new file mode 100644 index 00000000..9aa28bb0 --- /dev/null +++ b/crates/nexum-module-macros/src/lib.rs @@ -0,0 +1,280 @@ +//! Proc-macro glue for nexum runtime modules. +//! +//! [`module`] turns an `impl` block of named handlers into a complete +//! per-cdylib module: it emits the `wit_bindgen::generate!` call for a +//! per-module world derived from the crate's `module.toml` +//! `[capabilities]` declarations, the host adapter (via +//! `nexum_sdk::bind_host_via_wit_bindgen!`), the `Guest` implementation +//! whose `on-event` dispatches to the handlers present, and `export!`. +//! +//! The venue-side macros (`#[venue]`, `derive(IntentBody)`) live in +//! `videre-macros`. +//! +//! Consumers reach this through the SDK re-export (`nexum_sdk::module`) +//! rather than depending on this crate directly. + +use proc_macro::TokenStream; +use quote::quote; +use syn::{ImplItem, ItemImpl}; + +/// The handler names recognised on a `#[module]` impl. Any method not in +/// this set is left untouched on the type, except that names starting +/// with `on_` are rejected at compile time (a typo'd handler would +/// otherwise silently never fire); any handler in the set that is absent +/// is treated as a no-op in the generated `on-event` dispatch. +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 handler takes the wit-bindgen +/// payload for its event and returns `Result<(), Fault>`; `init` takes +/// the config table. +/// Handlers left undefined are ignored (their events become no-ops). The +/// macro emits `wit_bindgen::generate!`, the host adapter, the `Guest` +/// impl, and `export!` around the untouched impl. +/// +/// The world is per module, not shared: the macro reads the crate's +/// `module.toml` and synthesizes a world whose imports are exactly the +/// `[capabilities].required` and `optional` declarations, so the built +/// component imports what the manifest declares and nothing else - the +/// runtime's load-time capability check passes by construction instead +/// of relying on the toolchain eliding unused imports. Corollaries: the +/// manifest must sit at the crate root and carry a `[capabilities]` +/// section, an undeclared capability's bindings simply do not exist +/// (using one is a compile error, the cue to declare it), and only the +/// host-adapter pieces for declared capabilities are emitted. +/// +/// The other non-obvious invariant: the wit-bindgen output (`Guest`, +/// `Fault`, the `nexum::host::*` modules) lands at the module crate +/// root, so the emitted glue and the handler bodies resolve those names +/// there; the WIT package directories resolve against the crate's own +/// `wit/` and `wit/deps/`, then the nearest ancestor carrying the +/// package. Two corollaries: the consuming crate must +/// declare `wit-bindgen` as a direct dependency (the emitted +/// `wit_bindgen::generate!` call resolves against the consumer's +/// namespace), and the crate root must not shadow std prelude names +/// such as `Result`, `Vec`, or `Ok` (wit-bindgen's 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() +} + +/// Read the consuming crate's `module.toml` and synthesize the +/// per-module world from its `[capabilities]` declarations plus the +/// extension rows registered in the nearest ancestor `extensions.toml`. +/// Returns the rebuild anchor paths (the manifest, then the registry +/// when one exists) 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/crates/nexum-runtime/Cargo.toml index a4ddab3e..c867cbd3 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/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/crates/nexum-runtime/examples/embed.rs index c166e006..feba0440 100644 --- a/crates/nexum-runtime/examples/embed.rs +++ b/crates/nexum-runtime/examples/embed.rs @@ -3,9 +3,9 @@ //! //! [`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 +//! the Prometheus add-on. A domain capability 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 +//! returns its linker 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. diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index a48bebca..46fb2424 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -3,15 +3,23 @@ //! 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. +//! extensions 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. +//! +//! `nexum:host` is a leaf package: the host `event` variant carries a +//! status transition as opaque bytes, so the core world resolves against +//! `wit/nexum-host` alone. An extension's bindgen remaps onto the shared +//! interfaces here with `with`, so the `Host` impls and the `fault` type +//! its components see are the very 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/crates/nexum-runtime/src/bootstrap.rs index 532e5582..9ae57a94 100644 --- a/crates/nexum-runtime/src/bootstrap.rs +++ b/crates/nexum-runtime/src/bootstrap.rs @@ -3,13 +3,14 @@ //! //! 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 +//! domain extension) 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 //! [`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}; @@ -34,7 +35,7 @@ pub async fn run( 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/crates/nexum-runtime/src/builder.rs index be027497..0d551d00 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -9,27 +9,30 @@ //! loop - and returns a [`RuntimeHandle`] owning the manager and the //! running tasks. //! -//! The reference binary reaches this through its `run_from_config` one-liner; +//! The engine binaries reach this through the `nexum-launch` preset run; //! 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. +//! lattice, component builders, extensions, and add-ons in one call; +//! [`RuntimeBuilder::with_runtime`] binds a preset value carrying pre-built +//! backends. 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; @@ -127,8 +130,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 +202,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 +214,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 +223,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 +268,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 +311,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 +323,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 +333,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { &mut supervisor, block_streams, chain_log_streams, + extension_streams, reconnect_tasks, graceful.into_future(), ) @@ -351,39 +374,45 @@ 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 + /// Bind a [`Runtime`] preset by marker. Sugar over + /// [`with_runtime`](Self::with_runtime) for a `Default` preset: an /// embedder writes `RuntimeBuilder::new(cfg).runtime::().launch()`. - pub fn runtime(self) -> PresetBuilder<'a, R> { + pub fn runtime(self) -> PresetBuilder<'a, R> { + self.with_runtime(R::default()) + } + + /// Bind a [`Runtime`] preset by value, so a preset can carry pre-built + /// backends and extensions 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). +/// lattice, the component builders, its extensions, and the add-on set, +/// leaving only the optional extension hooks and 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 @@ -405,9 +434,38 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { self } + /// Override the preset's component builders before launch. `map` receives + /// the preset's [`ComponentsBuilder`] and returns the builders to open, so + /// an embedder swaps one seam (e.g. logs or store) while keeping the rest; + /// the preset's extensions and add-ons carry through unchanged. The preset + /// path's 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. Builds the [`Components`] bundle - /// from the preset's component builders, installs the preset's add-ons, - /// then drives [`LaunchRuntime::launch`] with a fresh [`TaskManager`]. + /// from the preset's component builders, gathers the preset's extensions + /// (appended ones after), installs the preset's add-ons, then drives + /// [`LaunchRuntime::launch`] with a fresh [`TaskManager`]. pub async fn launch(self) -> anyhow::Result { let tasks = TaskManager::new(); let executor = tasks.executor(); @@ -417,12 +475,70 @@ 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`]: the preset's extensions and add-ons are +/// already gathered, 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 +560,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 +568,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 } @@ -475,10 +594,10 @@ impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { } /// 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 +611,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,22 +641,23 @@ 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 @@ -572,9 +695,14 @@ 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; + 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; /// The preset shortcut is exercised at runtime, not just compiled: the /// component builders open the backends, the add-ons install, and the @@ -598,6 +726,260 @@ mod tests { assert!(err.to_string().contains("no modules to run"), "{err}"); } + /// Counts linker hook runs, so a test observes an extension reaching the + /// launch's linker build. + 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(), + })] + } + } + + /// The preset's own extensions and the appended ones both reach the + /// launch's linker build, each linked exactly once, before the boot + /// bails on the empty module set. + #[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() + } + } + + /// `components(self)` hands a pre-built instance through the preset seam: + /// the built bundle carries the exact pipeline the preset owned. + #[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, so the `with_components` tests + /// avoid the process-global Prometheus recorder the `CoreRuntime` preset + /// installs (only one such 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, so a test observes an overridden seam reaching the + /// launch's component build. + 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 on the preset path: the substituted + /// logs builder drives the launch's component build (once), which then + /// bails on the empty module set. Locks the preset escape hatch. + #[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 the logs seam overridden through + /// `with_components`: the run reads the substituted pipeline and the + /// trigger-to-wait handshake stops it. 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 = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates dir") + .parent() + .expect("repo 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("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"); + } + /// when every configured module fails `init`, launch must /// abort with an operator-facing error instead of idling behind an /// empty event loop. diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index 9305f640..c950cf1f 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -31,6 +31,103 @@ 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: the poll cadence 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, so a watch rides +/// out a provider outage of a couple of poll cadences before giving up. +pub const WATCH_GRACE_MULTIPLIER: u64 = 2; +/// Ceiling on the derived grace window, so a long `expiry` cannot stretch +/// the give-up deadline past a day. +pub const WATCH_GRACE_MAX: Duration = Duration::from_secs(86_400); + +/// The give-up window derived from `expiry`: `min(MULTIPLIER * expiry, MAX)`. +/// An explicit `grace_secs` in config overrides this. +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 installed providers. Both a +/// forwarded submission and a charged decode failure consume one unit; +/// the window slides so a caller's budget refills as old charges age out. +/// Resolved from `[limits.quota]`; the extension service that meters +/// callers consumes it. +#[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 { + /// Pair a budget 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. The cap bounds the per-cadence +/// poll fan-out; `grace` is the give-up deadline: how long a watch rides +/// out an unreachable provider before it is evicted unreported. `expiry` +/// is the base window `grace` 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 it is evicted unreported. A reachable poll (the provider + /// answered) resets it; a resolve failure or an errored poll rides out + /// against it. Derived `min(MULTIPLIER * expiry, MAX)` 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` window, the path + /// a configured `grace_secs` takes. + 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 @@ -85,6 +182,14 @@ pub struct EngineConfig { /// `docs/03-module-discovery.md`. #[serde(default)] pub modules: Vec, + /// Provider components the supervisor should boot alongside the + /// modules. Each entry resolves a `(component.wasm, module.toml)` pair + /// like a module, but the operator scopes its transport here rather + /// than in the provider's own manifest: the installer of a provider, + /// not its author, decides which hosts and messaging topics it may + /// reach. + #[serde(default)] + pub adapters: Vec, } /// One `[[modules]]` table from `engine.toml`. @@ -101,6 +206,33 @@ pub struct ModuleEntry { pub manifest: Option, } +/// One `[[adapters]]` table from `engine.toml`. +/// +/// `path` and `manifest` mirror [`ModuleEntry`]; `manifest` defaults to a +/// sibling `module.toml`. The two scope fields are the operator's grant of +/// the adapter's transport: `http_allow` is the outbound HTTP host +/// allowlist the adapter's wasi:http gate enforces, and `messaging_topics` +/// scopes the messaging content topics it may publish to. Both default +/// empty; an empty `http_allow` denies every outbound request, and an +/// empty `messaging_topics` leaves messaging unscoped for parity with the +/// module default (the messaging backend itself is deferred). +#[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 granted to this adapter. Each entry is + /// either an exact hostname or a `*.suffix` wildcard, matched the same + /// way as a module's `[capabilities.http].allow`. + #[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")] @@ -222,6 +354,12 @@ const DEFAULT_HTTP_TOTAL_DEADLINE: Duration = Duration::from_secs(60); /// guest heap that has to buffer it. const DEFAULT_HTTP_RESPONSE_BODY_MAX: u64 = 16 * 1024 * 1024; +/// Default cap on one chain JSON-RPC response body (1 MiB). Large enough +/// for typical read responses (receipts, log batches, contract state), +/// while preventing a misbehaving or adversarial node from filling the +/// guest heap via a single large reply. +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; @@ -242,6 +380,11 @@ const DEFAULT_LOG_BYTES_PER_RUN: usize = 256 * 1024; /// history for diagnosis without unbounded growth. const DEFAULT_LOG_RUNS_RETAINED: usize = 16; +/// Default cadence for provider status polling (5 s). Fast enough that a +/// settling submission is observed within a block time or two, slow +/// enough that per-receipt provider calls stay negligible. +const DEFAULT_STATUS_POLL_INTERVAL: Duration = Duration::from_secs(5); + /// Saturate an operator-supplied millisecond knob into [1 ms, 24 h]: /// zero would fail every request instantly, and huge values overflow /// timer arithmetic. @@ -266,6 +409,9 @@ fn clamp_http_ms(ms: u64) -> Duration { /// total_deadline_ms = 60_000 /// response_body_max_bytes = 16_777_216 /// +/// [limits.chain] +/// response_body_max_bytes = 1_048_576 +/// /// [limits.logs] /// bytes_per_run = 262_144 /// runs_retained = 16 @@ -292,12 +438,24 @@ pub struct ModuleLimits { /// 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,6 +472,17 @@ impl ModuleLimits { self.memory_bytes.unwrap_or(DEFAULT_MEMORY_LIMIT) } + /// Resolved chain response size cap (override or default). A + /// degenerate `0` saturates to 1 byte, matching the `logs` / + /// `poison` sections' zero handling, so resolution never yields a + /// cap that rejects even an empty body. + 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) @@ -406,6 +575,54 @@ impl ModuleLimits { .unwrap_or(DEFAULT_DISPATCH_REFILL_PER_SEC), ) } + + /// Resolved status-poll cadence (override or default). A zero interval + /// saturates up to 1 ms so a misconfigured cadence busy-loops a poll + /// task instead of dividing by zero timer arithmetic. + 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 (overrides or defaults). A zero + /// `max_charges` is saturated up to 1 by the consuming service, so a + /// misconfigured budget still admits one submission rather than + /// bricking every provider. + 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 (overrides or defaults). A zero + /// `max_entries` saturates up to 1 and a zero `expiry_secs` up to 1 s, + /// so a misconfigured bound still watches one receipt briefly rather + /// than nothing at all. `grace_secs` overrides the give-up deadline; + /// omitted, 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; @@ -431,6 +648,20 @@ pub struct HttpLimitsSection { pub response_body_max_bytes: Option, } +/// `[limits.chain]` chain JSON-RPC response size limit. Optional; +/// omitted values resolve to the built-in 1 MiB default. +/// +/// ```toml +/// [limits.chain] +/// response_body_max_bytes = 1_048_576 +/// ``` +#[derive(Debug, Default, Deserialize)] +pub struct ChainLimitsSection { + /// Cap on one chain JSON-RPC response body, in bytes. Named for + /// symmetry with `[limits.http].response_body_max_bytes`. + pub response_body_max_bytes: Option, +} + /// Resolved outbound HTTP limits the wasi:http gate enforces per /// request. Built by [`ModuleLimits::http`]. #[derive(Debug, Clone, Copy)] @@ -479,6 +710,54 @@ pub struct PoisonLimitsSection { pub window_secs: Option, } +/// `[limits.quota]` per-caller provider submission budget. Both optional; +/// omitted values resolve to the defaults via [`ModuleLimits::quota`]. +/// +/// A caller (a strategy module, keyed by its namespace) may accrue at most +/// `max_charges` submissions within a sliding `window_secs`; a decode failure +/// charged back to the caller counts the same, so a module feeding garbage +/// bodies exhausts its own budget rather than the provider's fuel. +#[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. Optional; an +/// omitted value resolves to the built-in default and a degenerate zero +/// saturates up to 1 ms via [`ModuleLimits::status_poll_interval`]. +/// +/// The cadence is how often the consuming service polls each installed +/// provider's `status` export for the receipts it watches; only observed +/// transitions fan out as events. +#[derive(Debug, Default, Deserialize)] +pub struct StatusPollSection { + /// Milliseconds between status poll sweeps. + pub interval_ms: Option, +} + +/// `[limits.watch]` status-watch set bounds. Both optional; omitted +/// values resolve to the defaults via [`ModuleLimits::watch`] and +/// degenerate zeroes saturate up to a usable minimum. +/// +/// The consuming service watches each accepted receipt until a terminal +/// status: the cap bounds the per-cadence poll fan-out, and the give-up +/// deadline evicts a watch whose provider stays unreachable. 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`]. @@ -753,6 +1032,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 +1176,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 +1255,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( diff --git a/crates/nexum-runtime/src/host/actor.rs b/crates/nexum-runtime/src/host/actor.rs new file mode 100644 index 00000000..c10f8e0a --- /dev/null +++ b/crates/nexum-runtime/src/host/actor.rs @@ -0,0 +1,108 @@ +//! The supervised host-actor primitive: one component instance the host +//! holds and others call. The store is refuelled before each guest call, +//! a trap is projected onto a typed fault instead of unwinding into the +//! caller, and each instance sits behind an [`ActorSlot`] async mutex held +//! across the guest await, so one store never runs two guest calls 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. A wasmtime `Store` +/// is not `Sync`; concurrent callers queue here. +pub type ActorSlot = Arc>; + +/// Shared liveness of one supervised component. The store marks it dead on +/// a trap, recording when, so the supervisor's restart sweep can count the +/// backoff from the death rather than from the sweep that observed it. +/// Cloning 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 the component dead: its store trapped and is unusable. Keeps + /// the first death instant when 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: the state is a bare + /// `Option`, valid under any interleaving. + 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; the wasm frame + /// list stays out of the caller-facing message. + #[error("trapped: {}", .0.root_cause())] + Trap(wasmtime::Error), +} + +/// A supervised component store: refuelled before each guest call so every +/// invocation starts from a full budget, with traps projected onto +/// [`ActorFault`] and recorded on the shared [`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 against the store. A trap marks the + /// shared liveness dead: the store is poisoned 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/crates/nexum-runtime/src/host/component/builder.rs index 705896bf..6b4111cc 100644 --- a/crates/nexum-runtime/src/host/component/builder.rs +++ b/crates/nexum-runtime/src/host/component/builder.rs @@ -3,8 +3,9 @@ //! //! 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. +//! payload and the log pipeline) into a [`Components`] bundle. The +//! composition root names the concrete builders once; boot drives them +//! through this trait. use std::future::Future; use std::path::Path; @@ -81,10 +82,23 @@ impl ComponentBuilder for LocalStoreBuilder { } } +/// 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. The leaf cause stays an /// `anyhow::Error` because the backends fail for heterogeneous reasons /// (I/O for the store, network for the chain). #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum BuildError { /// The chain backend builder failed. #[error("build the chain backend: {0}")] @@ -95,6 +109,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 +124,62 @@ 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 backend builders, the lattice `Ext` builder, and the +/// log pipeline builder into a [`Components`] bundle. The logs slot defaults +/// to [`LogPipelineBuilder`]; the embedder retains the read handle by +/// cloning [`Components::logs`] after the build. +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. The + /// builder outputs must match the lattice seams: chain to + /// [`RuntimeTypes::Chain`], store to [`RuntimeTypes::Store`], ext to + /// [`RuntimeTypes::Ext`]; logs always yields a [`LogPipeline`]. A + /// failing sub-build returns the [`BuildError`] variant 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, @@ -189,4 +227,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/crates/nexum-runtime/src/host/component/chain.rs b/crates/nexum-runtime/src/host/component/chain.rs index 072ebfe8..294eba71 100644 --- a/crates/nexum-runtime/src/host/component/chain.rs +++ b/crates/nexum-runtime/src/host/component/chain.rs @@ -5,66 +5,13 @@ 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() - } -} +/// The read surface is defined once in `nexum-world`; host and guest +/// re-export the same type, so the dispatch table and the guest +/// allowlist cannot drift. +pub use nexum_world::ChainMethod; /// Async chain backend. Methods mirror [`ProviderPool`] one-to-one; /// the `impl Future + Send` form bakes in the Send bound generic @@ -133,51 +80,3 @@ impl ChainProvider for ProviderPool { 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/mod.rs b/crates/nexum-runtime/src/host/component/mod.rs index 0adb15fa..fd9d24e1 100644 --- a/crates/nexum-runtime/src/host/component/mod.rs +++ b/crates/nexum-runtime/src/host/component/mod.rs @@ -11,7 +11,7 @@ mod state; pub use builder::{ BuildError, BuilderContext, ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, - ProviderPoolBuilder, + LogPipelineBuilder, ProviderPoolBuilder, }; pub use chain::{ChainMethod, ChainProvider}; pub use runtime_types::{Handle, RuntimeTypes}; @@ -52,6 +52,8 @@ mod tests { #[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/crates/nexum-runtime/src/host/component/runtime_types.rs b/crates/nexum-runtime/src/host/component/runtime_types.rs index a96cbeb1..33741499 100644 --- a/crates/nexum-runtime/src/host/component/runtime_types.rs +++ b/crates/nexum-runtime/src/host/component/runtime_types.rs @@ -5,7 +5,7 @@ //! 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 +//! Domain backends are not core seams: they live behind //! the [`RuntimeTypes::Ext`] slot and are wired in as extensions. use crate::host::component::{ChainProvider, StateStore}; @@ -13,7 +13,9 @@ 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 { +/// +/// Sealed: a lattice opts in by also implementing the sealing marker. +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. diff --git a/crates/nexum-runtime/src/host/component/state.rs b/crates/nexum-runtime/src/host/component/state.rs index b3213f7f..635ca0b1 100644 --- a/crates/nexum-runtime/src/host/component/state.rs +++ b/crates/nexum-runtime/src/host/component/state.rs @@ -29,6 +29,21 @@ 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. Default fetches the value; a backend + /// overrides when it can answer without. + fn contains(&self, key: &str) -> Result { + Ok(self.get(key)?.is_some()) + } + /// Value byte length, `Ok(None)` when absent. Default fetches the + /// value; on some backends this may be a scan. + fn len(&self, key: &str) -> Result, StorageError> { + Ok(self.get(key)?.map(|v| v.len() as u64)) + } + /// Number of keys starting with `prefix`. Default materialises the + /// key list; on some backends this may be a scan. + fn count(&self, prefix: &str) -> Result { + Ok(self.list_keys(prefix)?.len() as u64) + } } impl StateStore for LocalStore { @@ -59,4 +74,16 @@ 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) + } } diff --git a/crates/nexum-runtime/src/host/error.rs b/crates/nexum-runtime/src/host/error.rs index 8bdb4f9b..e31f803c 100644 --- a/crates/nexum-runtime/src/host/error.rs +++ b/crates/nexum-runtime/src/host/error.rs @@ -15,32 +15,40 @@ pub(crate) fn chain_denied(detail: impl Into) -> ChainError { } /// 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 { +/// structured-log `kind` field. Emitted from the single-source +/// [`nexum_world::FaultLabel`] vocabulary the SDK `HostFault::label` +/// mirrors. +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 { +/// field. The bindgen `Display` is the `{0:?}` debug form, so operator +/// logs render through this instead. The payload-bearing cases carry +/// their own detail; a rate limit keeps its `retry-after-ms` hint; +/// `timeout` renders a fixed phrase. +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"), } } @@ -49,7 +57,7 @@ pub(crate) fn fault_message(fault: &Fault) -> &str { /// 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 +/// so an SDK revert classifier can dispatch the revert /// envelopes. Everything else - transport failures, an unknown chain, /// bad params - becomes a shared [`Fault`]. impl From for ChainError { diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index f5b1b806..9d097fb0 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -1,39 +1,367 @@ -//! The extension seam: a linker hook plus the capability namespace an -//! extension contributes, assembled at the composition root and threaded -//! into every module linker. +//! The extension seam: what one extension contributes to the host - a +//! namespace, a capability namespace, a linker hook, an optional host +//! service, an optional provider kind, optional event sources, and +//! optional install predicates over the manifest sections it claims. +//! Assembled at the composition root and threaded into every module +//! linker. +use std::any::Any; +use std::collections::{BTreeMap, BTreeSet}; +use std::pin::Pin; use std::sync::Arc; -use wasmtime::component::Linker; +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::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 { +use crate::manifest::{ExtensionSections, NamespaceCaps}; + +/// One runtime extension. A module that imports an extension interface +/// boots only if the linker entry AND the capability namespace are both +/// registered before instantiation. +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 so a module importing + /// the extension's interfaces still validates. + fn capabilities(&self) -> NamespaceCaps; + + /// Adds the extension's imports to a worker 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 serializes access to the non-`Sync` wasmtime `Store`. + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; + + /// Host service this extension owns, published under its namespace on + /// [`HostServices`]. + fn service(&self) -> Option> { + None + } + + /// Provider kind this extension installs. + fn provider(&self) -> Option>> { + None + } + + /// Manifest section names this extension claims. A non-core section + /// no wired extension claims is refused at boot. + fn manifest_sections(&self) -> &'static [&'static str] { + &[] + } + + /// Admit one provider at install, over its opaque manifest sections. + /// Runs before compilation; an `Err` refuses the install fail-fast. + fn admit_provider(&self, provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + let _ = (provider, sections); + Ok(()) + } + + /// Admit one worker at install, over its own and the loaded + /// providers' opaque manifest sections. Runs before compilation; an + /// `Err` refuses the install fail-fast. + fn admit_worker( + &self, + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], + ) -> anyhow::Result<()> { + let _ = (worker, sections, providers); + Ok(()) + } + + /// Manifest subscription kinds this extension's event sources emit. + /// A `[[subscription]]` entry of any other non-core kind is refused + /// at boot. + fn subscriptions(&self) -> &'static [&'static str] { + &[] + } + + /// Open the extension's event sources once the engine is booted. The + /// event loop merges the returned streams and dispatches each item to + /// the modules its kind and attributes admit. + fn events(&self, sources: &mut EventSources<'_>) -> anyhow::Result> { + let _ = sources; + Ok(Vec::new()) + } +} + +/// One extension-observed event: dispatched to every module holding a +/// `[[subscription]]` of `kind` whose filters all 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>>; + +/// Ambient launch inputs for [`Extension::events`]: the loaded config, the +/// booted service map, the subscription kinds at least one module declares, +/// and the spawn surface for source tasks. +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 { - link: Arc::clone(&self.link), - capabilities: self.capabilities, + config, + services, + subscribed, + executor, + tasks, } } + + /// Spawn one event-source task through the engine's executor. The task + /// must end when its stream's receiver drops; the engine drains it on + /// shutdown. + pub fn spawn(&mut self, task: impl Future + Send + 'static) { + self.tasks.push(self.executor.spawn(async move { + task.await; + TaskExit::ReceiverGone + })); + } +} + +/// A type-erased host service an extension owns. Held per namespace on +/// `HostState::services` and downcast at the call site. Kept synchronous +/// so it stays `dyn`-compatible. +pub trait HostService: Any + Send + Sync + 'static {} + +/// A provider component kind: the host holds an instance behind the owning +/// extension's serialized service; others call it. `async_trait` carries +/// the one cold `dyn` boot path until `async_fn_in_dyn_trait` stabilizes. +#[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 one provider and install it behind the owning service. + /// [`Installed::Dead`] reports a failed guest `init`; an `Err` is a + /// boot error. + async fn install( + &self, + instance: ProviderInstance<'_, T>, + service: &Arc, + ) -> anyhow::Result; +} + +/// One provider instance ready to install: the compiled component, the +/// linker the kind's [`ProviderKind::link`] populated, the supervised +/// store, the manifest `[config]` and extension sections, and the +/// per-call fuel budget. +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, so a kind can + /// hold the instance to its manifest claims at install. + pub sections: &'a ExtensionSections, + /// Fuel budget applied before each routed guest call. + pub fuel_per_call: u64, + /// Shared liveness the installed instance reports traps on and the + /// supervisor's restart sweep reads. + pub liveness: Liveness, +} + +/// One loaded provider as [`Extension::admit_worker`] sees it: its +/// namespace, registered kind, and opaque manifest sections. Manifest +/// data only, so the predicate is static and liveness-independent. +#[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: each extension's [`HostService`] +/// under its [`Extension::namespace`], built once at boot and shared by +/// every module store. +#[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 namespace. + 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))) + } + + /// The service under `namespace`, downcast to its concrete type. + /// `None` when the namespace is absent or the type does not match. + 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. The boot + /// path seeds a service no extension registers yet. + 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/crates/nexum-runtime/src/host/http.rs index ec74e6c8..2624457e 100644 --- a/crates/nexum-runtime/src/host/http.rs +++ b/crates/nexum-runtime/src/host/http.rs @@ -268,26 +268,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 +328,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,13 +351,16 @@ 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) --------- @@ -449,14 +482,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 +497,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 +524,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"); }; diff --git a/crates/nexum-runtime/src/host/impls/chain.rs b/crates/nexum-runtime/src/host/impls/chain.rs index 3fb67286..f9afad2a 100644 --- a/crates/nexum-runtime/src/host/impls/chain.rs +++ b/crates/nexum-runtime/src/host/impls/chain.rs @@ -24,6 +24,40 @@ fn resolve_method(method: &str) -> Result { }) } +/// Return an error if `body` exceeds `cap` bytes. The check is applied +/// host-side before the response is copied into the guest, so an +/// oversized node response cannot saturate the guest heap. +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 +91,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!( @@ -90,10 +128,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)), } } @@ -283,4 +355,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/local_store.rs b/crates/nexum-runtime/src/host/impls/local_store.rs index 32a17f09..e5abc7c2 100644 --- a/crates/nexum-runtime/src/host/impls/local_store.rs +++ b/crates/nexum-runtime/src/host/impls/local_store.rs @@ -21,4 +21,16 @@ impl nexum::host::local_store::Host for HostState { 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) + } } diff --git a/crates/nexum-runtime/src/host/impls/messaging.rs b/crates/nexum-runtime/src/host/impls/messaging.rs index f2ff87c2..4cf45da5 100644 --- a/crates/nexum-runtime/src/host/impls/messaging.rs +++ b/crates/nexum-runtime/src/host/impls/messaging.rs @@ -1,13 +1,43 @@ -//! `nexum:host/messaging`: deferred to 0.3 (Waku backend). `query` -//! returns an empty result, same posture as `identity::accounts`. +//! `nexum:host/messaging`: the Waku backend is deferred to 0.3, so +//! `publish` reports `unsupported` and `query` returns empty, the same +//! posture as `identity::accounts`. The per-store topic scope is enforced +//! ahead of that stub: a provider carrying a +//! `[[adapters]].messaging_topics` grant may only publish within it, so +//! the egress boundary is live even though delivery is not. 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`. An empty scope is unscoped and +/// admits every topic (the module default); otherwise a topic is admitted +/// when it equals a scope entry or descends from one read as a path prefix +/// (`/nexum/1/` scopes the whole family beneath it). The prefix boundary is +/// the `/` path separator, so a grant never leaks into a longer sibling +/// segment (`/nexum/1/acme` does not admit `/nexum/1/acme-orders/...`). +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> { + 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())) } @@ -21,3 +51,39 @@ impl nexum::host::messaging::Host for HostState { 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/crates/nexum-runtime/src/host/local_store_redb.rs b/crates/nexum-runtime/src/host/local_store_redb.rs index eec7c4ef..8e2d95e4 100644 --- a/crates/nexum-runtime/src/host/local_store_redb.rs +++ b/crates/nexum-runtime/src/host/local_store_redb.rs @@ -49,7 +49,7 @@ pub struct ModuleStore { } impl LocalStore { - /// Open (or create) the redb file at `path`. Materialises the shared + /// Open (or create) the redb file at `path`. Initialises the shared /// table so subsequent read transactions never hit `TableDoesNotExist`. pub fn open(path: impl AsRef) -> Result { let db = Database::create(path).map_err(StorageError::Open)?; @@ -105,6 +105,49 @@ impl ModuleStore { Ok(value) } + /// 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. Reads the + /// entry's length in place; the value bytes are never copied out. + 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`. A bounded + /// B-tree range scan: no key strings are materialised. + 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. Under a quota, charges on-disk cost (prefix, key, /// value, overhead) and rejects an over-quota write untouched. The commit /// is fsync-durable. @@ -242,6 +285,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), diff --git a/crates/nexum-runtime/src/host/local_store_redb/tests.rs b/crates/nexum-runtime/src/host/local_store_redb/tests.rs index 1191d8d1..3e1fd159 100644 --- a/crates/nexum-runtime/src/host/local_store_redb/tests.rs +++ b/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(); diff --git a/crates/nexum-runtime/src/host/logs/mod.rs b/crates/nexum-runtime/src/host/logs/mod.rs index 74e0760f..b619bf3e 100644 --- a/crates/nexum-runtime/src/host/logs/mod.rs +++ b/crates/nexum-runtime/src/host/logs/mod.rs @@ -61,6 +61,7 @@ impl RunId { /// `source` field on the host tracing event. #[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, diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs index 66e4121e..a2842435 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -15,15 +15,19 @@ //! - `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. +//! - [`extension`]: the extension seam (linker hook, capability +//! namespace, service, provider kind, event sources) an extension is +//! wired in through at the composition root. Domain extensions live in +//! their own crates and plug in through this seam rather than being +//! hard-linked into the core host. +//! - [`actor`]: the supervised host-actor primitive provider instances +//! run behind (refuel, trap projection, serialising slot). //! - [`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 actor; pub mod component; pub mod error; pub mod extension; diff --git a/crates/nexum-runtime/src/host/provider_pool.rs b/crates/nexum-runtime/src/host/provider_pool.rs index 23230d4f..55d5e912 100644 --- a/crates/nexum-runtime/src/host/provider_pool.rs +++ b/crates/nexum-runtime/src/host/provider_pool.rs @@ -428,7 +428,7 @@ pub enum ProviderError { /// 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 + /// an SDK revert decoder). `None` when the failure /// was transport-level or the payload was not a hex string. data: Option>, /// Transport-side typed error. diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index 32723fb8..2023baf4 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -11,6 +11,7 @@ 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}; @@ -27,6 +28,12 @@ pub struct HostState { /// Per-module allowlist gate every wasi:http outgoing request /// passes through. pub http_gate: HttpGate, + /// Messaging content topics this store may publish to. Empty means + /// unscoped (the module default and current messaging posture); a + /// provider carries its `[[adapters]].messaging_topics` grant here, + /// so an out-of-scope publish is refused before it reaches the + /// backend. + pub messaging_topics: Vec, /// 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. @@ -38,9 +45,16 @@ pub struct HostState { pub ext: T::Ext, /// `chain` backend - per-chain alloy `DynProvider` pool. pub chain: T::Chain, + /// Host-enforced cap on a single chain JSON-RPC response body. + /// Responses larger than this are rejected before they reach the guest. + pub chain_response_max_bytes: usize, /// `local-store` backend - per-module handle with pre-computed /// keccak256 namespace prefix. pub store: Handle, + /// Extension-owned host services, keyed by extension namespace and + /// downcast at the call site. One shared map across every module store; + /// a provider store carries an empty map. + pub services: HostServices, } // `WasiView: Send`, so the backends must be `Send` too; the lattice diff --git a/crates/nexum-runtime/src/lib.rs b/crates/nexum-runtime/src/lib.rs index 1d200b90..4a29dd47 100644 --- a/crates/nexum-runtime/src/lib.rs +++ b/crates/nexum-runtime/src/lib.rs @@ -1,6 +1,11 @@ //! 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. +//! +//! Zero-leak charter: this crate is settlement-domain-agnostic. It +//! carries no domain symbol or WIT reference, `nexum:host` stays a +//! leaf WIT package, and no crate edge reaches a domain crate. The +//! zero-leak script under `scripts/` enforces this in CI. #![cfg_attr(not(test), warn(unused_crate_dependencies))] @@ -12,6 +17,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/crates/nexum-runtime/src/manifest/capabilities.rs index a47b3f6a..12b3a53a 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -6,6 +6,12 @@ //! the composition root via [`CapabilityRegistry::register`]. An extension //! interface is enforceable only once its namespace is registered. //! +//! Components built through `#[nexum_sdk::module]` compile against a +//! per-module world derived from the same manifest, so their imports +//! equal their declarations by construction and this check is a pure +//! backstop for them; it retains its teeth for components built against +//! a wider world by hand, where nothing upstream narrows the imports. +//! //! 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 @@ -18,7 +24,7 @@ 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/`). +/// own. #[derive(Clone, Copy)] pub struct NamespaceCaps { /// Interface-name prefix, e.g. `"nexum:host/"`. @@ -33,6 +39,25 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: CORE_CAPABILITIES, }; +/// The interfaces a provider world links: the scoped transport only. A +/// provider has no local-store, remote-store, identity, or logging - it +/// moves bytes to and from its counterparty and nothing else. `http` is +/// not listed here for the same reason it is not in the core set: it +/// gates `wasi:http/*` and is handled by the registry directly. +pub const PROVIDER_CAPABILITIES: &[&str] = &[ + nexum_world::Cap::Chain.as_str(), + nexum_world::Cap::Messaging.as_str(), +]; + +/// The provider namespace: the same `nexum:host/` prefix as core but only +/// the scoped-transport interfaces. Validating a provider manifest against +/// a registry built from this namespace rejects a declaration of any core +/// interface a provider must not reach (e.g. `local-store`) 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 /// (outgoing-handler, types, ...) is gated by the single /// [`HTTP_CAPABILITY`] declaration. @@ -40,7 +65,7 @@ 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`, @@ -91,13 +116,24 @@ 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: only the scoped + /// transport interfaces plus `http`. A provider manifest that declares + /// a core-only capability (e.g. `local-store`) fails as unknown here, + /// and the provider linker withholds the same interfaces so the + /// component cannot instantiate against them either. + 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); @@ -132,11 +168,11 @@ impl CapabilityRegistry { /// manifest declaration. /// /// Examples: - /// - `"nexum:host/chain@0.2.0"` -> `Some("chain")` - /// - `"shepherd:cow/cow-api@0.2.0"` -> `Some("cow-api")` once the cow + /// - `"nexum:host/chain@0.1.0"` -> `Some("chain")` + /// - `"test:acme/acme-api@0.1.0"` -> `Some("acme-api")` once that /// namespace is registered /// - `"wasi:http/outgoing-handler@0.2.12"` -> `Some("http")` - /// - `"nexum:host/types@0.2.0"` -> `None` (type-only, not a capability) + /// - `"nexum:host/types@0.1.0"` -> `None` (type-only, not a capability) /// - `"wasi:io/streams@0.2.0"` -> `None` 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); @@ -222,13 +258,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 +272,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 +303,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 +350,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 +372,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 +393,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 +402,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 +414,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 +475,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 +505,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 +524,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 +534,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 +547,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 +562,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/crates/nexum-runtime/src/manifest/error.rs index 73e1ac4f..ed5858c7 100644 --- a/crates/nexum-runtime/src/manifest/error.rs +++ b/crates/nexum-runtime/src/manifest/error.rs @@ -44,13 +44,14 @@ pub struct CapabilityViolation { /// Capability name (e.g. `"remote-store"`). pub capability: String, /// Full WIT import name as it appeared in the component (e.g. - /// `"nexum:host/remote-store@0.2.0"`). + /// `"nexum:host/remote-store@0.1.0"`). pub wit_import: String, } /// Error returned when 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/crates/nexum-runtime/src/manifest/load.rs index af9f148c..a7a70e98 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -172,30 +172,103 @@ 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: the runtime carries it verbatim and ascribes it no meaning. + #[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 +343,55 @@ 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; boot refuses it + /// against the registered kinds, where the valid set is known. + #[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 +444,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 +455,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/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs index fd484935..da7e0c06 100644 --- a/crates/nexum-runtime/src/manifest/mod.rs +++ b/crates/nexum-runtime/src/manifest/mod.rs @@ -35,7 +35,8 @@ 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}; +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 diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index 88feea78..1960dc61 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -4,23 +4,20 @@ //! and validation logic lives in [`mod@super::load`]; capability enforcement //! in [`super::capabilities`]. +use std::collections::BTreeMap; +use std::fmt; + use serde::Deserialize; +use serde::de::Error as _; /// Core capability names: the `nexum:host` interfaces the `event-module` -/// world links into every module linker. The `http` capability is not a +/// world links into every module linker, emitted from the +/// `nexum-world` capability table. 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", -]; +/// separately by the registry. Domain-extension capabilities are not +/// listed here; each extension contributes its own namespace to the +/// [`super::capabilities::CapabilityRegistry`] at the composition root. +pub const CORE_CAPABILITIES: &[&str] = &nexum_world::CORE_IFACES; #[derive(Debug, Deserialize, Default)] pub struct Manifest { @@ -36,15 +33,26 @@ pub struct Manifest { /// parsed and ignored (deferred to 0.3). #[serde(default, rename = "subscription")] pub subscriptions: Vec, + /// Extension-owned sections: every non-core top-level key, parsed + /// opaquely. The runtime ascribes them no meaning; it routes them + /// to the wired extensions' install predicates, and 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 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")] +/// validated per-kind by the supervisor. A kind outside the core set +/// parses as [`Subscription::Extension`] and is validated at boot +/// against the kinds the wired extensions declare, so a typo still +/// fails loudly rather than silently disabling an event source. +#[derive(Debug, Clone)] pub enum Subscription { /// New-block events. Fan-out is shared per chain - the /// supervisor opens one subscription per chain id and routes to @@ -57,31 +65,26 @@ pub enum Subscription { /// 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 + /// tolerate redelivery (the keeper 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 @@ -93,6 +96,94 @@ pub enum Subscription { #[allow(dead_code)] schedule: String, }, + /// An extension-owned event kind. Every non-`kind` key is a string + /// filter matched against the event's routing attributes: an event + /// is delivered when its 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, + }, +} + +/// The core subscription kinds, parsed by shape. Any other kind falls +/// 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)] @@ -104,12 +195,55 @@ pub struct ModuleSection { pub version: String, #[serde(default)] pub component: String, + /// Which component kind this manifest describes. Defaults to the + /// worker kind (`event-module`) so every existing `module.toml` keeps + /// its meaning; a provider names its registered kind. The supervisor + /// resolves the boot path from this discriminator. + #[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"; + +/// The component kind a manifest declares: the core worker kind, or the +/// manifest spelling of a provider kind an extension registers. Defaults +/// to the worker so every manifest written before providers existed keeps +/// its meaning; an unregistered provider spelling is refused at boot, +/// where the registered kinds are known. +#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)] +#[serde(from = "String")] +pub enum ComponentKind { + /// Event-driven worker over the six core primitives (`event-module`). + #[default] + Worker, + /// A provider the host holds behind a serialised actor, 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 layered over the engine `[limits]` /// defaults. Every field is optional; an unset field keeps the default. #[derive(Debug, Deserialize, Default)] diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs index 05556ee3..5a0b811c 100644 --- a/crates/nexum-runtime/src/preset.rs +++ b/crates/nexum-runtime/src/preset.rs @@ -1,24 +1,38 @@ -//! Runtime presets: a preset names a lattice, its component builders, and its -//! add-on set as one bundle, so an embedder launches with +//! Runtime presets: a preset names a lattice, its component builders, its +//! extensions, 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. +//! each seam. 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: 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 and returning its linker extensions. + +use std::sync::Arc; use crate::addons::{AddOns, PrometheusAddOn}; +use crate::engine_config::EngineConfig; use crate::host::component::{ - ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, ProviderPoolBuilder, RuntimeTypes, + 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 and add-ons the launcher needs, gathered behind one name. -/// Implemented by zero-sized markers; +/// A bundled runtime assembly: the [`RuntimeTypes`] lattice plus the +/// component builders, extensions, and add-ons the launcher needs, gathered +/// behind one name. /// [`RuntimeBuilder::runtime`](crate::builder::RuntimeBuilder::runtime) binds -/// one and launches it. -pub trait Runtime { +/// a `Default` marker; +/// [`RuntimeBuilder::with_runtime`](crate::builder::RuntimeBuilder::with_runtime) +/// binds a value, so a preset can hand back already-built backends through a +/// pass-through builder such as `Prebuilt`. +/// +/// 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`]). @@ -27,12 +41,32 @@ pub trait Runtime { type StoreBuilder: ComponentBuilder::Store>; /// Builds the extension payload ([`RuntimeTypes::Ext`]). type ExtBuilder: ComponentBuilder::Ext>; + /// Builds the shared [`LogPipeline`]. + type LogsBuilder: ComponentBuilder; - /// The component builders that open the backends at launch. - fn components() -> ComponentsBuilder; + /// The 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() -> AddOns; + fn add_ons(&self) -> AddOns; + + /// The extensions the preset launches with, derived from the loaded + /// config so an extension can carry config-resolved policy. None 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: the reference core backends (a chain @@ -41,6 +75,9 @@ pub trait Runtime { #[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; @@ -52,12 +89,15 @@ impl Runtime for CoreRuntime { type ChainBuilder = ProviderPoolBuilder; type StoreBuilder = LocalStoreBuilder; type ExtBuilder = (); + type LogsBuilder = LogPipelineBuilder; - fn components() -> ComponentsBuilder { + fn components( + self, + ) -> ComponentsBuilder { ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) } - fn add_ons() -> AddOns { + fn add_ons(&self) -> AddOns { vec![Box::new(PrometheusAddOn)] } } diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index 653f566c..3b848306 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -40,6 +40,7 @@ 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}; @@ -49,6 +50,7 @@ use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; /// supervisor consumes. Library-side code keeps `anyhow::Error` out /// of long-lived stream item types per the rust idiomatic rubric. #[derive(Debug, Error)] +#[non_exhaustive] pub enum StreamError { /// Underlying provider / transport failure while opening or /// pumping the subscription. @@ -457,7 +459,6 @@ 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 @@ -468,13 +469,18 @@ pub type TaggedChainLogStream = /// yields (the launcher passes the graceful-drain guard) is held /// until the loop returns, so the drain covers the final dispatch /// and cursor commit. +/// +/// Returns the `(blocks, chain_logs)` tally of events drained from the +/// streams - the same numbers the shutdown log line reports. Tests +/// assert on the tally; the launch path ignores it. 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 +499,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 +524,7 @@ pub async fn run( Box, Option>, ), + Extension(ExtensionEvent), // Carries the drain guard `shutdown` yielded. Shutdown(G), StreamPanic(&'static str), @@ -542,6 +555,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 +573,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 +584,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,12 +602,13 @@ 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); } } } @@ -641,6 +666,103 @@ pub async fn wait_for_shutdown_signal() -> anyhow::Result<&'static str> { mod tests { use super::*; + // ── Structural tests: per-stream task allocation (#56) ────────────────── + + /// `open_block_streams` spawns one independent reconnect task per chain. + /// Per-chain task isolation means a slow or reconnecting chain does not + /// delay events from other chains — each chain has its own mpsc channel + /// and backoff timer. + #[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 independent reconnect task per + /// (module, chain, filter) subscription. Two subscriptions from different + /// modules on the same chain each get their own task. + #[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; + } + + /// Issue #58's task-exit contract, asserted directly: a reconnect + /// task whose downstream receiver drops exits on its own with + /// [`TaskExit::ReceiverGone`] - it is not aborted. This cannot be + /// observed through `TaskSet::shutdown`, which aborts every handle + /// before joining, so the bare handle is joined here. + #[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 ────────────────────────────────── + /// The helper that decides whether to emit a /// "stream gap closed" line on the next block event. #[test] diff --git a/crates/nexum-runtime/src/runtime/poison_policy.rs b/crates/nexum-runtime/src/runtime/poison_policy.rs index 70998a6e..bfc1a427 100644 --- a/crates/nexum-runtime/src/runtime/poison_policy.rs +++ b/crates/nexum-runtime/src/runtime/poison_policy.rs @@ -31,7 +31,7 @@ use std::time::Duration; /// 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 +/// enough that a one-off RPC blip during a real extension submit does /// not get a module quarantined. pub const POISON_MAX_FAILURES: u32 = 5; pub const POISON_WINDOW: Duration = Duration::from_secs(600); diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 22400dfb..ebddddd8 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -18,6 +18,11 @@ //! `next_attempt = None` and never get scheduled - the init failure //! is treated as a manifest / config bug, not a transient. //! +//! Providers ride the same sweeps: a trap inside a routed call flips +//! the [`Liveness`] their actor shares with the supervisor, the owning +//! service reports the instance unavailable while dead, and the sweep +//! reinstalls the provider after the same backoff and poison policies. +//! //! 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 @@ -26,6 +31,7 @@ //! tasks own one per-chain backoff timer each, so a //! chain-A connection drop does not block chain-B events. +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -39,9 +45,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,13 +62,21 @@ 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. pub struct Supervisor { modules: Vec>, + /// Providers loaded at boot, whether or not `init` succeeded. Swept + /// for restart and poison alongside the modules. + providers: Vec, + /// Registered provider kinds paired with their services, kept for the + /// provider restart sweep to reinstall through. + kinds: ProviderKinds, /// 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 @@ -65,7 +86,10 @@ pub struct Supervisor { /// 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>, + extensions: Vec>>, + /// Extension-owned host services, built once at boot from the same + /// extension set and carried by every store. + services: HostServices, /// Poison-pill thresholds resolved from `[limits.poison]` at boot /// (production defaults: 5 failures / 10 min). poison_policy: crate::runtime::poison_policy::PoisonPolicy, @@ -82,6 +106,9 @@ pub struct Supervisor { #[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; @@ -202,6 +229,9 @@ struct LoadedModule { /// Cached for restart: outbound HTTP limits baked into the /// `HostState` we rebuild on each re-instantiation. http_limits: OutboundHttpLimits, + /// Cached for restart: chain response size cap baked into the + /// `HostState` we rebuild on each re-instantiation. + chain_response_max_bytes: usize, /// 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` @@ -234,6 +264,155 @@ struct LoadedModule { dispatch_bucket: crate::runtime::dispatch_rate::TokenBucket, } +/// One loaded provider. Mirrors [`LoadedModule`]'s restart and poison +/// bookkeeping; liveness is shared with the installed actor, which marks +/// it dead on a trap, and read back by the sweep. +struct LoadedProvider { + /// The provider's namespace: its manifest name, and the id its kind + /// installs it under. + name: String, + /// Registered kind spelling the restart sweep reinstalls through. + kind: &'static str, + /// Extension-owned manifest sections, as the worker install + /// predicates see them. + 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: the engine `[limits]` applied at boot. + 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`: a `true` here against a dead + /// liveness is an unrecorded trap. Boot init failure leaves it `false` + /// with `next_attempt = None`, permanent like a module's. + 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) +} + +/// The union of subscription kinds the wired extensions declare; a +/// manifest subscription of any other non-core kind fails the load. +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, so a typo'd +/// section fails loudly instead of silently skipping its extension's +/// install predicate. +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 string two wired extensions both claim, fail-fast at boot, in +/// any of three classes: service namespace, subscription kind, manifest +/// section. Each class dedupes silently downstream, so an unchecked +/// collision routes to whichever extension the map or `.any()` scan hits +/// first. +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 @@ -243,10 +422,46 @@ impl Supervisor { 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,17 +472,32 @@ 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, }) @@ -287,14 +517,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,13 +538,20 @@ 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, }) @@ -328,10 +570,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 +627,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 +642,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 +652,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 +671,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 +700,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 +722,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 @@ -523,7 +773,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 +784,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 +821,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,11 +831,197 @@ impl Supervisor { }) } + /// Load one `[[adapters]]` entry: resolve its manifest, resolve the + /// declared kind against the registered provider kinds, enforce the + /// scoped-transport capability set, build a supervised store carrying + /// the operator's HTTP and messaging grants, and hand the instance to + /// its kind to instantiate and install. A failed guest `init` loads the + /// provider dead and unroutable, permanently like a module's. + // 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() } + /// 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. Live: a trap drops + /// it, the restart sweep raises it again. + #[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 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 @@ -683,9 +1130,11 @@ impl Supervisor { // 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 +1145,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 +1210,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 +1226,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, ) { @@ -821,6 +1274,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 +1311,7 @@ impl Supervisor { let ok = matches!( self.dispatch_to( idx, - chain, + chain.id(), "chain-log", block_number.unwrap_or_default(), &event @@ -891,20 +1345,90 @@ impl Supervisor { ok } + /// Dispatch one extension-observed event to every module holding a + /// subscription of its kind whose filters all match the event's + /// attributes. Returns the number of modules invoked. Mirrors + /// `dispatch_block`: dead modules past their backoff are restarted + /// first, poisoned modules are skipped. + 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 + } + + /// The extension subscription kinds at least one loaded module + /// declares. An extension opens an event source only when its kind + /// appears here: with no subscriber every event would be dropped on + /// arrival. + 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, as booted. Shared by every module + /// store through the service map. + pub fn services(&self) -> &HostServices { + &self.services + } + /// 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`). + /// `chain_id` is telemetry only; chain-less event 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 +1518,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!( @@ -1083,6 +1607,134 @@ impl Supervisor { } } + /// Fold providers into the recovery path: record any trap the shared + /// liveness reports (backoff plus poison bookkeeping), then reinstall + /// dead, unpoisoned providers whose backoff has elapsed. Runs at the + /// head of every dispatch, beside the module restart sweep. + 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; + } + } + } + + /// Attempt to reinstall a dead provider in place: fresh store, fresh + /// instance, `init`, and a re-install replacing the dead slot. On + /// success the shared liveness is revived; on failure the backoff + /// slides further out, like a module restart. + 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, then hand + /// it back to its kind to instantiate and install 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 + } + /// 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. @@ -1118,7 +1770,7 @@ impl Supervisor { /// and capability enforcement via the crate-internal `capability_registry`. 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,21 +1779,68 @@ 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 own scoped imports +/// plus the ambient WASI base and the allowlisted `wasi:http`. The core +/// `nexum:host` interfaces a provider must not touch (local-store, +/// remote-store, identity, logging) are deliberately withheld, so a +/// provider that imports one of them fails to instantiate rather than +/// silently gaining reach. Extensions are not linked into providers: a +/// provider speaks its protocol over the standard transport, not a domain +/// extension surface. +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 path: the explicit `manifest` override +/// wins, else a sibling `module.toml`, else the deprecated `nexum.toml` +/// with a rename warning. `None` when neither sibling exists. Shared by the +/// module and adapter load paths. +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. 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 } @@ -1202,28 +1901,38 @@ enum DispatchOutcome { 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 policy window, and report the +/// recent-failure count once it crosses `policy.max_failures`. Shared by +/// the module and provider poison sweeps. +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 = true` once the module's failure window crosses the +/// policy threshold. The first transition emits the +/// `shepherd_module_poisoned` gauge + a structured 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,6 +1949,20 @@ 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()) diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 6ce381b5..947a17fc 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/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(); @@ -72,6 +169,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,6 +186,128 @@ 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. + +/// Block and chain-log streams are both consumed within the same `run()` +/// session — the `biased` select does not starve either event kind. One +/// item of each kind is queued before the loop starts; `run()`'s returned +/// tally must show both were drained. A regression that breaks either +/// select arm (or reorders the `biased` polling so one side never fires) +/// leaves its count at 0 and fails the assertion. Issue #56. +#[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", + ); +} + +/// After `run()` returns on the shutdown path, all reconnect tasks are +/// drained: the Shutdown arm calls `tasks.shutdown()`, which aborts every +/// handle and then joins each one, so no task detaches and outlives the +/// engine. (The companion contract — a task parked on a dropped receiver +/// exits with `ReceiverGone` on its own — is asserted directly in +/// `event_loop::tests::reconnect_task_exits_receiver_gone_when_receiver_drops`; +/// it cannot be observed here because `TaskSet::shutdown` aborts first.) +/// Issue #58. +#[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 @@ -134,7 +354,7 @@ fn make_wasmtime_engine() -> 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> { +fn core_extensions() -> Vec>> { Vec::new() } @@ -210,6 +430,45 @@ async fn e2e_supervisor_boots_example_module() { assert_eq!(supervisor.alive_count(), 1); } +/// The per-module world contract: the example component's +/// capability-bearing imports are exactly what its manifest declares +/// (`logging`), by construction of the emitted world rather than by +/// the toolchain eliding unused imports of a blanket world. +#[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] @@ -430,53 +689,6 @@ 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 { @@ -791,6 +1003,7 @@ chain_id = 1 manifest: Some(alive_manifest), }, ], + adapters: Vec::new(), }; let supervisor = Supervisor::boot( @@ -1131,6 +1344,7 @@ chain_id = 1 manifest: Some(example_manifest.clone()), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -1675,6 +1889,7 @@ chain_id = 100 manifest: Some(chain_b_manifest), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -1791,6 +2006,7 @@ chain_id = 100 manifest: Some(calm_manifest), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -1921,6 +2137,7 @@ chain_id = 100 manifest: Some(example_manifest), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -2138,3 +2355,250 @@ 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, so the boot-gate tests exercise the generic kind loop +/// without a real provider component. +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)] +} + +/// The module-kind discriminator gates the provider load path: an +/// `[[adapters]]` entry whose manifest is (or defaults to) an event-module +/// is rejected before instantiation with a message 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. This +/// proves the discriminator routed the entry to the provider load path +/// rather than rejecting it on kind. +#[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/harness.rs b/crates/nexum-runtime/src/test_utils/harness.rs index e06e163a..10d808d6 100644 --- a/crates/nexum-runtime/src/test_utils/harness.rs +++ b/crates/nexum-runtime/src/test_utils/harness.rs @@ -22,6 +22,7 @@ //! crate's backend through the same harness. use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use alloy_rpc_types_eth::{Header, Log}; @@ -54,7 +55,7 @@ where { wasm: PathBuf, manifest: ManifestSource, - extensions: Vec>>, + extensions: Vec>>>, ext: E, limits: ModuleLimits, chain: MockChainProvider, @@ -100,8 +101,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,7 +110,7 @@ impl TestRuntimeBuilder { /// Register several extensions at once. pub fn extensions( mut self, - extensions: impl IntoIterator>>, + extensions: impl IntoIterator>>>, ) -> Self { self.extensions.extend(extensions); self @@ -425,18 +426,31 @@ chain_id = {chain_id} 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) @@ -579,6 +593,229 @@ direction = "above" rt.wait().await.expect("clean shutdown"); } + /// Both block and chain-log events are dispatched to a live module in the + /// same session: the `biased` select in `run()` delivers both event kinds + /// without starvation. Addresses the ordering guarantee from issue #56. + #[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 at the module in the same order — + /// the per-chain stream, the select, and the dispatch path preserve + /// delivery order. Issue #56's ordering guarantee, asserted on the + /// module's own log records rather than inferred from termination. + #[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 signalled while a dispatch is pending never destroys + /// completed work: the dispatch path sits outside the shutdown select, + /// so a block that was picked up finishes its wasmtime call and its + /// log record survives `wait()`. The test first proves the dispatch + /// completed (log line present), then shuts down and re-reads the same + /// record after the engine is fully torn down — if teardown dropped or + /// truncated completed work, the second read fails. Issue #58. + #[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, end to end: the configured cap reaches + /// `HostState`, an over-cap node response is rejected before the guest + /// copy, and the module observes the typed `invalid-input` fault + /// instead of the body. Guards the wiring the unit tests on + /// `check_response_cap` cannot see (issue #154). + #[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 event loop's /// reconnect task reopens the subscription after backoff and the /// re-armed mock resumes delivery, matching a real provider that comes diff --git a/crates/nexum-runtime/src/test_utils/types.rs b/crates/nexum-runtime/src/test_utils/types.rs index 089d197a..4180b328 100644 --- a/crates/nexum-runtime/src/test_utils/types.rs +++ b/crates/nexum-runtime/src/test_utils/types.rs @@ -15,6 +15,8 @@ use crate::test_utils::{MockChainProvider, MockStateStore}; /// it derives no traits and is zero-sized at runtime. 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/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index 621da7bd..1da359e2 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -54,20 +54,25 @@ //! 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`). +//! Domain test crates compose these mocks with their own scripted +//! venue transports on the `videre:venue/client` seam. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] -use std::cell::RefCell; +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, LocalStoreHost, LoggingHost}; +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}; @@ -79,8 +84,14 @@ use tracing::{Event, Metadata, Subscriber}; 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, } @@ -111,6 +122,59 @@ impl LocalStoreHost for MockHost { 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 { @@ -189,43 +253,441 @@ impl ChainHost for MockChain { } } +// ---------------------------------------------------------------- identity + +/// 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`]. Holds a programmable account roster and +/// one programmed signing outcome; records every signing call. With no +/// outcome programmed, signing fails as [`Fault::Unsupported`], the +/// stub-backend posture; an account outside the roster fails as +/// [`Fault::Denied`] before the programmed outcome applies. +#[derive(Default)] +pub struct MockIdentity { + accounts: RefCell>, + response: RefCell>>, + calls: RefCell>, +} + +impl MockIdentity { + /// Add an account to the roster [`accounts`](IdentityHost::accounts) + /// reports and signing admits. + 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())) + } +} + +// ---------------------------------------------------------------- messaging + +/// 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 for assertion, and an optional topic scope +/// mirrors the host's `messaging_topics` grant. Seeded history and +/// published records are deliberately separate stores: a query answers +/// from what the test seeded, 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), with 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`, playing the component's + /// `messaging_topics` grant with the host's matching: a topic is + /// admitted when it equals a grant entry or descends from one read + /// as a `/`-bounded path prefix; anything else fails as + /// [`Fault::Denied`]. An empty grant is unscoped, the host's module + /// default, as is an untouched mock. + 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`. Multiple patterns can be registered; the first + /// matching one 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(()) + } +} + +/// The host's `messaging_topics` matching: an empty scope admits every +/// topic; otherwise a topic is admitted when it equals a scope entry or +/// descends from one read as a path prefix bounded at `/`, 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 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(()) + } + + /// Answer from the seeded history: exact-topic matches whose + /// timestamp lies within the inclusive `start_time..=end_time` + /// window, in seed order. Seed order is delivery order, so a + /// `limit` keeps the newest matches: 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) + } +} + +// ---------------------------------------------------------------- remote-store + +/// In-memory [`RemoteStoreHost`]: content-addressed blobs plus mutable +/// `(owner, topic)` feeds. The mock addresses blobs by `keccak256` of +/// their content, so uploads are deterministic for assertion; the real +/// store's addressing scheme is the host's concern. Feed writes land +/// under the mock's own owner ([`set_owner`](Self::set_owner), +/// zero-address by default), mirroring the host signing feed updates +/// with its configured identity. +#[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 without going through the trait; 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 for [`read_feed`](RemoteStoreHost::read_feed) + /// tests. + 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) + } +} + // ---------------------------------------------------------------- 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). +/// In-memory [`LocalStoreHost`] mirroring the runtime store's shape: +/// namespaced views over one shared row map, plus store-wide entry +/// and byte limits. +/// +/// A fresh store is the root view. [`namespaced`](Self::namespaced) +/// derives a sibling view over the same backing rows - identical key +/// strings in different namespaces never collide, matching the host's +/// per-module key prefixing. Limits sit on the shared backing store, +/// so one namespace's writes can exhaust another's headroom exactly +/// as two modules share one database file. Fault injection via +/// [`fail_on`](Self::fail_on) stays per-view. +/// +/// # Fidelity vs the real `redb` store /// -/// Supports optional error injection via [`MockLocalStore::fail_on`] -/// and entry-count limits via [`MockLocalStore::set_max_entries`]. +/// Two gaps remain (deferred to the `MockRuntime` refactor, #94): +/// - **No transaction semantics** - `redb` wraps each `on_event` in an +/// implicit write transaction (commit on `Ok`, rollback on trap); this +/// mock commits every `set` immediately. +/// - **No concurrent access** - the backing `RefCell` is single-threaded, +/// whereas `redb` uses MVCC. #[derive(Default)] pub struct MockLocalStore { - rows: RefCell>>, - /// When set, `set` returns `StorageFull` if the store reaches this many entries. - max_entries: RefCell>, + 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 { - /// Number of rows currently held. + /// A view over the same backing rows under `namespace`. Views with + /// the same namespace alias the same data (two handles onto one + /// module store); different namespaces are fully isolated even for + /// identical key strings. + /// + /// # Panics + /// + /// On an empty namespace - the runtime rejects those too. + 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.rows.borrow().len() + self.shared + .rows + .borrow() + .keys() + .filter(|(ns, _)| *ns == self.namespace) + .count() } - /// Whether the store is empty. + /// Whether this view's namespace holds no rows. pub fn is_empty(&self) -> bool { - self.rows.borrow().is_empty() + self.len() == 0 } - /// Direct read for assertions - bypasses the trait. + /// Direct read of this view's namespace for assertions - bypasses + /// the trait. pub fn snapshot(&self) -> HashMap> { - self.rows.borrow().clone() + self.shared + .rows + .borrow() + .iter() + .filter(|((ns, _), _)| *ns == self.namespace) + .map(|((_, key), value)| (key.clone(), value.clone())) + .collect() } - /// Set a maximum number of entries. Once reached, `set` on a new - /// key returns a `StorageFull` error. `None` disables the limit. + /// Cap the row count across every namespace. Once reached, `set` + /// on a new key fails; overwriting an existing key still succeeds. pub fn set_max_entries(&self, limit: usize) { - *self.max_entries.borrow_mut() = Some(limit); + self.shared.max_entries.set(Some(limit)); + } + + /// Cap total stored bytes (key + value, across every namespace). + /// A `set` that would push the total past the cap fails; deletes + /// and same-key overwrites release the bytes they displace. + pub fn set_max_bytes(&self, limit: usize) { + self.shared.max_bytes.set(Some(limit)); } /// Inject a fault for any operation where the key starts with @@ -250,40 +712,95 @@ impl MockLocalStore { impl LocalStoreHost for MockLocalStore { fn get(&self, key: &str) -> Result>, Fault> { self.check_injected_error(key)?; - Ok(self.rows.borrow().get(key).cloned()) + 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)?; - 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" - ))); - } + 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" + ))); } - self.rows - .borrow_mut() - .insert(key.to_string(), value.to_vec()); + // 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)?; - self.rows.borrow_mut().remove(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(|k| k.starts_with(prefix)) - .cloned() + .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) + } } // ---------------------------------------------------------------- logging @@ -583,6 +1100,8 @@ pub fn capture_tracing(f: impl FnOnce() -> R) -> (R, CapturedEvents) { #[cfg(test)] mod tests { + use nexum_sdk::prelude::U256; + use super::*; #[test] @@ -643,6 +1162,33 @@ mod tests { 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(); @@ -671,22 +1217,290 @@ mod tests { 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. + // 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] diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 8410babb..844b50c6 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -19,16 +19,37 @@ 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 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 +60,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/crates/nexum-sdk/src/address.rs index 2106e4a5..e1c8cb3a 100644 --- a/crates/nexum-sdk/src/address.rs +++ b/crates/nexum-sdk/src/address.rs @@ -2,8 +2,8 @@ //! //! 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 +//! one of the entries is malformed, and native tooling parses single +//! `0x...` strings out of 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 diff --git a/crates/nexum-sdk/src/chain/chainlink.rs b/crates/nexum-sdk/src/chain/chainlink.rs index f589f96b..57c49b70 100644 --- a/crates/nexum-sdk/src/chain/chainlink.rs +++ b/crates/nexum-sdk/src/chain/chainlink.rs @@ -18,7 +18,7 @@ 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 @@ -45,8 +45,11 @@ sol! { /// /// `domain` is embedded in the log line so a single host log stream /// can disambiguate which module's oracle failed. +// 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, diff --git a/crates/nexum-sdk/src/chain/mod.rs b/crates/nexum-sdk/src/chain/mod.rs index dd60ba0d..dc8ff363 100644 --- a/crates/nexum-sdk/src/chain/mod.rs +++ b/crates/nexum-sdk/src/chain/mod.rs @@ -1,10 +1,21 @@ -//! `chain::request` JSON plumbing. +//! Chain access for guest strategies. //! -//! 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. +//! 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/crates/nexum-sdk/src/chain/provider.rs b/crates/nexum-sdk/src/chain/provider.rs new file mode 100644 index 00000000..426b4fd5 --- /dev/null +++ b/crates/nexum-sdk/src/chain/provider.rs @@ -0,0 +1,130 @@ +//! `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 strategy calls typed provider methods +/// instead of hand-building JSON-RPC. Blanket-implemented for every +/// cloneable [`ChainHost`]; drive the returned futures with +/// [`block_on`]. +/// +/// ``` +/// use alloy_provider::Provider; +/// use nexum_sdk::chain::{Chain, ProviderHost, block_on}; +/// use nexum_sdk::host::{ChainError, ChainHost}; +/// +/// #[derive(Clone)] +/// struct StubHost; +/// impl ChainHost for StubHost { +/// fn request(&self, _: u64, _: &str, _: &str) -> Result { +/// Ok("\"0x2a\"".into()) +/// } +/// } +/// +/// let provider = StubHost.provider(Chain::mainnet()); +/// let block = block_on(provider.get_block_number()).unwrap(); +/// assert_eq!(block, 42); +/// ``` +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` means an async alloy layer crept in and the +/// chain SDK must move to a host-driven surface, not a poll loop. +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/crates/nexum-sdk/src/chain/transport.rs b/crates/nexum-sdk/src/chain/transport.rs new file mode 100644 index 00000000..5ae36dcc --- /dev/null +++ b/crates/nexum-sdk/src/chain/transport.rs @@ -0,0 +1,252 @@ +//! [`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: the host blocks the guest until +/// the response is available, so every returned future is ready on its +/// first poll and [`block_on`](super::block_on) drives it for free. +/// +/// Methods outside the typed [`ChainMethod`] surface never reach the +/// host; they fail as a JSON-RPC `-32601` error response. 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/crates/nexum-sdk/src/config.rs index 4a278cfc..fdfcfc9c 100644 --- a/crates/nexum-sdk/src/config.rs +++ b/crates/nexum-sdk/src/config.rs @@ -20,6 +20,7 @@ use thiserror::Error; /// /// [`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:?}")] diff --git a/crates/nexum-sdk/src/host.rs b/crates/nexum-sdk/src/host.rs index 6e0e71d5..254c8787 100644 --- a/crates/nexum-sdk/src/host.rs +++ b/crates/nexum-sdk/src/host.rs @@ -1,13 +1,14 @@ //! 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 +//! Each trait mirrors one nexum host interface: [`ChainHost`], +//! [`IdentityHost`], [`LocalStoreHost`], [`RemoteStoreHost`], +//! [`MessagingHost`], and [`LoggingHost`]. 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`]. +//! [`Host`] supertrait (all six) or the exact traits it exercises, +//! 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` //! @@ -18,7 +19,7 @@ //! the mocks compile without a wasm toolchain. See `nexum-sdk-test`'s //! crate docs for the adapter pattern. -use alloy_primitives::Bytes; +use alloy_primitives::{Address, B256, Bytes, Signature}; use strum::IntoStaticStr; use tracing_core::Level; @@ -44,7 +45,7 @@ pub enum Fault { Denied(String), /// Rate-limited by an upstream service; may carry backoff guidance /// when the host knows the retry window. - #[error("rate limited")] + #[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")] @@ -65,13 +66,32 @@ pub struct RateLimit { 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`] 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 { +/// +/// Sealed: an error type opts in by also implementing the sealing +/// marker. +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. @@ -120,6 +140,7 @@ pub struct RpcError { /// [`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)] +#[non_exhaustive] pub enum ChainError { /// A shared host fault. #[error(transparent)] @@ -192,6 +213,21 @@ pub trait LocalStoreHost { fn delete(&self, key: &str) -> Result<(), Fault>; /// Enumerate keys whose raw form starts with `prefix`. fn list_keys(&self, prefix: &str) -> Result, Fault>; + /// Whether `key` exists. Default fetches the value; a backend + /// overrides when it can answer without. + fn contains(&self, key: &str) -> Result { + Ok(self.get(key)?.is_some()) + } + /// Value byte length, `Ok(None)` when absent. Default fetches the + /// value; on some backends this may be a scan. + fn len(&self, key: &str) -> Result, Fault> { + Ok(self.get(key)?.map(|v| v.len() as u64)) + } + /// Number of keys starting with `prefix`. Default materialises the + /// key list; on some backends this may be a scan. + fn count(&self, prefix: &str) -> Result { + Ok(self.list_keys(prefix)?.len() as u64) + } } /// `nexum:host/logging` - structured runtime logs. @@ -202,14 +238,108 @@ pub trait LoggingHost { 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). +/// `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. The +/// host confines both 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`]. The WIT edge +/// carries it as bytes; any length but 20 is a host-side bug, folded +/// 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 is a host-side bug, folded 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 is a host-side bug, folded 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 that bundles all six core host interfaces. 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 exercise fewer interfaces bound exactly those +/// (`H: ChainHost + LoggingHost`, say) so their production adapter +/// only needs the capabilities the module declares; a domain +/// extension's host trait is bounded the same way (the CoW SDK's +/// `CowHost`). /// -/// A blanket impl is provided for any type that implements all three +/// A blanket impl is provided for any type that implements all six /// component traits, so callers do not have to add a redundant /// `impl Host for MyHost {}`. /// @@ -222,8 +352,10 @@ pub trait LoggingHost { /// ``` /// use nexum_sdk::Level; /// use nexum_sdk::host::{ -/// ChainError, ChainHost, Fault, Host, LocalStoreHost, LoggingHost, +/// ChainError, ChainHost, Fault, Host, IdentityHost, LocalStoreHost, LoggingHost, +/// Message, MessagingHost, RemoteStoreHost, /// }; +/// # use nexum_sdk::prelude::{Address, B256, Signature}; /// /// /// Pure strategy logic - no wit-bindgen calls in here. /// fn record_block(host: &H, chain_id: u64, key: &str) -> Result<(), Fault> { @@ -241,34 +373,157 @@ pub trait LoggingHost { /// # Ok("\"0x0\"".into()) /// # } /// # } +/// # impl IdentityHost for StubHost { +/// # fn accounts(&self) -> Result, Fault> { Ok(vec![]) } +/// # fn sign(&self, _: Address, _: &[u8]) -> Result { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # fn sign_typed_data(&self, _: Address, _: &str) -> Result { +/// # Err(Fault::Unsupported("stub".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 RemoteStoreHost for StubHost { +/// # fn upload(&self, _: &[u8]) -> Result { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # fn download(&self, _: B256) -> Result, Fault> { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # fn read_feed(&self, _: Address, _: B256) -> Result>, Fault> { Ok(None) } +/// # fn write_feed(&self, _: B256, _: &[u8]) -> Result { +/// # Err(Fault::Unsupported("stub".into())) +/// # } +/// # } +/// # impl MessagingHost for StubHost { +/// # fn publish(&self, _: &str, _: &[u8]) -> Result<(), Fault> { Ok(()) } +/// # fn query( +/// # &self, +/// # _: &str, +/// # _: Option, +/// # _: Option, +/// # _: Option, +/// # ) -> 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 {} +/// Sealed: the blanket impl is the only implementation. +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 super::{ChainError, Fault, HostFault, RateLimit, RpcError}; + use alloy_primitives::{Address, B256, U256}; + + use super::{ + ChainError, Fault, HostFault, RateLimit, RpcError, account_from_wire, reference_from_wire, + signature_from_wire, + }; #[test] - fn fault_labels_are_stable_snake_case() { + 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 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()), "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"), + (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); @@ -276,6 +531,18 @@ mod tests { } } + #[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); diff --git a/crates/nexum-sdk/src/http.rs b/crates/nexum-sdk/src/http.rs index 7ca0c551..cac11bc3 100644 --- a/crates/nexum-sdk/src/http.rs +++ b/crates/nexum-sdk/src/http.rs @@ -56,6 +56,7 @@ impl Default for FetchOptions { /// 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. @@ -95,6 +96,17 @@ pub trait Fetch { } } +/// A shared reference forwards, so a wrapper can borrow its transport. +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. /// /// Guest-only glue: the type exists on every target so module diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 06d1cd9e..8dbe2309 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -3,6 +3,11 @@ //! alone so they compile for any world and test against the in-memory //! mocks. //! +//! Private, single-tenant instance over the wallet's own authorised +//! orders; it submits intents to the venue and does not settle them. +//! This is not a public keeper network, fee marketplace, or MEV +//! searcher. +//! //! Three stores cover the machinery watcher modules hand-roll: //! //! - [`WatchSet`] - the watch-set registry, one `watch:{owner}:{hash}` @@ -13,10 +18,18 @@ //! - [`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. +//! Two pieces drive the stores from the poll loop: +//! +//! - [`ConditionalSource`] - the world-neutral poll seam: one watch in, +//! one outcome out, at a given [`Tick`]. Implementations own the +//! transport and the outcome shape. +//! - [`Retrier`] - runs a [`RetryAction`]'s effect through the +//! stores after a failed keeper run attempt. +//! +//! [`WatchRef`] ties the first two together: gate and marker keys are +//! derived from the exact hex substrings of the stored watch key, and +//! [`WatchSet::remove`] drops a watch together with all of its derived +//! keys so no failure path can orphan one. //! //! ``` //! use nexum_sdk::keeper::{Gates, Journal, WatchRef, WatchSet}; @@ -69,6 +82,7 @@ //! ``` use alloy_primitives::{Address, B256}; +use strum::IntoStaticStr; use crate::host::{Fault, LocalStoreHost}; @@ -85,6 +99,24 @@ pub const SUBMITTED_PREFIX: &str = "submitted:"; /// the observe-and-verify path (e.g. ethflow) recorded an existing /// upstream order as seen. pub const OBSERVED_PREFIX: &str = "observed:"; +/// Prefix of the first-refusal marker paired with a watch: the block a +/// [`RetryAction::DropOnRepeat`] refusal was first seen at, u64 +/// little-endian. +pub const REFUSED_PREFIX: &str = "refused:"; + +/// Journal marker [`Journal::reserve`] writes before a submit, distinct +/// from the empty marker [`Journal::record`] commits so +/// [`Journal::release`] can drop a still-pending reservation without +/// touching a committed receipt. +const RESERVE_MARK: &[u8] = b"\x01"; + +/// Canonical watch key for an owner / commitment-hash pair (lowercase +/// `0x`-prefixed hex on both halves). Free-standing because the key +/// shape is a property of the store convention, not of any host. +#[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, parsed from a /// `watch:{owner}:{hash}` row. Gate keys are derived from the exact @@ -137,6 +169,11 @@ impl<'k> WatchRef<'k> { 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 @@ -152,10 +189,11 @@ impl<'h, H: LocalStoreHost> WatchSet<'h, H> { Self { host } } - /// Canonical key for an owner / commitment-hash pair (lowercase - /// `0x`-prefixed hex on both halves). + /// Canonical key for an owner / commitment-hash pair. Thin + /// delegate kept for discoverability; prefer the free + /// [`watch_key`], which needs no host turbofish. pub fn key(owner: &Address, hash: &B256) -> String { - format!("{WATCH_PREFIX}{owner:#x}:{hash:#x}") + watch_key(owner, hash) } /// Insert or overwrite the watch row; returns the key written. @@ -176,11 +214,13 @@ impl<'h, H: LocalStoreHost> WatchSet<'h, H> { 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. + /// Drop the watch together with its gate and refusal keys. The + /// derived keys go first: a fault part-way leaves the watch row + /// behind so a retry re-drops it, and a derived key can never + /// outlive its watch. 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()) } } @@ -215,12 +255,12 @@ impl<'h, H: LocalStoreHost> Gates<'h, H> { /// 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())? + if let Some(next) = read_u64(self.host, &watch.next_block_key())? && block < next { return Ok(false); } - if let Some(next) = self.read_u64(&watch.next_epoch_key())? + if let Some(next) = read_u64(self.host, &watch.next_epoch_key())? && epoch_s < next { return Ok(false); @@ -233,28 +273,32 @@ impl<'h, H: LocalStoreHost> Gates<'h, H> { 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) - } +/// Little-endian u64 row read shared by the gate and refusal-marker +/// keys. Absent key: silently `None`. Present but wrong length: the +/// value is corrupt, so warn before falling open to `None` - fail-open +/// is deliberate (a corrupt value can only make the watch act sooner, +/// never wedge it), but it must not pass unobserved. +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) } } } /// 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. +/// prefix. Presence of the key is the receipt, so re-recording is +/// idempotent. [`record`](Journal::record) commits an empty marker; +/// [`reserve`](Journal::reserve) writes a distinct pre-submit marker a +/// [`release`](Journal::release) can drop, so a submit path can guard +/// against a re-post before its record write lands. pub struct Journal<'h, H> { host: &'h H, prefix: &'static str, @@ -279,16 +323,158 @@ impl<'h, H: LocalStoreHost> Journal<'h, H> { } } - /// Record the receipt. + /// Reserve `receipt` ahead of a submit: a non-empty marker written + /// before the venue call. Presence bars a re-post, so a record + /// write that faults after an accepted submit still leaves a + /// marker. [`record`](Self::record) commits it on acceptance; + /// [`release`](Self::release) drops it on any non-accepting + /// outcome. + pub fn reserve(&self, receipt: &str) -> Result<(), Fault> { + self.host.set(&self.key(receipt), RESERVE_MARK) + } + + /// Commit the receipt: the durable empty presence marker. + /// Overwrites a prior [`reserve`](Self::reserve) in place. pub fn record(&self, receipt: &str) -> Result<(), Fault> { - self.host.set(&format!("{}{receipt}", self.prefix), b"") + self.host.set(&self.key(receipt), b"") } - /// Whether the receipt is already journalled. + /// Drop a still-pending reservation so a non-accepting submit + /// re-posts next sweep. No-op once [`record`](Self::record) has + /// committed, leaving the durable receipt intact. + pub fn release(&self, receipt: &str) -> Result<(), Fault> { + let key = self.key(receipt); + if self.host.get(&key)?.as_deref() == Some(RESERVE_MARK) { + self.host.delete(&key)?; + } + Ok(()) + } + + /// Whether the receipt is journalled, reserved or committed. pub fn contains(&self, receipt: &str) -> Result { - Ok(self - .host - .get(&format!("{}{receipt}", self.prefix))? - .is_some()) + Ok(self.host.get(&self.key(receipt))?.is_some()) + } + + /// Prefixed store key for a receipt. + fn key(&self, receipt: &str) -> String { + format!("{}{receipt}", self.prefix) + } +} + +/// One poll dispatch's world view: chain, block height, and the block +/// clock in Unix seconds. Gate checks and backoff arithmetic read the +/// same instant a source is polled at, so a watch can never gate +/// itself 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 so implementations stay mock- +/// testable; deliberately no venue-transport abstraction - the source +/// owns its own wire (an `eth_call`, an HTTP probe, a stub). +/// +/// A transient failure should surface as a retry-flavoured outcome, +/// not tear down the caller's sweep: `poll` is infallible by contract. +pub trait ConditionalSource { + /// What one poll produces. + type Outcome; + + /// Poll the source for `watch` at `tick`. `params` is the stored + /// watch value (the encoded commitment parameters), passed + /// verbatim so the source owns the decode. + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Self::Outcome; + + /// Short strategy name compositions prefix shared log lines with + /// (for example `"twap"`). Diagnostic only - no behaviour keys + /// off it. + fn label(&self) -> &'static str { + "conditional" + } +} + +/// What the retry ledger should do to a watch after a failed +/// keeper run attempt. +/// +/// `IntoStaticStr` exposes each variant as a snake_case `&'static +/// str` for log and metric labels. `#[non_exhaustive]` so the +/// contract can grow a variant; downstream dispatch should treat an +/// unknown variant as "leave the watch in place" (the conservative +/// choice). +#[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 the watch to the next one; 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 watch's first-refusal marker: an accepted submit ends + /// the refusal episode, so a later [`RetryAction::DropOnRepeat`] + /// refusal earns a fresh one-block grace. No-op when no marker is + /// set. + pub fn clear_refusal(&self, watch: WatchRef<'_>) -> Result<(), Fault> { + self.host.delete(&watch.refused_key()) } } diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index ac6531fe..201aa1fd 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -17,23 +17,33 @@ //! 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. +//! - [`host`] - host trait seam: [`Host`] bundling all six core +//! interfaces ([`ChainHost`] / [`IdentityHost`] / [`LocalStoreHost`] +//! / [`RemoteStoreHost`] / [`MessagingHost`] / [`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. //! +//! - [`module`] - attribute macro that generates the whole per-cdylib +//! glue (the wit-bindgen call, the adapter above, the +//! `Guest`/`on-event` dispatch, and `export!`) from an `impl` block of +//! named handlers. +//! //! - [`keeper`] - strategy-keeper stores over [`LocalStoreHost`]: //! the watch-set registry ([`WatchSet`]), block/epoch gate keys //! ([`Gates`]) and the receipt-keyed idempotency journal -//! ([`Journal`]). +//! ([`Journal`]); plus the [`ConditionalSource`] poll seam and the +//! [`Retrier`] dispatching a [`RetryAction`] through the stores. //! -//! - [`chain`] - `eth_call` JSON plumbing ([`eth_call_params`], +//! - [`chain`] - typed chain access: alloy [`Chain`], +//! the closed [`ChainMethod`] read surface, and the alloy provider +//! seam ([`HostTransport`], [`provider`], [`block_on`]); plus +//! `eth_call` JSON plumbing ([`eth_call_params`], //! [`parse_eth_call_result`]) and the Chainlink AggregatorV3 reader //! ([`read_latest_answer`]). //! @@ -75,12 +85,23 @@ //! [`keccak256`]: alloy_primitives::keccak256 //! [`Host`]: host::Host //! [`ChainHost`]: host::ChainHost +//! [`IdentityHost`]: host::IdentityHost //! [`LocalStoreHost`]: host::LocalStoreHost +//! [`RemoteStoreHost`]: host::RemoteStoreHost +//! [`MessagingHost`]: host::MessagingHost //! [`LoggingHost`]: host::LoggingHost //! [`Fault`]: host::Fault //! [`WatchSet`]: keeper::WatchSet //! [`Gates`]: keeper::Gates //! [`Journal`]: keeper::Journal +//! [`ConditionalSource`]: keeper::ConditionalSource +//! [`Retrier`]: keeper::Retrier +//! [`RetryAction`]: keeper::RetryAction +//! [`Chain`]: alloy_chains::Chain +//! [`ChainMethod`]: chain::ChainMethod +//! [`HostTransport`]: chain::HostTransport +//! [`provider`]: chain::ProviderHost::provider +//! [`block_on`]: chain::block_on //! [`eth_call_params`]: chain::eth_call_params //! [`parse_eth_call_result`]: chain::parse_eth_call_result //! [`read_latest_answer`]: chain::chainlink::read_latest_answer @@ -100,6 +121,11 @@ #![warn(missing_docs)] #![cfg_attr(docsrs, feature(doc_cfg))] +/// Generate the per-cdylib module glue (wit-bindgen, host adapter, +/// `Guest`/`on-event` dispatch, `export!`) 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; diff --git a/crates/nexum-sdk/src/prelude.rs b/crates/nexum-sdk/src/prelude.rs index ef4bcc1f..b72a6b04 100644 --- a/crates/nexum-sdk/src/prelude.rs +++ b/crates/nexum-sdk/src/prelude.rs @@ -7,4 +7,4 @@ //! 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}; +pub use alloy_primitives::{Address, B256, Bytes, Signature, U256, address, b256, hex, keccak256}; diff --git a/crates/nexum-sdk/src/proptests.rs b/crates/nexum-sdk/src/proptests.rs index 0f3e24b9..2a4709e7 100644 --- a/crates/nexum-sdk/src/proptests.rs +++ b/crates/nexum-sdk/src/proptests.rs @@ -11,7 +11,8 @@ //! `balance-tracker`'s persistence path). //! //! The CoW-domain properties (`decode_revert`, the -//! `gpv2_to_order_data` marker guard) live in `shepherd-sdk`. +//! `gpv2_to_order_data` marker guard) live in `composable-cow` and +//! `cow-venue`. #![cfg(test)] diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 3e33c68c..98059b8b 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -3,53 +3,173 @@ //! //! 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. +//! impls plus the fault, chain-error, and level conversions. 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. +//! The adapter is capability-selected: the `caps: [...]` form emits +//! only the pieces backed by the module's declared capabilities +//! (`#[nexum_sdk::module]` invokes it this way, matching the +//! per-module world it generates), while the zero-argument form emits +//! the full six-interface set (`chain, identity, local_store, +//! remote_store, messaging, logging`) for modules that compile +//! against a blanket world with every core import present. +//! Either way the call site must already have the wit-bindgen output +//! for its world in scope (`wit_bindgen::generate!({ ..., +//! generate_all })`): each selected piece resolves its +//! `nexum::host::*` module, so selecting a capability the world does +//! not import is a compile error. //! //! 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). +//! macro and adding trait impls for the same `WitBindgenHost`. //! //! 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`. +//! // or, capability-selected: +//! // nexum_sdk::bind_host_via_wit_bindgen!(caps: [chain, logging]); +//! +//! // `WitBindgenHost` and the `Fault` `From` impls (both directions) +//! // are now in scope, plus per selected capability: `convert_chain_err` +//! // (chain), the `IdentityHost`, `LocalStoreHost`, `RemoteStoreHost`, +//! // and `MessagingHost` impls (identity / local_store / remote_store / +//! // messaging), and the `Level` `From` impl, `HostLogSink`, and +//! // `install_tracing` (logging), 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. `From` impls for +//! // `nexum_sdk::events::Log` and `nexum_sdk::host::Message` are also +//! // emitted so `on_event` maps chain-log batches and messages +//! // straight to the SDK types. //! ``` -/// Generate `WitBindgenHost` + the core `*Host` trait impls + the -/// error / level converters. See module docs. +/// Generate `WitBindgenHost` + the `*Host` trait impls + the error / +/// level `From` impls for the selected capabilities. See module docs. +/// +/// The fault and level conversions are `From` impls: orphan-legal here +/// because the wit-bindgen types are local to the expanding cdylib. /// /// 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. +/// `HostLogSink`, and `install_tracing` are intentionally visible in the +/// caller's scope. #[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 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; + /// 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. + 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), + } + } + } + + /// Reverse direction: lower the SDK `Fault` back into the + /// per-cdylib wit-bindgen `Fault` so `Guest::init` / + /// `Guest::on_event` can return what the export signature + /// expects (`?` applies it). + /// + /// 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. + 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 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() + } + } + + /// Rebuild the SDK message from the per-cdylib wit-bindgen + /// `message` record, so `on_event` maps a delivery straight to + /// `nexum_sdk::host::Message`. + 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, @@ -61,6 +181,59 @@ macro_rules! bind_host_via_wit_bindgen { } } + /// 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(::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, @@ -69,94 +242,112 @@ macro_rules! bind_host_via_wit_bindgen { ::core::option::Option<::std::vec::Vec>, $crate::host::Fault, > { - nexum::host::local_store::get(key).map_err(convert_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(convert_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(convert_fault) + nexum::host::local_store::delete(key).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(convert_fault) + nexum::host::local_store::list_keys(prefix).map_err($crate::host::Fault::from) } - } - - impl $crate::host::LoggingHost for WitBindgenHost { - fn log(&self, level: $crate::Level, message: &str) { - nexum::host::logging::log(convert_level(level), message); + 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) } } - - /// 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), - }) - } + }; + (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) } } - - /// 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), + }; + (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(), + ) } } - - /// 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)), + }; + (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); } } @@ -164,40 +355,19 @@ macro_rules! bind_host_via_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, + 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 } - .into() } } diff --git a/crates/nexum-sdk/tests/keeper.rs b/crates/nexum-sdk/tests/keeper.rs index 5ac54c87..f73c9adf 100644 --- a/crates/nexum-sdk/tests/keeper.rs +++ b/crates/nexum-sdk/tests/keeper.rs @@ -1,15 +1,17 @@ -//! 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. +//! Keeper-store acceptance tests against the composed +//! `nexum_sdk_test::MockHost` - the keeper touches only the local +//! store, so the world-neutral mock is the whole seam. 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, + ConditionalSource, Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, REFUSED_PREFIX, + Retrier, RetryAction, Tick, WATCH_PREFIX, WatchRef, WatchSet, watch_key, }; -use shepherd_sdk_test::MockHost; +use nexum_sdk_test::MockHost; fn sample_owner() -> Address { address!("00112233445566778899aabbccddeeff00112233") @@ -23,7 +25,7 @@ fn sample_hash() -> B256 { #[test] fn watch_key_is_lowercase_prefixed_hex() { - let key = WatchSet::::key(&sample_owner(), &sample_hash()); + let key = watch_key(&sample_owner(), &sample_hash()); assert_eq!( key, concat!( @@ -35,7 +37,7 @@ fn watch_key_is_lowercase_prefixed_hex() { #[test] fn watch_key_round_trips_via_parse() { - let key = WatchSet::::key(&sample_owner(), &sample_hash()); + let key = watch_key(&sample_owner(), &sample_hash()); let watch = WatchRef::parse(&key).expect("parse"); assert_eq!( watch.owner_hex().parse::
().unwrap(), @@ -112,7 +114,7 @@ fn put_overwrites_in_place() { fn get_absent_watch_is_none() { let host = MockHost::new(); let watches = WatchSet::new(&host); - let key = WatchSet::::key(&sample_owner(), &sample_hash()); + let key = watch_key(&sample_owner(), &sample_hash()); let watch = WatchRef::parse(&key).unwrap(); assert_eq!(watches.get(watch).unwrap(), None); } @@ -351,3 +353,246 @@ fn submitted_and_observed_keyspaces_are_disjoint() { assert!(snapshot.contains_key("submitted:0xuid")); assert!(!snapshot.contains_key("observed:0xuid")); } + +// ---- retry ledger ---- + +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); + } +} + +// ---- conditional source ---- + +/// A source is generic over the host and owns its outcome shape; the +/// keeper passes the stored params verbatim and the tick it judged +/// the gates by. +#[test] +fn conditional_source_sees_params_and_tick_verbatim() { + struct EchoSource; + impl ConditionalSource 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); +} diff --git a/crates/nexum-world/Cargo.toml b/crates/nexum-world/Cargo.toml new file mode 100644 index 00000000..8b33d24b --- /dev/null +++ b/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/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs new file mode 100644 index 00000000..4a6aae1e --- /dev/null +++ b/crates/nexum-world/src/lib.rs @@ -0,0 +1,929 @@ +//! Per-module world synthesis: turn a manifest's `[capabilities]` +//! declarations into an inline WIT world whose imports are exactly the +//! declared capability interfaces. +//! +//! The one non-obvious invariant: the capability rows here must agree +//! with the runtime's capability registry (`nexum-runtime`'s manifest +//! enforcement) on both the capability names and the WIT interfaces they +//! map to. The runtime cross-checks a component's imports against the +//! manifest at load time; because the imports are derived from the same +//! manifest, a macro-built component passes that check by construction +//! rather than by relying on the toolchain eliding unused imports. +//! +//! The table here carries only the core `nexum:host` rows. Per-namespace +//! rows come from the composition root's `extensions.toml` registry +//! ([`manifest_extensions`]): the caller passes them to [`synthesize`], +//! so this crate carries no downstream name. + +use std::path::{Path, PathBuf}; +use strum::{EnumString, IntoStaticStr, VariantNames}; + +/// A core capability name: the single source the [`CORE`] table 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. Hand-written rather + /// than derived: [`CORE`] and [`CORE_IFACES`] evaluate it in const + /// context, and strum's `IntoStaticStr` emits a non-const `From`. + 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. +/// `IntoStaticStr` yields the label, `VARIANTS` the whole vocabulary. +#[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 both the guest allowlist (`nexum_sdk::chain::ChainMethod`) +/// and the host dispatch table (`nexum_runtime::host::component:: +/// ChainMethod`) emit from. 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 +/// crosses the WIT edge. This is the structural ceiling; an operator +/// allowlist narrows within it and 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. `&'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() + } +} + +/// 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, or `None` for + /// capabilities with no world import (`http` is granted through the + /// SDK's wasi:http client and the host allowlist, not the world). + 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 carrying this + /// capability's host-adapter pieces, if the SDK has a trait seam + /// for it. + 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's capability registry +/// enforces. `http` is absent (no world import). +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 a +/// composition root declares in its `extensions.toml`. An extension +/// always has a WIT import and never a host-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). Always + /// starts with the base set the host `event` variant needs. + pub packages: Vec, + /// Capability idents to pass to `bind_host_via_wit_bindgen!`. + pub adapters: Vec<&'static str>, +} + +/// Extract the declared capability names (`required` then `optional`) +/// from the manifest text. A missing or malformed `[capabilities]` +/// section is an error: the emitted world is derived from it, so the +/// synthesis has nothing to build from without one. +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) +} + +/// Extract the declared `[module] kind` from the manifest text, `None` +/// when absent (the runtime defaults an absent kind 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()), + } +} + +/// Parse the registered extension rows from an `extensions.toml`. Each +/// `[extensions.]` table carries the WIT `import` the declaration +/// turns into and the extra `packages` its resolve path needs. A file +/// without an `[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() +} + +/// Find the extension registry for a build rooted at `start`: the +/// nearest ancestor `extensions.toml`. `None` means no registered +/// extensions. +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 +} + +/// Build the per-module world from the declared capability names +/// (required and optional alike: an optional capability must still be +/// importable, the host decides at load time whether to back or stub +/// it). `extensions` carries the per-namespace rows of the registered +/// extensions, emitted after the core rows. Unknown names are an error +/// so a typo cannot silently drop an import; a registered name that +/// shadows a core row or another registration is an error so a +/// colliding registry cannot emit a duplicate import. +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 component build rooted at +/// `start` (the consuming crate's manifest directory). A package +/// resolves crate-locally, vendored `wit/deps/` before own +/// `wit/`; a crate not carrying it falls back to the nearest +/// ancestor `wit/` that does (the transitional monorepo layout). +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: + /// `nexum:host` is a leaf package, so it stands alone. + 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())); + } + + /// The const accessor is hand-written, so pin it to the derived + /// vocabulary in both directions. + #[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/crates/no-std-probe/Cargo.toml b/crates/no-std-probe/Cargo.toml new file mode 100644 index 00000000..0a5c6279 --- /dev/null +++ b/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/crates/no-std-probe/src/lib.rs b/crates/no-std-probe/src/lib.rs new file mode 100644 index 00000000..fd455704 --- /dev/null +++ b/crates/no-std-probe/src/lib.rs @@ -0,0 +1,14 @@ +//! Compile-only `#![no_std]` probe: `#[derive(IntentBody)]` must expand +//! without the consumer's std prelude or an `extern crate alloc`. + +#![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), +} 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/crates/shepherd/Cargo.toml b/crates/shepherd/Cargo.toml new file mode 100644 index 00000000..1de15410 --- /dev/null +++ b/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-launch" } +nexum-runtime = { path = "../nexum-runtime" } +videre-host = { path = "../videre-host" } + +anyhow.workspace = true +tokio.workspace = true diff --git a/crates/shepherd/src/main.rs b/crates/shepherd/src/main.rs new file mode 100644 index 00000000..b35ad0ee --- /dev/null +++ b/crates/shepherd/src/main.rs @@ -0,0 +1,49 @@ +//! The `shepherd` binary: the cow composition root. Boots the +//! reference backends, registers the videre venue platform, and hands +//! it all to the generic launcher; CoW enters only as the bundled +//! `cow-venue` adapter component, and the engine itself stays venue- +//! and cow-free. + +#![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/crates/videre-host/Cargo.toml b/crates/videre-host/Cargo.toml new file mode 100644 index 00000000..e45d28d3 --- /dev/null +++ b/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-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-runtime", features = ["test-utils"] } +nexum-tasks = { path = "../nexum-tasks" } +tempfile.workspace = true diff --git a/crates/videre-host/src/bindings.rs b/crates/videre-host/src/bindings.rs new file mode 100644 index 00000000..06e2b887 --- /dev/null +++ b/crates/videre-host/src/bindings.rs @@ -0,0 +1,275 @@ +//! WIT bindings for the venue platform, generated by +//! `wasmtime::component::bindgen!`. +//! +//! The shared `nexum:host` interfaces are reused from the runtime's +//! `event-module` bindings via `with`, so the `chain`/`messaging` `Host` +//! impls and the `fault` type an adapter sees are the very ones the core +//! host constructs. The `videre:types` and `videre:value-flow` types +//! generate in the adapter bindgen and the client bindgen remaps onto +//! them, so one Rust type serves the registry and the adapter face alike. +//! `PartialEq` is derived so the registry can compare a polled status +//! against the last delivered one. + +/// The provider face: the `videre:venue/venue-adapter` world. An adapter +/// imports only the scoped transport it needs (chain and messaging; +/// outbound HTTP is wasi:http, linked and allowlisted separately) 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. The +/// world imports the interface a module calls; the videre types it uses +/// are reused from the adapter bindings above via `with`, so the +/// `SubmitOutcome` and `VenueError` the registry hands back to a module +/// are the very ones an adapter's `submit` produced. Async, because the +/// `Host` impl awaits the per-adapter mutex and the adapter's own async +/// 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 the registry implements +/// and the `add_to_linker` the videre extension's linker hook calls. +pub use client_host::videre::venue::client; +/// The shared intent ontology, re-exported at the plain spellings the +/// registry and the `client::Host` impl name. +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 the wire `venue-error`: the bindgen +/// `Display` is the `{0:?}` debug form, so logs render through this +/// instead and the rate-limit `retry-after-ms` hint survives. +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"), + } +} + +#[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"); + } +} + +/// Bindgen smoke for the `videre:value-flow` types package, compiled under +/// test through a throwaway world that imports the interface. Its value is +/// the identifier-hygiene gate: the test names every generated type, +/// variant, and field by its plain Rust spelling, so a WIT id that collided +/// with a Rust keyword would surface as an `r#` escape and fail to compile +/// here rather than in a downstream binding. +#[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()); + } +} + +/// Bindgen smoke for the `videre:types` and `videre:venue` packages, +/// mirroring the value-flow smoke above: a throwaway world imports the +/// client interface and, transitively, the types interface and its +/// value-flow dependency. The test names every generated type, case, and +/// field by its plain Rust spelling, and a dummy `client` host impl pins +/// the five function signatures, so a keyword collision or an accidental +/// signature change fails this build rather than a downstream binding. +#[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/crates/videre-host/src/client.rs b/crates/videre-host/src/client.rs new file mode 100644 index 00000000..cbe27e70 --- /dev/null +++ b/crates/videre-host/src/client.rs @@ -0,0 +1,56 @@ +//! `videre:venue/client`: the keeper-facing venue import. Every method +//! resolves the shared [`VenueRegistry`] from the store's service map under +//! the videre namespace and delegates; the registry owns the venue +//! resolution, per-adapter serialisation, guard seam (advisory-only for +//! now), and quota. The caller identity the registry meters against is this +//! store's module namespace. No registry service means no venues, so every +//! call resolves to `unknown-venue`. The `Host` trait is local to this +//! crate's bindgen, so implementing it for the runtime's `HostState` is +//! orphan-legal. + +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/crates/videre-host/src/handshake.rs b/crates/videre-host/src/handshake.rs new file mode 100644 index 00000000..2544bd49 --- /dev/null +++ b/crates/videre-host/src/handshake.rs @@ -0,0 +1,291 @@ +//! Install-time body-version handshake over the `[venue]` manifest +//! section: a keeper declares the one body-schema version it encodes, +//! an adapter the set it decodes, and a keeper boots only when every +//! installed venue adapter decodes its version, since any of them is a +//! legal runtime submit target. + +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 `[venue]` section, when present, must be the +/// adapter shape with a non-empty version set. +/// +/// Opt-in: a manifest omitting `[venue]` is admitted unconditionally, an +/// intentional silent opt-out for non-venue keepers. +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 so the two sources of the +/// decode set cannot drift. +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(()) +} + +/// The membership install predicate: a worker declaring `[venue] +/// body_version` is admitted only when every installed venue adapter's +/// `[venue] body_versions` set contains that version. Every installed +/// venue is a legal runtime submit target, so one non-decoding adapter +/// refuses the keeper. +/// +/// Opt-in: a manifest omitting `[venue]` is admitted unconditionally, an +/// intentional silent opt-out for non-venue keepers. +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}"); + } + + /// Membership is a conjunction over the installed adapters: one + /// non-decoding adapter refuses the keeper even when another decodes + /// its version, since either is a legal runtime submit target. + #[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/crates/videre-host/src/lib.rs b/crates/videre-host/src/lib.rs new file mode 100644 index 00000000..82784126 --- /dev/null +++ b/crates/videre-host/src/lib.rs @@ -0,0 +1,186 @@ +//! The videre venue platform, packaged 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 registers it all +//! with `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, +}; + +/// Buffer for the status poll channel; small because the event loop +/// drains in real time. +const STATUS_CHANNEL_BUF: usize = 64; + +/// The venue platform over the config-resolved quota and watch policy, +/// with the unit guard. The single registration entrypoint. +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. Registers the +/// `videre:venue/client` interface and its capability namespace on every +/// worker linker, publishes the [`VenueRegistry`] service, installs the +/// venue-adapter provider kind, and opens the status-poll event source. +pub struct Videre { + registry: Arc, +} + +impl Videre { + /// Assemble over a pre-built registry, for a custom [`EgressGuard`] + /// or policy; [`platform`] covers the config-resolved default. + pub fn from_registry(registry: VenueRegistry) -> Self { + Self { + registry: Arc::new(registry), + } + } +} + +impl Extension for Videre { + fn namespace(&self) -> &'static str { + VenueRegistry::NAMESPACE + } + + /// Only the keeper-facing `client` interface is a capability; the + /// `videre:types` and `videre:value-flow` packages are type-only and + /// 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 source: on every cadence tick, poll each installed + /// adapter's status export through the shared registry and forward the + /// observed transitions. Opened only when a module subscribes and at + /// least one 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 first so +/// the engine's boot dispatch settles before the first poll; ends when +/// the event loop drops its receiver. +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/crates/videre-host/src/registry.rs b/crates/videre-host/src/registry.rs new file mode 100644 index 00000000..c33a70db --- /dev/null +++ b/crates/videre-host/src/registry.rs @@ -0,0 +1,1915 @@ +//! The venue registry: the keeper-facing `videre:venue/client` import +//! resolved to installed venue adapters. +//! +//! A module's `client::submit(venue, body)` reaches the host here. The +//! registry resolves the venue id to the one installed adapter that answers +//! for it, then drives a fixed sequence against that adapter: derive the +//! header, run the guard interposition seam on it (advisory-only for now: +//! see [`EgressGuard`]), and only then submit. +//! Status and cancel are pass-throughs; they are not submissions, so they +//! skip the header, the guard, and the quota. Observe puts an +//! externally-obtained receipt under status watch without touching the +//! adapter at all. +//! +//! Invocation is serialised per adapter through the supervised-actor +//! primitive: each adapter sits behind its own [`ActorSlot`], so concurrent +//! client calls to the same venue queue while calls to different venues run +//! in parallel. The lock is held across the guest await, which is the whole +//! point - it is the actor boundary that keeps one adapter store +//! single-threaded. +//! +//! Fuel cannot cross stores, so a module that spams undecodable bodies would +//! otherwise burn an adapter's budget for free. Two mechanisms close that: +//! a per-caller quota gates every quote and submit before the adapter is +//! touched, and a decode failure (the adapter's `invalid-body`) is charged +//! to the calling module's quota, so a caller feeding garbage exhausts its +//! own budget rather than the adapter's. + +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; + +/// The registry-observed status transition, carried in the `custom` +/// event's opaque payload, re-exported at the spelling the registry +/// names. +pub use videre_status_body::IntentStatusUpdate; + +use crate::bindings::{ + IntentHeader, IntentStatus, Quotation, RateLimit, SubmitOutcome, VenueAdapter, VenueError, +}; + +/// Venue identifier: the id an adapter registers under and a submission +/// names. 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) + } +} + +/// The guard interposition seam. The registry runs this on the +/// adapter-derived header after `derive-header` and before `submit`. +/// +/// Advisory-only: the checkpoint is not yet enforcing. A `Deny` verdict is +/// logged as a would-deny and the submission proceeds. The shipped policy is +/// the unit guard, which allows every egress; the egress-guard epic installs +/// the real facts-plus-analysers pipeline and turns the verdict enforcing, +/// without the registry changing shape. +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: who is submitting, to which venue, and the header the +/// adapter derived from the opaque body. The header is the stable ontology +/// policy has teeth on; the raw body never reaches the guard. +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 the egress with an operator-facing reason. Logged, not + /// enforced, while the seam is advisory-only. + Deny(String), +} + +/// The per-adapter invocation seam. One installed adapter answers for exactly +/// one venue; the registry owns the adapter's `Store` behind an async mutex +/// and reaches it only through this trait, so the registry's sequencing and +/// quota logic is testable against a stub that never spins up a wasmtime +/// store. +/// +/// The futures are boxed so the registry can hold heterogeneous adapters +/// behind one `dyn` slot without the whole registry turning generic over an +/// adapter type it never names. +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 where a previously submitted intent is in its life. The receipt + /// is owned: it is used once, unlike the body a submission re-decodes. + fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result>; + + /// Ask the venue to withdraw an intent. + fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>>; +} + +/// The live adapter: a [`SupervisedStore`] plus the `venue-adapter` +/// bindings. Each guest call is refuelled by the primitive; a trap is +/// projected onto `unavailable` rather than propagated, because a +/// misbehaving adapter must not be the caller's fault and must not unwind +/// through the registry into the calling module's store. +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 into the venue-error space. The fault carries +/// the root cause only, so an operator sees why the adapter died without +/// the wasm frame list leaking to the calling module. +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: the adapter slot plus the liveness the supervisor's +/// sweep shares with the actor. A dead entry stays installed, so the venue +/// resolves to `unavailable` (temporarily dead) rather than `unknown-venue` +/// (never installed) 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 the registry polls for status transitions. `last` starts +/// `None` so the first successful poll always reports, giving a +/// subscriber the intent's current state without waiting for a change. +/// `expires_at` is the give-up deadline, pushed a full `grace` window out +/// whenever the venue is reachable (it answered the poll, even if the body +/// then failed to encode); an unreachable venue rides out against it until +/// `grace` elapses. `None` (deadline arithmetic overflowed) never expires. +struct WatchedIntent { + venue: VenueId, + receipt: Vec, + last: Option, + expires_at: Option, +} + +/// A polled status is terminal when the intent can never change again: +/// the registry stops watching the receipt after reporting it. +fn is_terminal(status: IntentStatus) -> bool { + matches!( + status, + IntentStatus::Fulfilled | IntentStatus::Cancelled | IntentStatus::Expired + ) +} + +/// Lower a polled status onto the opaque status body the host `event` +/// stream carries. The registry attests the lifecycle state alone; proof +/// and failure reason ride the body only when the venue supplies them. +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, + } +} + +/// The shared registry state. Cloning a [`VenueRegistry`] is an `Arc` bump; +/// every module store carries the same handle, so a submission from any +/// module reaches the same adapters and the same quota ledger. Adapters +/// install through the shared handle at provider boot, before any client +/// call routes. +struct VenueRegistryInner { + adapters: Mutex>, + guard: Arc, + quota: SubmitQuota, + ledger: Mutex, + watch_limit: WatchLimit, + /// Receipts under status watch, appended by accepted submissions and + /// explicit observes, pruned as they reach a terminal status, expire, + /// or overflow [`WatchLimit`]. + watched: Mutex>, +} + +/// The keeper-facing venue registry, cheap to clone and shared across every +/// module store. +#[derive(Clone)] +pub struct VenueRegistry { + inner: Arc, +} + +/// The registry is the venue-routing host service. +impl HostService for VenueRegistry {} + +impl VenueRegistry { + /// Service namespace the registry publishes under: the videre + /// extension's. + pub const NAMESPACE: &'static str = "videre"; + + /// Install an adapter under its venue id, sharing `liveness` with its + /// invoker. Rejects a duplicate id while the incumbent is alive: two + /// adapters answering the same venue would silently shadow one another, + /// which is a config error worth failing boot over. A dead incumbent is + /// replaced: that is the sweep restarting a trapped adapter. + /// + /// Crate-internal: adapters install at provider boot, never post-boot + /// through a shared handle clone. + 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 installed adapter slot. An uninstalled + /// venue is `unknown-venue`; an installed but dead one is `unavailable` + /// pending the supervisor's restart sweep, without touching its + /// poisoned store. + 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 current window. Read-only: it + /// prunes aged charges but does not record one. + 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` on behalf of `caller`: resolve the + /// adapter, gate on the caller's quota, derive the header, run the guard + /// seam (advisory-only: a deny logs and the submission proceeds), then + /// forward to the adapter. A decode failure is charged to the + /// caller before returning, so a caller feeding garbage exhausts its own + /// budget and is stopped at the gate on the next call rather than + /// re-invoking the adapter. + /// + /// Charged once the header derives, ahead of the guard and adapter, so + /// a deny (when enforcing) or a venue outage is never a free retry. + /// Derive-stage venue errors other than a decode failure are left + /// 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` on behalf of `caller`. Not a + /// submission, so the header and guard are skipped (a quotation moves + /// no value), but it is adapter work on a caller-supplied body: the + /// caller's quota gates it and every quote spends one unit, so a + /// quote spammer exhausts its own budget, not the adapter's. + 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)` pair under status + /// watch: the `observe` half of the client face, for receipts the + /// registry never submitted (an accepted submit is watched implicitly). + /// Not a submission: no header, no guard, no quota; the watch cap + /// bounds it. 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)` pair under status watch, reporting + /// whether it is watched. Idempotent: a re-submitted receipt keeps + /// its existing watch entry. Bounded: expired entries evict first, + /// and at the cap the new watch is refused and logged rather than + /// an existing live watch 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 against its adapter's status export and + /// return the transitions: statuses that differ from the last one + /// reported for that receipt (the first successful poll always + /// reports). A terminal status is reported once and the receipt is + /// dropped from the watch. An unreachable venue (resolve failure or an + /// errored poll) leaves the entry to ride out against `grace` rather + /// than refreshing it; an entry whose `grace` has elapsed is evicted + /// unpolled and unreported. + 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)` when it + /// differs from the last reported status. The venue answered, so it is + /// reachable: every path here refreshes the give-up deadline (or drops + /// the entry on a terminal status reported cleanly), so a reachable + /// venue never expires this cadence. An encode failure therefore costs + /// the update, not the watch: the deadline is still refreshed and the + /// entry retried next cadence. `None` also covers an entry that + /// disappeared 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 where a previously submitted intent is in its life. Not a + /// submission: no header, no guard, no quota, just the serialised call. + 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. Not a submission, so it skips the + /// header, guard, and quota like `status`. + 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() + } +} + +/// The venue-adapter provider kind: boots a `videre:venue/venue-adapter` +/// component and installs its actor in the venue registry. Registered +/// through the videre extension's provider slot. +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, and watch bounds +/// freeze at build; adapters install afterwards through the shared handle +/// at provider boot. The guard defaults to the unit guard; the egress-guard +/// epic overrides it here. +pub struct VenueRegistryBuilder { + guard: Arc, + quota: SubmitQuota, + watch_limit: WatchLimit, +} + +impl VenueRegistryBuilder { + /// Start an empty 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. The egress-guard epic wires the real + /// pipeline through here; tests inject a denying policy to prove the + /// advisory seam. + 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, + } + } + + /// A programmable adapter that records call counts and returns canned + /// outcomes, so the registry's sequencing, guard seam, and quota are + /// tested without a wasmtime store. + #[derive(Default)] + struct StubCalls { + derive: AtomicUsize, + quote: AtomicUsize, + submit: AtomicUsize, + status: AtomicUsize, + cancel: AtomicUsize, + /// Highest number of overlapping invocations observed; proves the + /// per-adapter mutex serialises access. + max_concurrency: AtomicUsize, + live: AtomicUsize, + } + + struct StubAdapter { + calls: Arc, + derive: Result, + submit: Result, + /// Accept each submission with its body as the receipt, so one + /// stub can mint distinct receipts. + 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/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs new file mode 100644 index 00000000..3011ee60 --- /dev/null +++ b/crates/videre-host/tests/platform.rs @@ -0,0 +1,1211 @@ +//! 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. Exercises pre-built wasm artefacts and skips gracefully when +//! an 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 ─────────────────────────────────────────────── + +/// Workspace-root-relative path. `CARGO_MANIFEST_DIR` is +/// `crates/videre-host`; two parents up is the workspace root. +fn workspace_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates dir") + .parent() + .expect("workspace root") + .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 under test plus the extension slice the boot paths take. +/// The concrete handle stays available for the 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 under the videre +/// namespace. +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: +/// the transition rides the generic `custom` channel, its tagged borsh +/// envelope the opaque payload. +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 ──────────────────────────────────────────────────── + +/// The per-component venue-adapter world contract: an adapter built +/// through `#[videre_sdk::venue]` imports exactly the scoped +/// transport its manifest declares (`chain`), by construction of the +/// emitted world. The venue side never depended on toolchain elision; +/// this pins that it does not regress to it. +#[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 honours the same contract: outbound HTTP is +/// its only capability, so the component structurally cannot reach +/// chain, messaging, host 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 +/// (chain, messaging, wasi base, allowlisted http) and withholds the +/// core-only interfaces. Assembling it proves the scope wires without a +/// duplicate-definition clash between the shared `nexum:host` interfaces. +#[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 ──────────────────────────────────── + +/// A scripted venue adapter for the registry: accepts every submission +/// with a fixed receipt and serves statuses front-first from a script; +/// once drained, every further call 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") +} + +/// The acceptance path: a module subscribed to `intent-status` receives +/// the transitions the registry observed by polling the adapter's status +/// export, and a transition from a venue outside its 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 on the venue +/// platform 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("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 against the booted service map, its poll task drives the +/// supervisor, and the module's handler observably ran (its log line is +/// retained). +#[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 ─────────────────────────────────────────────────── + +/// The acceptance path, end to end over two real components: the +/// echo-client module submits through `videre:venue/client`, the host +/// registry forwards to the installed echo-venue adapter, and the module +/// receives the fulfilled `intent-status` the registry polls back. Proves +/// the intent core round-trips module -> host registry -> venue adapter +/// with no scripted stand-ins on either side. +#[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("modules/examples/echo-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path("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 blessed keeper path over the same 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, all with a typed body - and receives +/// the fulfilled `intent-status` the registry polls back. Proves the +/// macro-emitted worker and the typed client end to end, with no +/// hand-written byte marshalling on the keeper side. +#[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("modules/examples/echo-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path("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 installed cow adapter - the body-version +/// handshake admits the pair - and a Sepolia block dispatch reaches it +/// and keeps it alive. The chainless poll surfaces a fault the strategy +/// absorbs, so no orderbook traffic occurs. +#[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("crates/cow-venue/module.sepolia.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path("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 sweep 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 at the +/// keeper's install, before instantiation, naming both sides' versions. +#[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 manifest claims versions its code does not decode +/// fails its own install: the `body-versions()` export must equal the +/// manifest `[venue] body_versions` set. +#[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, whose head starts at +/// the fixture's poison sentinel. Returns the chain handle so the test can +/// let the venue recover. +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("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 full trap-to-recovery lifecycle over a real wasm adapter: a trapped +/// venue is temporarily dead (`unavailable`, not `unknown-venue`) and the +/// provider restart sweep reinstantiates it after backoff, after which a +/// submit 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 provider poison sweep: +/// at the threshold the restarts stop, and the venue stays dead past every +/// backoff until an operator intervenes. +#[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: forwards +/// every face `boot_single` consults to [`Videre`] save `service`, which +/// returns `None`, so `HostServices::from_extensions` seeds no venue +/// registry. The provider and event faces default (unused under +/// `boot_single`). +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, end to end: a keeper importing +/// `videre:venue/client` boots through `boot_single` with no registry +/// service seeded, so `client.rs` resolves every venue call to +/// `unknown-venue` before any adapter is consulted. Distinct from the +/// adapter-map miss, where the registry is present and the venue id is +/// merely 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/crates/videre-host/tests/zero_leak.rs b/crates/videre-host/tests/zero_leak.rs new file mode 100644 index 00000000..43f32ea5 --- /dev/null +++ b/crates/videre-host/tests/zero_leak.rs @@ -0,0 +1,155 @@ +//! 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}; + +/// Workspace-root-relative path. `CARGO_MANIFEST_DIR` is +/// `crates/videre-host`; two parents up is the workspace root. +fn workspace_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates dir") + .parent() + .expect("workspace root") + .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("modules/examples/echo-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path("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/crates/videre-macros/Cargo.toml b/crates/videre-macros/Cargo.toml new file mode 100644 index 00000000..7cf8ec6f --- /dev/null +++ b/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-world", features = ["macros"] } +proc-macro2.workspace = true +quote.workspace = true +syn = { workspace = true, features = ["full"] } diff --git a/crates/videre-macros/src/intent_body.rs b/crates/videre-macros/src/intent_body.rs new file mode 100644 index 00000000..8ebbc3d6 --- /dev/null +++ b/crates/videre-macros/src/intent_body.rs @@ -0,0 +1,141 @@ +//! Expansion for `#[derive(IntentBody)]`: the borsh codec over a +//! per-venue version enum. +//! +//! The derive enforces the outer-enum shape at compile time (an enum of +//! newtype variants, one published body version per variant) and emits +//! `to_bytes` / `from_bytes` whose wire form is the borsh enum layout: a +//! one-byte version tag (the variant's declaration index) followed by the +//! borsh-encoded payload. Decoding matches the tag itself, so an unknown +//! version surfaces as the typed `BodyError::UnknownVersion` rather than +//! a stringly borsh error, and a known version delegates the payload to +//! its type's `BorshDeserialize`. +//! +//! Generated code names the venue SDK by its crate path +//! (`::videre_sdk`), so the derive is only usable through that +//! crate's re-export. The expansion names only `::core` and the SDK's +//! `__private` re-exports (borsh, `alloc`), 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, or a compile +/// error naming the shape rule the input broke. +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/crates/videre-macros/src/keeper.rs b/crates/videre-macros/src/keeper.rs new file mode 100644 index 00000000..9af9c569 --- /dev/null +++ b/crates/videre-macros/src/keeper.rs @@ -0,0 +1,322 @@ +//! Expansion for `#[keeper]`: the keeper-worker mirror of `#[module]`. +//! +//! Same world synthesis and event dispatch as the plain module macro, +//! with the keeper deltas: the `client` capability is required, the +//! videre interfaces remap onto the SDK bindings (one shim set, one +//! type identity for the typed client), async handlers complete via +//! `videre_sdk::rt::complete`, 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; the `#[module]` +/// set, since a keeper is a plain worker. +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, or a compile +/// error naming the rule the input broke. +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::rt::complete(#call) { + ::core::option::Option::Some(result) => result, + ::core::option::Option::None => ::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 the consuming crate's `module.toml`, require the worker shape +/// (no `[module] kind`) and the `client` capability, and synthesize the +/// per-module world with the client extension row guaranteed. Returns +/// the rebuild anchor paths alongside 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/crates/videre-macros/src/lib.rs b/crates/videre-macros/src/lib.rs new file mode 100644 index 00000000..a9cb5677 --- /dev/null +++ b/crates/videre-macros/src/lib.rs @@ -0,0 +1,244 @@ +//! Proc-macro glue for the two videre personas. +//! +//! [`venue`] is the single blessed venue authoring path: applied to an +//! `impl VenueAdapter` block it emits the per-cdylib wit-bindgen for a +//! manifest-derived world exporting `videre:venue/adapter`, asserts the +//! manifest kind, and expands to the SDK's internal export codegen. +//! +//! [`keeper`] is its worker mirror: applied to a handler impl it emits +//! the per-cdylib wit-bindgen for a manifest-derived module world, +//! wires the `videre:venue/client` import onto the SDK's shared shims, +//! and dispatches events to the handlers, completing async ones on the +//! synchronous guest boundary. +//! +//! [`derive@IntentBody`] implements the venue SDK's versioned body codec +//! over a per-venue version enum. +//! +//! The plain module macro (`#[module]`) lives in `nexum-module-macros`. +//! +//! Consumers reach these through the SDK re-exports +//! (`videre_sdk::venue`, `videre_sdk::keeper`, `videre_sdk::IntentBody`) +//! rather than depending on this crate directly. + +mod intent_body; +mod keeper; +mod world; + +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, ItemImpl}; + +/// Derive the venue SDK's `IntentBody` codec on the outer per-venue +/// version enum: one newtype variant per published body version, each +/// payload a borsh type. +/// +/// The wire form is the borsh enum layout (a one-byte 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`. +/// +/// Generated code resolves the SDK by crate path, so use the +/// `videre_sdk::IntentBody` re-export with `videre-sdk` as a +/// direct dependency. +#[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. Mirrors the +/// venue-adapter provider kind's manifest spelling. +const VENUE_KIND: &str = "venue-adapter"; + +/// Generate the per-cdylib glue for a venue adapter. +/// +/// Apply 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 `wit_bindgen::generate!`, the +/// untouched trait impl, and the SDK's internal export codegen wiring +/// the world's `Guest` faces through the trait. So the built component +/// imports what the manifest declares and nothing else, by construction +/// of the emitted world. +/// +/// The generated world remaps `videre:types/types`, +/// `videre:value-flow/types`, and `nexum:host/types` onto the SDK's +/// bindings, so the impl speaks `videre_sdk` types directly and shares +/// type identity with the conformance kit and the client core. +/// +/// A venue's capabilities are scoped transport only: 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 same crate-root resolution invariants as `#[module]` apply: the +/// wit-bindgen output lands at the module crate root (so the export +/// codegen resolves `Guest`, `exports`, and `export!` there), and the +/// consuming crate must declare `wit-bindgen` and `videre-sdk` as +/// direct dependencies. +#[proc_macro_attribute] +pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { + if !attr.is_empty() { + return syn::Error::new( + proc_macro2::Span::call_site(), + "#[videre_sdk::venue] takes no arguments", + ) + .to_compile_error() + .into(); + } + + let input = syn::parse_macro_input!(item as ItemImpl); + + 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 that drives +/// venues through the typed client. +/// +/// The keeper-author mirror of `#[module]`. Apply to an `impl` block +/// whose associated functions are the event handlers (`init`, +/// `on_block`, `on_chain_logs`, `on_tick`, `on_message`, +/// `on_intent_status`); handlers may be `async` and are completed on +/// the synchronous guest boundary (`videre_sdk::rt::complete`), so a +/// handler can await the typed `VenueClient` directly. +/// +/// The macro reads the crate's `module.toml`, requires the `client` +/// capability (the `videre:venue/client` import is what makes a keeper +/// a keeper), synthesizes the per-module world exactly as `#[module]` +/// does, and remaps the videre interfaces onto the SDK bindings: the +/// module's client import resolves to the SDK's shared shims, so the +/// `VenueClient` a handler drives and the wire speak one set of types. +/// A `From` impl onto the wire fault is emitted, so `?` +/// applies to client calls inside handlers. +/// +/// The same crate-root resolution invariants as `#[module]` apply, and +/// 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 the consuming crate's `module.toml`, assert it declares the +/// venue-adapter kind, and synthesize the per-component venue-adapter +/// world from its `[capabilities]` declarations. Returns the manifest +/// path (for the rebuild anchor) alongside 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/crates/videre-macros/src/world.rs b/crates/videre-macros/src/world.rs new file mode 100644 index 00000000..081a4b3f --- /dev/null +++ b/crates/videre-macros/src/world.rs @@ -0,0 +1,155 @@ +//! 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. A venue speaks one venue's +/// protocol over scoped transport and nothing else: chain RPC, +/// messaging, and outbound HTTP (granted through the SDK's wasi:http +/// client, so no world import). It structurally cannot reach host key +/// material or persistent state, so `local-store`, `remote-store`, +/// `identity`, and `logging` are refused rather than silently imported. +const VENUE_CAPABILITIES: &[&str] = &["chain", "messaging", "http"]; + +/// Build the per-component venue-adapter world from the declared +/// capability names. The world exports `init` and the +/// `videre:venue/adapter` face and imports exactly the declared scoped +/// transport, so a macro-built adapter's imports equal its declarations +/// by construction. A capability outside the venue-permitted set is a +/// compile error: an adapter that reaches for host key material or +/// persistent state is rejected at expansion, not at boot. +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/crates/videre-sdk/Cargo.toml b/crates/videre-sdk/Cargo.toml new file mode 100644 index 00000000..0c87c63b --- /dev/null +++ b/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 sweep 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-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 sweep's non-critical store cleanups. +tracing.workspace = true +wit-bindgen.workspace = true + +[dev-dependencies] +# In-memory `LocalStoreHost` behind the keeper sweep tests. +nexum-sdk-test = { path = "../nexum-sdk-test" } diff --git a/crates/videre-sdk/src/adapter.rs b/crates/videre-sdk/src/adapter.rs new file mode 100644 index 00000000..0e6f3edb --- /dev/null +++ b/crates/videre-sdk/src/adapter.rs @@ -0,0 +1,116 @@ +//! 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: `init` from the +//! world itself, the intent functions and the body-version declaration +//! from `videre:venue/adapter`. +//! Functions are associated (no `self`): the component model instantiates +//! one adapter per venue and calls exports statically, so adapter state +//! lives in the adapter's own statics, exactly as in event modules. + +use crate::{Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueError}; + +/// One venue's protocol speaker: the guest-side face of the +/// `venue-adapter` world. Implement it on a unit struct and apply +/// [`#[videre_sdk::venue]`](crate::venue) to the impl; bodies and +/// receipts arrive as the opaque bytes the wire carries, and impls +/// recover typing through [`IntentBody`](crate::IntentBody) (whose +/// [`BodyError`](crate::BodyError) converts into [`VenueError`] via `?`). +pub trait VenueAdapter { + /// Configure the adapter from its `[config]` table before any + /// submission. Mirrors the event-module `init`, so the supervisor + /// boots both component kinds through the same machinery. + fn init(config: Config) -> Result<(), Fault>; + + /// Body-schema versions this adapter decodes. Install asserts it + /// equals the manifest `[venue] body_versions` set. Defaults to + /// declaring none. + fn body_versions() -> Vec { + Vec::new() + } + + /// Project an opaque intent body onto the stable header guard + /// policy runs on. Must be a pure derivation: no transport, no side + /// effects, so the host can inspect a header before deciding to + /// submit. The host's guard checkpoint is advisory-only until the + /// egress-guard epic lands: a would-deny is logged, not enforced. + fn derive_header(body: Vec) -> Result; + + /// Price an opaque intent body: an indicative quotation, not an + /// offer the venue is bound to fill. + fn quote(body: Vec) -> Result; + + /// Submit an opaque intent body to this adapter's venue. Success is + /// either 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. + 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. + fn cancel(receipt: Vec) -> Result<(), VenueError>; +} + +/// Internal codegen `#[videre_sdk::venue]` expands to: a hidden shim +/// wiring a [`VenueAdapter`] impl to the macro-synthesized world's +/// `Guest` faces, then that world's `export!`. `Guest`, `exports`, and +/// `export!` resolve at the expansion site (the adapter crate root, +/// where the attribute put the world's bindgen), so the macro is +/// meaningful only inside the attribute's output. Not public API. +#[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> { + <$adapter as $crate::VenueAdapter>::status(receipt) + } + + fn cancel( + receipt: ::std::vec::Vec, + ) -> ::core::result::Result<(), $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::cancel(receipt) + } + } + + export!(__VidereVenueAdapterExport); + }; +} diff --git a/crates/videre-sdk/src/bindings.rs b/crates/videre-sdk/src/bindings.rs new file mode 100644 index 00000000..9f57dde8 --- /dev/null +++ b/crates/videre-sdk/src/bindings.rs @@ -0,0 +1,43 @@ +//! Guest bindings for the videre SDK, generated once from an +//! import-only inline world carrying every interface both personas +//! speak: the videre type vocabulary, the host types and scoped +//! transport, and the keeper-facing `videre:venue/client` shims. +//! +//! Unlike event modules, which run `wit_bindgen::generate!` per cdylib, +//! the SDK generates these bindings once: the +//! [`VenueAdapter`](crate::VenueAdapter) trait, the typed transport +//! wrappers, and the client core are all expressed over them. The +//! per-cdylib bindgens (`#[videre_sdk::venue]`, `#[videre_sdk::keeper]`) +//! remap the shared interfaces onto these modules with `with`, so a +//! macro-built component shares type identity (and, for the keeper's +//! client, one shim set) with the SDK and the conformance kit. +//! +//! The world is import-only on purpose: an embedded world section is +//! unioned into every linking module at componentization, where an +//! unused import prunes but an export is a hard obligation. Keeping the +//! adapter export out of the SDK world is what lets a keeper module +//! link this crate without being asked to be a venue; the export face +//! is emitted per-cdylib by `#[videre_sdk::venue]` alone. + +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/crates/videre-sdk/src/body.rs b/crates/videre-sdk/src/body.rs new file mode 100644 index 00000000..33c40378 --- /dev/null +++ b/crates/videre-sdk/src/body.rs @@ -0,0 +1,102 @@ +//! The versioned intent-body codec: [`IntentBody`] and its typed +//! [`BodyError`]. +//! +//! An intent body crosses the pool and adapter boundaries as opaque +//! bytes; typing is recovered guest-side against the venue's published +//! schema. That schema is an outer version enum whose wire form is the +//! borsh enum layout: a one-byte version tag (the variant's declaration +//! index) followed by the borsh-encoded payload. `#[derive(IntentBody)]` +//! (re-exported at the crate root) implements the codec over such an +//! enum and is the intended way to get an impl; the derive owns the tag +//! handling, so an unknown version fails as the typed +//! [`BodyError::UnknownVersion`] instead of a stringly borsh error. +//! +//! The one non-obvious invariant: the tag order is the schema. Venues +//! append new versions at the end and never reorder or remove variants. + +use strum::IntoStaticStr; + +use crate::VenueError; + +/// The codec between a venue's typed body enum and the opaque bytes the +/// pool and adapter boundaries carry. Sealed to +/// `#[derive(IntentBody)]` on the outer version enum: the derive owns +/// the tag rules, so no hand impl can break them. +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 for log and +/// metric fields. +#[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. The + /// decodable-future-versions story lives here: a v1 adapter handed + /// a v2 body reports the exact unknown tag instead of garbling the + /// payload. + #[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; derived payloads encode + /// infallibly. + #[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 malformed body (`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; not a +/// public surface. `alloc` rides along so the expansion resolves in a +/// `#![no_std]` consumer without its own `extern crate alloc`. +#[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/crates/videre-sdk/src/client.rs b/crates/videre-sdk/src/client.rs new file mode 100644 index 00000000..63ff375d --- /dev/null +++ b/crates/videre-sdk/src/client.rs @@ -0,0 +1,310 @@ +//! The typed venue client: [`VenueClient`] binds one [`Venue`] over the +//! byte-level [`VenueTransport`] seam. +//! +//! The wire carries opaque bodies and a stringly venue selector; typing +//! is recovered here. A venue is named once, as a [`Venue`] marker +//! carrying its [`VenueId`] and body schema, and every call encodes +//! through [`IntentBody`] before the seam, so keeper code never handles +//! wire bytes. [`HostVenues`] is the seam bound to the module's own +//! `videre:venue/client` import; tests and in-process adapters +//! implement [`VenueTransport`] directly. The transport methods are +//! native AFIT, so dispatch is static and nothing on the call path +//! boxes. + +use std::borrow::Cow; +use std::fmt; +use std::future::Future; +use std::marker::PhantomData; + +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: the [`Venue::ID`] spelling. + #[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: the id its adapter registers under +/// and the body schema it decodes. 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 marker for [`VenueTransport`]: a transport opts in by also +/// implementing it. +#[doc(hidden)] +pub mod sealed { + pub trait SealedTransport {} +} + +/// 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. [`HostVenues`] binds it to +/// the module's own import; tests implement it in memory. +/// +/// 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`: a transport that can watch foreign receipts opts + /// in. + 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>; +} + +/// The module's `videre:venue/client` import behind the +/// [`VenueTransport`] seam: the transport every guest-side +/// [`VenueClient`] defaults to. +#[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`] to wire bytes 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 (tests, + /// in-process adapters). + 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 + } + + /// 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. + pub async fn status(&self, receipt: &[u8]) -> Result { + Ok(self.transport.status(&V::ID, receipt).await?) + } + + /// Ask the bound venue to withdraw an intent. + pub async fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { + 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. Consuming it with +/// [`submit`](Self::submit) is the only way from a quote to a +/// submission, so a keeper cannot submit a body other than the one +/// quoted. +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 for log and +/// metric fields. +#[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), +} diff --git a/crates/videre-sdk/src/event.rs b/crates/videre-sdk/src/event.rs new file mode 100644 index 00000000..31a188a0 --- /dev/null +++ b/crates/videre-sdk/src/event.rs @@ -0,0 +1,91 @@ +//! Typed recovery of videre events from the core `custom` channel. +//! +//! A venue transition reaches a module as a `custom` event; this module +//! decodes it 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/crates/videre-sdk/src/faults.rs b/crates/videre-sdk/src/faults.rs new file mode 100644 index 00000000..ac57203f --- /dev/null +++ b/crates/videre-sdk/src/faults.rs @@ -0,0 +1,233 @@ +//! Conversions between the three 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. +//! +//! Every conversion here is lossy only downward (a structured case folds +//! to a payload-bearing string case, never the reverse), so `?` in an +//! adapter always 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` with `Display`: what typed +/// client code reports when the registry or a venue refuses. The +/// structured retry hint (`rate-limited`'s `retry-after-ms`) survives +/// the lift. +/// +/// `IntoStaticStr` yields a snake_case label per case for log and +/// metric fields. +#[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, +} + +/// Lift the wire error into the owned mirror. Exhaustive: the wire enum +/// is this crate's own bindgen, so a new WIT case fails here first. +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, + } + } +} + +/// Lift the wire fault into the SDK-neutral vocabulary the transport +/// seams and `nexum-sdk` helpers speak. Exhaustive: the wire enum is +/// this crate's own bindgen, so a new WIT case fails here first. +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, so a helper's `host::Fault` propagates with `?`. +/// +/// Carries a wildcard arm because `host::Fault` is `#[non_exhaustive]`: +/// a future SDK 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; `unavailable` keeps its detail; the caller-shaped cases +/// (`invalid-input`, `internal`) fold to retryable `unavailable` because +/// inside an intent function the transport's caller is the adapter +/// itself, never the module. +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 and a misnamed venue are the +/// caller's `invalid-input`; venue 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, + }, + } + } +} + +/// 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 (including a request the adapter +/// itself malformed) 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/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs new file mode 100644 index 00000000..c0418106 --- /dev/null +++ b/crates/videre-sdk/src/keeper.rs @@ -0,0 +1,635 @@ +//! The generic keeper sweep: one pass assembling the world-neutral +//! stores - [`WatchSet`] to [`Gates`] to [`ConditionalSource::poll`] to +//! [`Retrier`] to [`Journal`] - and routing submissions through the +//! [`VenueTransport`] seam. +//! +//! [`Sweep`] is the shared poll outcome: the concrete +//! [`ConditionalSource::Outcome`] a keeper's sources produce so +//! [`Keeper::sweep`] can act on every one of them. The world-neutral +//! primitives stay in `nexum_sdk::keeper`; this module only assembles +//! them. + +use nexum_sdk::host::{Fault, LocalStoreHost}; +use nexum_sdk::keeper::{ + ConditionalSource, Gates, Journal, 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 sweep to do with its watch. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Sweep { + /// 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, +} + +/// A keeper: one conditional source bound to one venue, swept over the +/// keeper stores. +pub struct Keeper { + source: S, + venues: P, + venue: VenueId, +} + +impl Keeper { + /// Bind a source to the venue id its submissions route to. + pub fn new(source: S, venues: P, venue: impl Into) -> Self { + Self { + source, + venues, + venue: venue.into(), + } + } + + /// The venue every submission routes to. + pub fn venue(&self) -> &VenueId { + &self.venue + } +} + +impl Keeper { + /// Sweep the watch set once at `tick`: poll every ready watch, + /// submit [`Sweep::Submit`] bodies through the venue seam, and + /// run every other outcome and every venue refusal through the + /// [`Retrier`]. The [`submission_key`] is reserved on the + /// `submitted:` [`Journal`] before every submit and committed on + /// acceptance - the watch's first-refusal marker is then cleared + /// best-effort. The reserve is a durable write before the venue + /// call, so a record write that faults after an accepted submit, or + /// a trap after acceptance, still leaves the marker and the next + /// sweep does not re-post. A non-accepting outcome releases the + /// reservation, so a `requires-signing` answer or a retryable + /// refusal is retried afresh. The residual runs the other way: a + /// crash between the reserve and the venue call strands the marker, + /// deferring an unsent body, never duplicating a sent one. Store + /// faults abort the sweep, bar the post-acceptance record and marker + /// clear; venue refusals never do - they fold into per-watch retry + /// actions. + pub async fn sweep(&self, host: &H, tick: &Tick) -> Result + where + H: LocalStoreHost, + S: ConditionalSource, + { + let watches = WatchSet::new(host); + let gates = Gates::new(host); + let retrier = Retrier::new(host); + let journal = Journal::submitted(host); + let mut report = SweepReport::default(); + + 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) { + Sweep::Submit(body) => { + let key = submission_key(&self.venue, &body); + if journal.contains(&key)? { + report.duplicates += 1; + continue; + } + // Reserve before the venue call: a record write that + // faults after acceptance still leaves this marker, + // so the next sweep does not re-post. + journal.reserve(&key)?; + match self.venues.submit(&self.venue, body).await { + Ok(SubmitOutcome::Accepted(_)) => { + // Commit best-effort: the reservation already + // guards the re-post, so a record fault must + // not abort the sweep and unwind it. + if let Err(fault) = journal.record(&key) { + tracing::error!( + %fault, + "record write failed after accepted submit", + ); + } + if let Err(fault) = retrier.clear_refusal(watch) { + tracing::error!( + %fault, + "refusal-marker clear failed after journalled acceptance", + ); + } + report.submitted += 1; + continue; + } + Ok(SubmitOutcome::RequiresSigning(tx)) => { + journal.release(&key)?; + report.unsigned.push(tx); + continue; + } + Err(fault) => { + journal.release(&key)?; + retry_action(&fault) + } + } + } + Sweep::WaitBlock => RetryAction::TryNextBlock, + Sweep::Backoff { seconds } => RetryAction::Backoff { seconds }, + Sweep::Drop => RetryAction::Drop, + }; + match action { + RetryAction::Drop => report.dropped += 1, + _ => report.retried += 1, + } + retrier.apply(watch, action, tick)?; + } + Ok(report) + } +} + +/// One sweep's tally, by watch disposition. +#[derive(Clone, Debug, Default, PartialEq)] +#[non_exhaustive] +pub struct SweepReport { + /// Watches polled. + pub polled: usize, + /// Watches skipped by an unexpired gate. + pub gated: usize, + /// Watches skipped unread: a malformed key, or a row that vanished + /// mid-sweep. + pub skipped: usize, + /// Bodies the venue accepted, submission key newly journalled. + pub submitted: usize, + /// Bodies whose key an earlier sweep had journalled, skipped + /// without a venue call. + pub duplicates: usize, + /// Watches left in place for a later tick. + pub retried: usize, + /// Watches dropped. + pub dropped: usize, + /// Transactions the venue answered `requires-signing`; a sweep + /// cannot sign, so the caller owns them. + pub unsigned: Vec, +} + +/// Deterministic pre-submit journal key: the venue id and the +/// keccak-256 of the body. The hash is a fixed-length suffix, so the +/// key is unambiguous whatever the venue id contains. Public so a +/// keeper journalling outside [`Keeper::sweep`] writes the key the +/// sweep checks. +pub fn submission_key(venue: &VenueId, body: &[u8]) -> String { + format!("{venue}:{}", hex::encode_prefixed(keccak256(body))) +} + +/// Fold a venue refusal into the retry action the ledger runs: the +/// throttle hint becomes an epoch gate, transient failures retry next +/// block, and refusals no retry can cure drop the watch. Public so a +/// keeper sweeping outside [`Keeper::sweep`] folds refusals the same +/// way. +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(_) => RetryAction::Drop, + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use nexum_sdk::host::{Fault, LocalStoreHost}; + use nexum_sdk::keeper::{Gates, Journal, Tick, WatchRef, WatchSet}; + use nexum_sdk::prelude::{Address, B256, hex, keccak256}; + use nexum_sdk_test::MockLocalStore; + + use super::{Keeper, Sweep, SweepReport}; + use crate::client::{VenueId, VenueTransport}; + use crate::{IntentStatus, Quotation, SubmitOutcome, UnsignedTx, VenueFault}; + + /// Drive a sweep on the test's synchronous boundary. + fn run(future: F) -> F::Output { + crate::rt::complete(future).expect("sweep futures complete in one poll") + } + + /// Answers every poll with one programmed outcome. + struct StubSource(Sweep); + + impl nexum_sdk::keeper::ConditionalSource for StubSource { + type Outcome = Sweep; + + fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Sweep { + self.0.clone() + } + } + + /// Pops one programmed outcome per poll, from the back. + struct SeqSource(RefCell>); + + impl nexum_sdk::keeper::ConditionalSource for SeqSource { + type Outcome = Sweep; + + fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Sweep { + self.0.borrow_mut().pop().unwrap_or(Sweep::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: Sweep, 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(Sweep::Submit(b"body".to_vec()), &venue); + + let report = run(keeper.sweep(&host, &TICK)).expect("sweep 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 sweep re-polls the watch but never re-posts the body. + let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); + assert_eq!(report.submitted, 0); + assert_eq!(report.duplicates, 1); + assert_eq!(venue.submitted.borrow().len(), 1); + } + + /// Wraps the mock and faults the commit write (the empty marker), + /// letting the non-empty reserve through: the post-accept record + /// fault the reservation is meant to survive. + struct FailCommit(MockLocalStore); + + impl LocalStoreHost for FailCommit { + fn get(&self, key: &str) -> Result>, Fault> { + self.0.get(key) + } + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { + if value.is_empty() { + return Err(Fault::Unavailable("commit store down".into())); + } + self.0.set(key, value) + } + fn delete(&self, key: &str) -> Result<(), Fault> { + self.0.delete(key) + } + fn list_keys(&self, prefix: &str) -> Result, Fault> { + self.0.list_keys(prefix) + } + } + + #[test] + fn record_fault_after_acceptance_does_not_resubmit() { + let host = FailCommit(MockLocalStore::default()); + put_watch(&host.0); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![0xAA]))); + let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); + + // Reserve lands, submit is accepted, the commit write faults and + // is swallowed - the reservation persists. + let report = + run(keeper.sweep(&host, &TICK)).expect("a record fault must not abort the sweep"); + assert_eq!(report.submitted, 1); + assert_eq!(venue.submitted.borrow().len(), 1); + let key = format!("stub:{}", hex::encode_prefixed(keccak256(b"body"))); + assert!( + Journal::submitted(&host).contains(&key).expect("reads"), + "the reservation survives a faulted commit", + ); + + // The next sweep re-polls but the reservation blocks the re-post. + let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); + assert_eq!(report.submitted, 0); + assert_eq!(report.duplicates, 1); + assert_eq!( + venue.submitted.borrow().len(), + 1, + "an accepted body is never re-posted", + ); + } + + #[test] + fn retryable_submit_fault_still_retries() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = StubVenue::new(Err(VenueFault::Unavailable("down".into()))); + let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); + + // Each transient fault releases the reservation, so the body + // reaches the venue again rather than being skipped for good. + assert_eq!( + run(keeper.sweep(&host, &TICK)).expect("sweep runs").retried, + 1 + ); + assert_eq!( + run(keeper.sweep(&host, &TICK)).expect("sweep runs").retried, + 1 + ); + assert_eq!( + venue.submitted.borrow().len(), + 2, + "a retryable fault must not permanently skip the watch", + ); + let key = format!("stub:{}", hex::encode_prefixed(keccak256(b"body"))); + assert!( + !Journal::submitted(&host).contains(&key).expect("reads"), + "a released reservation leaves no marker", + ); + } + + #[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(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) + .expect("sweep 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(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) + .expect("marker-clear fault must not abort the sweep"); + 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![ + Sweep::Submit(b"two".to_vec()), + Sweep::Submit(b"one".to_vec()), + ])); + let keeper = Keeper::new(source, &venue, "stub"); + + assert_eq!( + run(keeper.sweep(&host, &TICK)) + .expect("sweep runs") + .submitted, + 1 + ); + assert_eq!( + run(keeper.sweep(&host, &TICK)) + .expect("sweep 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(Sweep::Submit(b"body".to_vec()), &venue); + + let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); + assert_eq!(report.unsigned, vec![tx.clone()]); + assert_eq!(report.submitted, 0); + + // Nothing accepted, nothing journalled: the next sweep + // surfaces the same transaction again. + let report = run(keeper.sweep(&host, &TICK)).expect("sweep 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(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) + .expect("sweep 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(Sweep::Drop, &venue).sweep(&host, &TICK)).expect("sweep 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(Sweep::Backoff { seconds: 30 }, &venue); + + let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); + assert_eq!(report.retried, 1); + + // Still inside the backoff window: gated, not polled. + let report = run(keeper.sweep(&host, &TICK)).expect("sweep 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.sweep(&host, &later)).expect("sweep 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(Sweep::Submit(b"body".to_vec()), &venue); + + let report = run(keeper.sweep(&host, &TICK)).expect("sweep 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.sweep(&host, &at_2s)).expect("sweep runs").gated, + 1 + ); + let at_3s = Tick { + epoch_s: TICK.epoch_s + 3, + ..TICK + }; + assert_eq!( + run(keeper.sweep(&host, &at_3s)).expect("sweep 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(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) + .expect("sweep 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(Sweep::Submit(b"body".to_vec()), &venue); + + let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); + assert_eq!(report.retried, 1); + assert_eq!( + run(keeper.sweep(&host, &TICK)).expect("sweep 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(Sweep::WaitBlock, &venue).sweep(&host, &TICK)).expect("sweep runs"); + assert_eq!(report, SweepReport::default()); + } +} diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs new file mode 100644 index 00000000..007eb2e2 --- /dev/null +++ b/crates/videre-sdk/src/lib.rs @@ -0,0 +1,135 @@ +//! # videre-sdk +//! +//! Guest-side SDK for the videre personas: the venue author (one +//! venue's protocol speaker exporting the `venue-adapter` world) and +//! the keeper author driving venues through the client seam. Where +//! `nexum-sdk` serves the strategy-module persona, this crate serves +//! both venue sides of it. +//! +//! ## What lives here +//! +//! - [`VenueAdapter`] - the trait mirroring the world's export face +//! (`init` plus the five intent functions). `#[videre_sdk::venue]` +//! on the impl turns it into the component's export glue: the single +//! blessed authoring path. +//! +//! - [`IntentBody`] (trait and derive) with [`BodyError`] - the borsh +//! codec over the outer per-venue version enum. The wire form is a +//! one-byte version tag plus the borsh payload; an unknown tag fails +//! typedly rather than as a stringly decode error. +//! +//! - [`client`] - the typed venue client: a [`Venue`] marker (its +//! [`VenueId`] plus body schema) drives [`VenueClient`], which +//! encodes through [`IntentBody`] before the byte-level, native-AFIT +//! [`VenueTransport`] seam ([`HostVenues`] binds it to the module's +//! own `videre:venue/client` import). Lives here (not in the +//! strategy SDK) so the codec and the client that speaks it version +//! together. `#[videre_sdk::keeper]` on a handler impl wires the +//! import and drives async handlers; [`rt`] completes their futures +//! on the synchronous guest boundary. +//! +//! - [`keeper`](mod@keeper) - the generic sweep assembler: +//! [`Keeper::sweep`] runs the world-neutral `nexum_sdk::keeper` +//! stores over a +//! [`ConditionalSource`](nexum_sdk::keeper::ConditionalSource) +//! producing the shared [`Sweep`] outcome, submitting through the +//! [`VenueTransport`] seam. +//! +//! - [`transport`] - typed wrappers over the world's scoped imports: +//! [`HostChain`](transport::HostChain) behind the SDK [`ChainHost`] +//! seam (plus batch), [`HostMessaging`](transport::HostMessaging) +//! behind [`MessagingHost`](transport::MessagingHost), the +//! wasi:http surface re-exported as [`transport::http`], and +//! [`BoundedFetch`](transport::BoundedFetch), which caps the +//! wasi:http phase timeouts of every adapter request. +//! +//! - [`faults`] - the conversions that make `?` work across the wire +//! fault, the SDK-neutral fault, and [`VenueError`]; plus +//! [`VenueFault`], the owned client-side mirror. +//! +//! - [`event`] - typed recovery of videre events from the core `custom` +//! escape hatch: [`event::intent_status_update`] decodes an +//! intent-status transition a keeper subscribes to. +//! +//! - [`status_body`] - the venue status-body codec: decode an +//! `intent-status` event's `status` bytes into a typed +//! [`StatusBody`](status_body::StatusBody). +//! +//! ## Why the bindgen lives in this crate +//! +//! The shared interfaces generate once, in [`bindings`], from an +//! import-only world: the trait, wrappers, and client core are all +//! typed over them. The per-cdylib bindgens (`#[venue]`, `#[keeper]`) +//! remap the shared interfaces onto [`bindings`], so a macro-built +//! component speaks these types while its world stays derived from its +//! own manifest. +//! +//! [`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 rt; +pub mod transport; + +pub use adapter::VenueAdapter; +pub use body::{BodyError, IntentBody}; +pub use client::{ClientError, HostVenues, Quoted, Venue, VenueClient, VenueId, VenueTransport}; +pub use faults::VenueFault; +pub use keeper::{Keeper, Sweep, SweepReport, retry_action}; +/// Derive [`IntentBody`] on the outer per-venue version enum. See +/// [`videre_macros::IntentBody`]. +pub use videre_macros::IntentBody; +/// The blessed keeper authoring path. Apply to a worker's handler impl: +/// emits the per-cdylib bindgen for a world derived from `module.toml` +/// (asserting the `client` capability), remaps the videre interfaces +/// onto the SDK bindings so the module drives a [`VenueClient`] with +/// shared type identity, dispatches events to the handlers (async ones +/// completed through [`rt::complete`]), and folds [`ClientError`] into +/// the wire fault so `?` works in handlers. See +/// [`videre_macros::keeper`]. +pub use videre_macros::keeper; +/// The single blessed venue authoring path. Apply to the adapter's +/// `impl VenueAdapter for MyVenue` block: emits the per-cdylib bindgen +/// for a world derived from `module.toml` (asserting its +/// `kind = "venue-adapter"`), the `videre:venue/adapter` export glue, +/// and `export!`. The built component imports exactly the manifest's +/// declared scoped transport. See [`videre_macros::venue`]. +pub use videre_macros::venue; + +/// The intent ontology at its plain spellings: the types the +/// [`VenueAdapter`] face and the 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 use bindings::videre::value_flow::types as value_flow; +/// The venue status-body codec: decode an `intent-status` event's +/// `status` bytes into a typed [`StatusBody`](status_body::StatusBody). +pub use videre_status_body as status_body; + +/// The intent-status transition a keeper recovers from a `custom` event +/// through [`event::intent_status_update`]. Its wire form is a version +/// tag plus the borsh envelope defined by this struct, not a WIT record: +/// it crosses the `custom` event as opaque bytes, and an unknown tag +/// fails closed. The status body rides its own inner codec. +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. Transport +/// seams speak the SDK-neutral [`nexum_sdk::host::Fault`] instead; the +/// [`faults`] conversions bridge the two. +pub use bindings::nexum::host::types::Fault; diff --git a/crates/videre-sdk/src/rt.rs b/crates/videre-sdk/src/rt.rs new file mode 100644 index 00000000..6a62f4f3 --- /dev/null +++ b/crates/videre-sdk/src/rt.rs @@ -0,0 +1,38 @@ +//! Futures on the synchronous guest boundary. + +use std::future::Future; +use std::pin::pin; +use std::task::{Context, Poll, Waker}; + +/// Complete a future in one poll. Guest host imports are synchronous, +/// so every await in a keeper future resolves immediately and one poll +/// runs it to completion. `None` reports a future that suspended, +/// which nothing built over the host imports does; the keeper macro's +/// emitted glue folds it to a typed fault. +pub fn complete(future: F) -> Option { + let mut future = pin!(future); + let mut cx = Context::from_waker(Waker::noop()); + match future.as_mut().poll(&mut cx) { + Poll::Ready(output) => Some(output), + Poll::Pending => None, + } +} + +#[cfg(test)] +mod tests { + use super::complete; + + #[test] + fn ready_chain_completes_in_one_poll() { + async fn two() -> u8 { + let one = async { 1u8 }.await; + one + async { 1u8 }.await + } + assert_eq!(complete(two()), Some(2)); + } + + #[test] + fn suspending_future_reports_none() { + assert_eq!(complete(std::future::pending::<()>()), None); + } +} diff --git a/crates/videre-sdk/src/transport.rs b/crates/videre-sdk/src/transport.rs new file mode 100644 index 00000000..93add1e0 --- /dev/null +++ b/crates/videre-sdk/src/transport.rs @@ -0,0 +1,236 @@ +//! Typed wrappers over the adapter world's scoped transport imports: +//! chain RPC, messaging, and outbound wasi:http. +//! +//! Each wrapper adapts this crate's bindgen import shims to the +//! SDK-neutral vocabulary (`nexum_sdk::host`), so adapter logic written +//! against the seams is unit-testable host-free and reuses the +//! `nexum-sdk` chain helpers unchanged. The wrappers only translate; +//! scoping is the host's: chain methods pass through the host's +//! permitted read-only surface, messaging is confined to the adapter's +//! `messaging_topics`, and HTTP to its `http_allow` list, 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. +/// [`fetch`](nexum_sdk::http::Fetch::fetch) speaks the standard `http` +/// crate's request/response types; an off-allowlist request fails as +/// [`FetchError::Denied`], which +/// converts into [`VenueError`](crate::VenueError) via `?`. +pub use nexum_sdk::http; + +/// Caps the wasi:http phase timeouts of any [`Fetch`]: every request +/// through it, including plain [`Fetch::fetch`], has its connect, +/// first-byte and between-bytes timeouts clamped to `bound`, so a hung +/// endpoint errors within it rather than stalling the export call. A +/// caller may ask for less; it can never exceed the bound. +#[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. Unit-struct handle: hold it where strategy logic +/// takes `&impl ChainHost` and slot a mock in host-side tests. +#[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 where the host transport supports it. Entries are + /// independent: the outer error is the batch failing to execute at + /// all, the per-entry results carry each call's own 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`], mirrored +/// from `nexum:host/chain.rpc-request`. `method` carries its namespace +/// prefix (`eth_call`); `params` is the JSON-encoded positional array. +#[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`]. +/// Exhaustive on both the fault vocabulary and the rpc-error shape. +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/crates/videre-sdk/tests/adapter.rs b/crates/videre-sdk/tests/adapter.rs new file mode 100644 index 00000000..0c490331 --- /dev/null +++ b/crates/videre-sdk/tests/adapter.rs @@ -0,0 +1,314 @@ +//! Acceptance surface for the venue SDK: a hand-written adapter +//! compiles against [`VenueAdapter`] and round-trips a versioned body +//! through `#[derive(IntentBody)]` - including the typed +//! unknown-version failure and the typed [`VenueClient`] driving the +//! adapter through the [`VenueTransport`] seam. The world-export glue +//! is `#[videre_sdk::venue]`'s alone; echo-venue is its worked target. + +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 { + videre_sdk::rt::complete(future).expect("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 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/crates/videre-status-body/Cargo.toml b/crates/videre-status-body/Cargo.toml new file mode 100644 index 00000000..67967b12 --- /dev/null +++ b/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/crates/videre-status-body/src/lib.rs b/crates/videre-status-body/src/lib.rs new file mode 100644 index 00000000..327c285c --- /dev/null +++ b/crates/videre-status-body/src/lib.rs @@ -0,0 +1,444 @@ +//! The 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. +//! The emitter encodes, the keeper decodes, the host never inspects the +//! bytes. An unknown tag fails closed and a body is never empty. +//! +//! v1 wire form: `0x01`, the [`IntentStatus`] discriminant, then the +//! borsh `option` encodings of `proof` and `reason`. +//! +//! The [`IntentStatusUpdate`] envelope that carries such a body is tagged +//! the same way and fails closed the same way, on its own version line: +//! the envelope tag describes the `venue`/`receipt`/`status` framing, the +//! body tag describes the status payload, and the two move independently. +//! v1 envelope wire form: `0x01`, then the borsh `{venue, receipt, +//! status}` payload. + +#![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`]: the framing versions apart from the body it carries. +pub const ENVELOPE_VERSION_V1: u8 = 1; + +/// The extension event kind an intent-status transition rides on: the +/// `custom-event.kind` the venue platform stamps and a subscribing +/// module matches. Shared by the emit side and the decode side so the +/// one string is never spelt twice. +pub const INTENT_STATUS_KIND: &str = "intent-status"; + +/// The intent-status transition an intent-status `custom` event carries +/// in its opaque payload: [`ENVELOPE_VERSION_V1`], then borsh +/// `{venue, receipt, status}`, where `status` is a [`StatusBody`]-encoded +/// body (the inner codec above). +#[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, failing typedly on an empty envelope, an unknown version + /// tag (fail-closed), or a payload that does not parse as the tagged + /// version (including trailing bytes). + 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 version tag names no published envelope version. Fail-closed: + /// a skewed peer's framing is refused, never guessed at. + #[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 the wire form: 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. +/// +/// `proof` is display-grade venue bytes (for an EVM venue, typically +/// the settlement transaction hash). There is no `failed` status: a +/// venue-reported 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: + /// at minimum the tag and the status discriminant. + 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, failing typedly on an empty body, an unknown version + /// tag (fail-closed), or a payload that does not parse as the + /// tagged version (including trailing bytes). + 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 version tag names no published version. Fail-closed: a + /// keeper never guesses at a future layout. + #[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's position: + /// a borsh layout drift is a wire break, so it must fail here. + #[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 peer that framed the envelope to a version this build does not + /// publish is refused, not misparsed into plausible-looking fields. + #[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, + }), + ); + } + + /// The pre-tag framing (bare borsh, no leading tag) is skew too: it + /// must never decode, whatever the leading length byte happens to be. + #[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, + .. + }), + )); + } + + /// The envelope tag and the body tag are separate wire lines: the + /// envelope decodes even when the body it carries is a version this + /// build refuses. + #[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/crates/videre-test/Cargo.toml b/crates/videre-test/Cargo.toml new file mode 100644 index 00000000..02198e5a --- /dev/null +++ b/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-sdk" } +# `MockChain` is composed rather than reimplemented: one chain mock +# serves both personas. +nexum-sdk-test = { path = "../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/crates/videre-test/goldens/reference-header.json b/crates/videre-test/goldens/reference-header.json new file mode 100644 index 00000000..c56703fd --- /dev/null +++ b/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/crates/videre-test/src/codec.rs b/crates/videre-test/src/codec.rs new file mode 100644 index 00000000..538db948 --- /dev/null +++ b/crates/videre-test/src/codec.rs @@ -0,0 +1,404 @@ +//! Codec conformance vectors: the file format that publishes a venue's +//! `IntentBody` wire bytes, and the check that holds a codec to them. +//! +//! A vector file is the venue's codec contract in portable form: JSON, +//! a leading format version (unknown versions fail closed), bytes as +//! lowercase hex, one entry per published body, never zero. A non-Rust +//! adapter author proves byte-exactness by decoding and re-encoding +//! each `round-trip` vector in their own language and comparing bytes; +//! a Rust author runs [`CodecVectors::assert_conforms`] against the +//! derived enum. The failure vectors pin the typed error contract: +//! empty, unknown-version, and malformed bodies must fail exactly as +//! [`BodyError`] names them, not garble into a decoded value. + +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, + /// Name of the body schema the vectors bind, e.g. + /// `acme-dex/order-body`. Informational: the check never reads it. + pub schema: String, + /// The vectors, in publication order. Never empty in a parsed + /// file: an empty set would conform vacuously. + #[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 conforming codec. The failure +/// cases mirror [`BodyError`] minus its free-text detail: the detail +/// wording is the Rust implementation's, not part of the contract. +#[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 vector + /// before publishing: a parsed file is never empty. + pub fn new(schema: impl Into) -> Self { + Self { + version: FormatVersion, + schema: schema.into(), + vectors: Vec::new(), + } + } + + /// Append a round-trip vector by encoding `body` through the codec + /// under publication. Returns the pushed vector 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`]; round-trip vectors are encoded + /// from a typed body via [`push_round_trip`](Self::push_round_trip) + /// so their bytes are canonical by construction. + 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 + /// rather than stopping at the first. + /// + /// A `round-trip` vector must decode and re-encode to the exact + /// published bytes; a failure vector must produce the matching + /// [`BodyError`] case (the free-text detail is not compared). An + /// empty set is itself a violation: it would conform vacuously. + 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. The assertion form for adapter test suites. + 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/crates/videre-test/src/fixture.rs b/crates/videre-test/src/fixture.rs new file mode 100644 index 00000000..6b2b5118 --- /dev/null +++ b/crates/videre-test/src/fixture.rs @@ -0,0 +1,126 @@ +//! 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), so a reader +/// never guesses at a future layout. +#[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: an empty +/// set would conform vacuously. +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. The JSON case carries +/// serde's rendered detail rather than the error value so the type +/// stays independent of `serde_json`'s feature set. +#[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 its canonical published form: pretty-printed +/// JSON with a trailing newline, so a regenerated file diffs cleanly. +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, so the file +/// is legible without a borsh decoder. +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/crates/videre-test/src/header.rs b/crates/videre-test/src/header.rs new file mode 100644 index 00000000..91063dc4 --- /dev/null +++ b/crates/videre-test/src/header.rs @@ -0,0 +1,417 @@ +//! Header-derivation goldens: the file format that publishes what +//! `derive-header` must project from each published body, and the +//! check that holds an adapter to it. +//! +//! A golden file pairs wire bodies with the intent header a conforming +//! adapter derives from them, spelled in the golden mirror types below +//! (JSON, a leading format version that fails closed on an unknown tag, +//! kebab-case case names matching the WIT, bytes as lowercase hex, +//! never zero goldens). The mirrors exist because wit-bindgen types +//! carry no serde; [`GoldenHeader`] converts from the venue SDK's +//! `IntentHeader`, which macro-built adapters speak too, 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: an empty set would conform vacuously. + #[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 golden + /// before publishing: a parsed file is never empty. + pub fn new(venue: impl Into) -> Self { + Self { + version: FormatVersion, + venue: venue.into(), + goldens: Vec::new(), + } + } + + /// Append a golden by running the publishing adapter's own + /// `derive-header` on `body`. Returns the pushed golden 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 an adapter's `derive-header` against every golden, + /// collecting all violations rather than stopping at the first. + /// + /// `derive` is the adapter's derivation; a trait-based adapter + /// passes `MyAdapter::derive_header` directly. An empty set is + /// itself a violation: it would conform vacuously. + 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. The assertion form for adapter test suites. + 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/crates/videre-test/src/lib.rs b/crates/videre-test/src/lib.rs new file mode 100644 index 00000000..2485b000 --- /dev/null +++ b/crates/videre-test/src/lib.rs @@ -0,0 +1,81 @@ +//! # videre-test +//! +//! Conformance kit for venue adapters: file-published codec vectors, +//! header-derivation goldens, and an in-memory transport mock, so an +//! adapter proves its wire behaviour against fixtures any +//! implementation language can read. +//! +//! ## The three pieces +//! +//! - [`CodecVectors`] - the venue's `IntentBody` wire bytes as a JSON +//! file (bytes as lowercase hex). A Rust adapter checks its derived +//! enum with [`CodecVectors::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, spelled in the +//! [`GoldenHeader`] mirror types. +//! - [`MockTransport`] - the three transports an adapter is granted +//! (chain, messaging, outbound HTTP) as programmable in-memory mocks +//! behind the SDK's own seams. +//! +//! ## Usage +//! +//! Add as a dev-dep on the adapter crate: +//! +//! ```toml +//! [dev-dependencies] +//! videre-test = { path = "../../crates/videre-test" } +//! ``` +//! +//! Hold the adapter to its published fixtures: +//! +//! ```rust +//! use videre_test::reference::{ +//! CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, +//! }; +//! use videre_test::{CodecVectors, HeaderGoldens}; +//! +//! // In a real adapter test these load the venue's own published +//! // files; the kit's reference venue stands in here. +//! 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); +//! ``` +//! +//! Publishing works through the same types: build the fixtures with +//! [`CodecVectors::push_round_trip`] / [`HeaderGoldens::record`] and +//! [`write`](CodecVectors::write) them next to the venue's schema +//! documentation. +//! +//! ## Macro-built adapters +//! +//! `#[videre_sdk::venue]` adapters speak the SDK's own types (the +//! macro remaps the type interfaces onto `videre_sdk::bindings`), so +//! both checks apply directly: pass `MyAdapter::derive_header` to +//! [`HeaderGoldens::assert_conforms`] and the derived enum to +//! [`CodecVectors::assert_conforms`]. No bridge types. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] + +pub mod codec; +pub mod fixture; +pub mod header; +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 report::{ConformanceReport, Violation}; +pub use transport::{ + ChainCall, Message, MessagingHost, MockChain, MockFetch, MockMessaging, MockTransport, + PublishRecord, RecordedRequest, +}; diff --git a/crates/videre-test/src/reference.rs b/crates/videre-test/src/reference.rs new file mode 100644 index 00000000..020cd6f9 --- /dev/null +++ b/crates/videre-test/src/reference.rs @@ -0,0 +1,277 @@ +//! The kit's reference venue: a published body schema, its codec +//! vector file, and its header golden file. +//! +//! The reference exists so the fixture formats ship with a worked, +//! machine-checked example. Its payloads exercise every borsh +//! primitive a body schema is likely to carry (fixed-width integers, +//! length-prefixed strings and byte vectors, options, bools), so a +//! non-Rust author can prove their borsh implementation byte-exact +//! against [`CODEC_VECTORS_JSON`] before touching their own schema. +//! 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 reference venue's outer version enum. Tag order is the schema: +/// versions 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 venue's pure header derivation, the subject the +/// published goldens pin. Gives the amount as the chain's native token, +/// wants (for v2) the same amount as an ERC-20 at the recipient token +/// address, and authorises via EIP-712. V1 wants nothing, spelled as 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 with leading zeros trimmed: the minimal spelling +/// of a wire amount, 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()) +} + +#[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; the tests above + /// compare against the compiled-in copy, so they go green on the + /// next build. + #[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/crates/videre-test/src/report.rs b/crates/videre-test/src/report.rs new file mode 100644 index 00000000..97f56b97 --- /dev/null +++ b/crates/videre-test/src/report.rs @@ -0,0 +1,51 @@ +//! 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 under test failed, with enough +/// detail to fix the divergence without re-running under a debugger. +#[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 entry per failing +/// vector. A check never stops at the first failure: the report is the +/// whole distance between the subject and the published fixtures. +#[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/crates/videre-test/src/transport.rs b/crates/videre-test/src/transport.rs new file mode 100644 index 00000000..a7295c97 --- /dev/null +++ b/crates/videre-test/src/transport.rs @@ -0,0 +1,352 @@ +//! In-memory mocks for the three transports a venue adapter is +//! granted: chain RPC, messaging, and outbound HTTP. +//! +//! [`MockTransport`] composes the three behind the same seams the SDK +//! wrappers implement ([`ChainHost`], [`MessagingHost`], [`Fetch`]), so +//! adapter logic written against `&impl Seam` runs unchanged in unit +//! tests. Grant play mirrors the host's: [`MockMessaging::scope_topics`] +//! plays the adapter's `messaging_topics` grant with the host's +//! `/`-bounded prefix matching, and [`MockFetch::scope_hosts`] plays the +//! `[capabilities.http].allow` list with the host's exact-or-`*.suffix` +//! matching; both refuse off-grant calls as a typed `denied`, exactly 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 exposes the per-seam mock +/// so tests can program responses and assert on calls. +#[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. Equivalent to `Default::default`. + 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) + } +} + +// ------------------------------------------------------------ http + +/// 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 into an `http::Response` per call +/// because the standard response type is not `Clone`. +#[derive(Clone, Debug)] +struct StoredResponse { + status: http::StatusCode, + body: Vec, +} + +/// In-memory [`Fetch`] backed by a `(method, uri)` -> response map. +/// Records every request so tests can assert dispatch shape. An +/// optional host scope plays the adapter's `[capabilities.http].allow` +/// grant ([`scope_hosts`](Self::scope_hosts)); one-off refusals can +/// still be programmed via [`fail_with`](Self::fail_with). +#[derive(Default)] +pub struct MockFetch { + responses: RefCell>>, + requests: RefCell>, + scope: RefCell>>, +} + +impl MockFetch { + /// Confine the mock to `hosts`, playing the adapter's + /// `[capabilities.http].allow` grant with the host's matching: + /// case-insensitive, an entry is an exact hostname or a `*.suffix` + /// wildcard, and an off-grant request fails as + /// [`FetchError::Denied`]. An empty grant denies every host, the + /// host's posture for an absent allow list; an untouched mock is + /// unscoped. + pub fn scope_hosts(&self, hosts: impl IntoIterator>) { + *self.scope.borrow_mut() = Some(hosts.into_iter().map(Into::into).collect()); + } + + /// Program a response for the `(method, uri)` pair. 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 the `(method, uri)` pair. 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}" + ))), + } + } +} + +/// The host's `[capabilities.http].allow` matching: host-only and +/// case-insensitive, an entry admits its exact hostname or, as +/// `*.suffix`, any strict subdomain of the suffix. +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/crates/videre-test/tests/conformance.rs b/crates/videre-test/tests/conformance.rs new file mode 100644 index 00000000..da0bc538 --- /dev/null +++ b/crates/videre-test/tests/conformance.rs @@ -0,0 +1,142 @@ +//! Acceptance surface for the conformance kit: an adapter written +//! against `videre-sdk` is held to the published vector and +//! golden files, and a deliberately divergent adapter is caught by +//! them. + +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}; + +/// An adapter under test: the reference venue implemented through the +/// SDK trait, transport injected through the seams so the kit's mocks +/// drive it. +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/crates/videre-test/vectors/reference-body.json b/crates/videre-test/vectors/reference-body.json new file mode 100644 index 00000000..4bc46024 --- /dev/null +++ b/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/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..3e8427e9 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -11,14 +11,12 @@ Two project names look similar but mean different things - keeping them straight | 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::*` | +| **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.1.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. -> **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. - ## Architecture ```mermaid @@ -96,7 +94,7 @@ In addition to the six core primitives, 0.2 introduces one optional capability t Time and secure randomness are WASI concerns rather than Nexum capabilities: `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 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. ## WIT Worlds @@ -121,7 +119,7 @@ graph TB ``` // 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) @@ -136,7 +134,7 @@ world event-module { } // CoW Protocol extension -package shepherd:cow@0.2.0 +package shepherd:cow@0.1.0 world shepherd { include event-module @@ -158,7 +156,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`, `shepherd:cow@0.1.0`) | - | | Guest bindings | wit-bindgen | 0.57.x | | Async | Tokio | - | | Ethereum RPC | alloy | 1.5.x | @@ -272,11 +270,13 @@ The SDK ships as two crate pairs: `nexum-sdk`, the generic module-author SDK (ho | | `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 | +| | `keeper::{WatchSet, Gates, Journal, Retrier, ConditionalSource}` - the conditional-commitment strategy keeper: watch registry, poll gates, receipt journal, retry dispatch over the local-store seam | | | `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`) | +| | `cow::{order, composable, error}` - CoW Protocol bridging (`gpv2_to_order_data`, `Verdict`, `LegacyRevertAdapter`, `RetryAction`, `classify_api_error`) | +| | `cow::run` - the shared poll-loop composition: sweep the keeper watch set, poll a `ConditionalSource`, submit `Ready` orders behind the `submitted:` journal guard and retry ledger | | | `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 | @@ -309,7 +309,7 @@ Tower layer stack per chain: timeout -> retry (exponential + jitter) -> rate lim ### Error Model -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. +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. ### Observability @@ -342,11 +342,13 @@ The mobile/wallet host story - including the experimental `query-module` world's | # | 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 | +| 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 | +\* **M2 divergence.** The "ComposableCoW contract mods" deliverable (enhanced polling interfaces, optimized getters, monitoring events) was intentionally met off-chain: no Solidity was modified. The TWAP module polls `getTradeableOrderWithSignature` via raw `eth_call` with SDK helpers. Contract-side interfaces would fix one concrete TWAP implementation behind the boundary and block competing strategies (ADR-0006). The functional goal stands; the literal deliverable does not. + ## Repository Structure ``` @@ -357,8 +359,7 @@ shepherd/ │ ├── 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 +│ └── shepherd-sdk-test/ CoW mock host (MockCowApi + composed MockHost) for strategy tests ├── modules/ │ ├── twap-monitor/ TWAP order monitoring module │ ├── ethflow-watcher/ Ethflow order monitoring module @@ -377,8 +378,7 @@ shepherd/ ├── operations/ Runbooks, E2E reports, load reports, baselines ├── production.md Operator handbook ├── sdk.md Module-author entry point (shipped SDK reference) - ├── tutorial-first-module.md - └── migration/0.1-to-0.2.md + └── 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..8ad5f84f 100755 --- a/docs/01-runtime-environment.md +++ b/docs/01-runtime-environment.md @@ -101,12 +101,12 @@ let bindings = EventModule::instantiate_pre(&mut store, &pre)?; 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. -### 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,14 +301,14 @@ 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-Specific Package: `shepherd:cow@0.1.0` 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: ```wit -package shepherd:cow@0.2.0; +package shepherd:cow@0.1.0; interface cow-api { use nexum:host/types.{chain-id, fault}; diff --git a/docs/02-modules-events-packaging.md b/docs/02-modules-events-packaging.md index cbbf122f..15c1e0c4 100755 --- a/docs/02-modules-events-packaging.md +++ b/docs/02-modules-events-packaging.md @@ -6,7 +6,7 @@ A module is distributed as a **bundle** - a WASM component plus a manifest that ### 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,9 +54,9 @@ 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 `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. @@ -277,10 +277,10 @@ The runtime serialises event data via the canonical ABI (handled automatically b 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. -### 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,10 +409,10 @@ world event-module { } ``` -### CoW-Specific Package: `shepherd:cow@0.2.0` +### CoW-Specific Package: `shepherd:cow@0.1.0` ```wit -package shepherd:cow@0.2.0; +package shepherd:cow@0.1.0; interface cow-api { use nexum:host/types.{chain-id, fault}; diff --git a/docs/04-state-store.md b/docs/04-state-store.md index 59de103a..96d5434b 100755 --- a/docs/04-state-store.md +++ b/docs/04-state-store.md @@ -55,8 +55,7 @@ interface local-store { /// 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. + /// Quota exhaustion surfaces as fault.invalid-input. set: func(key: string, value: list) -> result<_, fault>; /// Delete a key. No-op if key doesn't exist. @@ -67,7 +66,7 @@ interface local-store { } ``` -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. +In 0.1 `local-store` errors were bare `string` values. 0.2 replaces them with the shared `fault` vocabulary 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. Keys are UTF-8 strings. Values are opaque bytes - the SDK provides typed wrappers (see doc 05). diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 697d3448..085bbf97 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -1,982 +1,472 @@ -# 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 - -``` -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) -``` - -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: - -```toml -# nexum-sdk/Cargo.toml -[package.metadata.component.target] -path = "../wit/nexum-host" -``` - -```toml -# shepherd-sdk/Cargo.toml -[package.metadata.component.target] -path = "../wit/shepherd-cow" -``` - -Both SDKs pin a specific `wit-bindgen` version so module authors are insulated from upstream churn. - -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. - -## The `#[nexum::module]` and `#[shepherd::module]` Macros - -### Universal: `#[nexum::module]` +# SDK Design: The Two-Persona SDK + +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. + +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 `crates/nexum-sdk/` and `crates/videre-sdk/`. + +## The two personas + +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`). + +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. + +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). + +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 strategy logic +can be unit-tested against an in-memory mock without a `wasm32-wasip2` +toolchain or a running wasmtime instance. + +## Crate layout + +``` +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, ConditionalSource, 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 + ├── keeper.rs # Keeper::sweep - the generic sweep assembler; Sweep, SweepReport + ├── transport.rs # HostChain, HostMessaging, http, BoundedFetch + ├── faults.rs # VenueFault + conversions across wire fault / SDK fault / VenueError + ├── rt.rs # completes async keeper handlers on the sync guest boundary + └── bindings.rs # the shared import-only bindgen the macros remap onto -Without the macro, a module author writes (against the typed 0.2 config): +videre-macros/ # #[venue], #[keeper], derive(IntentBody) (proc-macro) -```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) => { ... } - } - } -} +videre-test/ # venue conformance kit +└── src/ # CodecVectors, HeaderGoldens, MockTransport, reference venue -export!(MyModule); +cow-venue/ # the CoW venue, as feature slices +composable-cow/ # ComposableCoW keeper machinery (body, poll seam, sweep) ``` -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!`: +`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. -```rust -use nexum_sdk::prelude::*; +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. -#[nexum::module] -struct TwapMonitor; +## Module-author persona: `nexum-sdk` -impl TwapMonitor { - fn init(config: Config) -> Result<()> { - Ok(()) - } +### The host-trait seam - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let num = provider.get_block_number().await?; - // ... - 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 -} -``` - -The `#[nexum::module]` macro generates code against the `nexum:host/event-module` world. - -### CoW Protocol: `#[shepherd::module]` - -For CoW Protocol modules, the `#[shepherd::module]` macro targets the `shepherd:cow/shepherd` world, which extends `event-module` with the merged `cow-api` import: +`nexum-sdk` never calls `wit_bindgen`-generated functions directly. +Instead `nexum_sdk::host` exposes small traits that mirror the WIT +interfaces: ```rust -use shepherd_sdk::prelude::*; - -#[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(()) - } +pub trait ChainHost { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result; } -``` - -### What the macro generates - -For the universal `#[nexum::module]`: - -```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 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>; } - -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 LoggingHost { + fn log(&self, level: Level, message: &str); } -``` - -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` +pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} +impl Host for T {} +``` + +Strategy 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. + +### The wit-bindgen adapter: `bind_host_via_wit_bindgen!` + +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. + +### The `#[nexum_sdk::module]` macro + +`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 -// 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]>, -} - -// 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/")?; -``` +// modules/examples/http-probe/src/lib.rs (shipped) +mod strategy; -Implementation: +use nexum::host::types; -```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), - } - } +struct HttpProbe; - pub fn set(key: &str, value: &T) -> Result<()> { - let bytes = postcard::to_allocvec(value)?; - local_store::set(key, &bytes)?; - Ok(()) - } - - pub fn delete(key: &str) -> Result<()> { - local_store::delete(key)?; +#[nexum_sdk::module] +impl HttpProbe { + fn init(config: Vec<(String, String)>) -> Result<(), Fault> { + install_tracing(); + let cfg = strategy::parse_config(&config)?; + // ... Ok(()) } - 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. - -## Signer - -The `identity` WIT interface provides cryptographic identity -- key management and signing (ECDSA secp256k1 by default, extensible). The SDK wraps this with a typed `Signer`: - -```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) - -// 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: - -```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) - } - - /// 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() + fn on_block(block: types::Block) -> Result<(), Fault> { + strategy::on_block(&nexum_sdk::http::WasiFetch, /* ... */ block.number) + .map_err(Into::into) } - - /// 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) - } - - /// 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) - } -} -``` - -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` - -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: - -```rust -use shepherd_sdk::prelude::*; - -let cow = Cow::new(42161); - -// 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() -})?; - -// Get a quote -let quote = cow.get_quote(&OrderQuoteRequest { ... })?; - -// 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)?; -``` - -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. - -## Logging Convenience - -Wrappers over the `logging` WIT interface (provided in `nexum-sdk`; CoW modules import them from `nexum-sdk` directly): - -```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)*)) - }; -} -``` - -Usage: - -```rust -nexum_sdk::info!("processing block {} on chain {}", block.number, block.chain_id); -``` - -## Error Handling - -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`. - -```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: - -```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(()) -} -``` - -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. - -## Testing Framework - -### Universal: `nexum-sdk` Mock Host - -The `nexum-sdk` provides a mock host so modules can be tested without a live blockchain or runtime. - -```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: - -```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); - - let result = host.dispatch(Event::Block(Block { - chain_id: 42161, - number: 19_000_001, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - })); - - assert!(result.is_ok()); - - // Verify an order was submitted (CoW-specific) - assert_eq!(host.submitted_orders().len(), 1); } ``` -### MockHost Internals - -```rust -// nexum-sdk: universal mock host -pub struct MockHost { - local_store: HashMap>, - chains: HashMap, - identity: MockIdentity, - logs: Vec<(Level, String)>, -} - -pub struct MockChain { - block_number: u64, - /// Maps (method, params_json) -> result_json for RPC mocking - rpc_mocks: HashMap<(String, String), String>, - logs: Vec, -} - -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)>, -} - -// 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 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. - -For alloy `Provider`-based tests, the `nexum-sdk` also provides `MockProvider` (backed by alloy's `Asserter`-based mock transport) -- see doc 07 for details. - -### Integration Testing - -For tests that compile to WASM and run in a real wasmtime instance: +Two things worth being precise about: + +- **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.) + +The `Guest`/`export!` shape the macro emits follows the `strategy.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`, +`ConditionalSource`, `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 +sweep. + +## Venue persona: `videre-sdk` + +### The venue contract + +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. + +### Bodies: the `IntentBody` derive + +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 -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()); +#[derive(videre_sdk::IntentBody)] +enum EchoBody { + V1(u64), } ``` -This tests the full component boundary (canonical ABI marshalling, host function binding). - -## Project Scaffolding - -### `cargo-nexum` CLI - -> **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. - -```bash -cargo nexum new my-module -``` - -Generates: - -``` -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" -``` +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. + +### `#[videre_sdk::venue]` + +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. + +### The typed client and `#[videre_sdk::keeper]` + +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: -`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(()) - } +struct CowVenue; +impl Venue for CowVenue { + const ID: VenueId = VenueId::from_static("cow"); + type Body = CowIntentBody; } ``` -#### CoW Protocol module (targeting `shepherd:cow/shepherd`) - -`Cargo.toml`: -```toml -[package] -name = "my-cow-module" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -shepherd-sdk = "0.2" +`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. + +`#[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::rt` +completes the futures on the synchronous guest boundary. A +`From` impl onto the wire fault is emitted, so `?` +applies to client calls inside handlers. + +`videre_sdk::keeper::Keeper::sweep` assembles the world-neutral +`nexum_sdk::keeper` stores - `WatchSet` to `Gates` to +`ConditionalSource::poll` to `Retrier` to `Journal` - over the +`VenueTransport` seam, so a conditional-commitment keeper writes one +`poll` producing the shared `Sweep` outcome and inherits the whole +gate/journal/retry pass. + +### Testing: `nexum-sdk-test` and `videre-test` + +Keeper strategy 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. -[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(()) - } - - fn on_tick(tick: Tick) -> Result<()> { - info!("tick fired at {} ms UTC", tick.fired_at); - Ok(()) - } -} -``` - -`module.toml` (same for both): -```toml -[module] -name = "my-module" -version = "0.1.0" -description = "" -authors = [] -component = "sha256:TODO" - -[module.resources] -max_memory_bytes = 10_485_760 -max_fuel_per_event = 100_000 -max_state_bytes = 52_428_800 - -[module.restart] -max_consecutive_failures = 10 - -[chains] -required = [] -optional = [] - -[capabilities] -required = ["chain", "local-store", "logging"] -optional = [] - -[config] -``` - -### Build - -```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 -``` - -## 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 | +use nexum_sdk::host::*; +use nexum_sdk_test::MockHost; + +let host = MockHost::new(); +host.chain.respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); + +assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); +assert_eq!(host.chain.calls().len(), 1); +``` + +Adapters are held to the `videre-test` conformance kit instead: + +- **`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. + +(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.) + +## Walkthrough: authoring a venue on videre + +The shipped reference pair is `modules/examples/echo-venue` (adapter) +and `modules/examples/echo-keeper` (driver); the production instance +of the same shape is `crates/cow-venue` driven by `modules/twap-monitor`. + +1. **Declare the manifest.** A venue adapter is a component with a + `module.toml` whose kind names it: + + ```toml + [module] + name = "echo-venue" # the venue id the registry installs it under + version = "0.1.0" + kind = "venue-adapter" + component = "sha256:..." + + [capabilities] + required = ["chain"] # scoped transport only: chain, messaging, http + + [capabilities.http] + allow = [] + + [venue] + body_versions = [1] # must equal the body-versions export; install asserts it + ``` + +2. **Implement `VenueAdapter`.** One impl block under the macro; the + five intent functions plus `init` and `body_versions`: + + ```rust + use videre_sdk::{IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, VenueError}; + + struct EchoVenue; + + #[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> { /* ... */ } + } + ``` + + `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. + +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. + +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: + + ```toml + [[adapters]] + path = "target/wasm32-wasip2/release/echo_venue.wasm" + manifest = "modules/examples/echo-venue/module.toml" + http_allow = [] # the operator's outbound-HTTP grant + ``` + + 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)). + +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: + + ```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(()) + } + } + ``` + + 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`. + +## The CoW venue + +CoW ships as the production instance of the persona, in two crates so +the venue stays orderbook-only: + +- **`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 `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 + `sweep` slice composing the poll loop over the typed `CowClient`. + +The shipped CoW keepers - `modules/twap-monitor`, +`modules/ethflow-watcher`, `modules/examples/stop-loss` - are ordinary +`#[videre_sdk::keeper]` modules on this surface. + +## Non-Rust module and adapter authors + +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. + +## Where to go next + +- [`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/07-rpc-namespace-design.md b/docs/07-rpc-namespace-design.md index f7e8f542..25246aac 100755 --- a/docs/07-rpc-namespace-design.md +++ b/docs/07-rpc-namespace-design.md @@ -75,7 +75,7 @@ flowchart TD Replace the `blockchain` interface with `chain`: ```wit -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface chain { use types.{chain-id, fault}; @@ -107,7 +107,7 @@ interface chain { } ``` -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. +Errors are reported via `chain-error`: either a shared `fault` (see doc 00 and ADR-0011) 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). @@ -1231,10 +1231,6 @@ The primary trade-off is **type safety at the WIT boundary**: JSON strings vs. s 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 | diff --git a/docs/08-platform-generalisation.md b/docs/08-platform-generalisation.md index d0391e04..fc9023d9 100755 --- a/docs/08-platform-generalisation.md +++ b/docs/08-platform-generalisation.md @@ -39,22 +39,23 @@ These six primitives are orthogonal: 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. +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.) Two additional **additive** capabilities are declared via the manifest's `[capabilities]` section but are not part of the six-primitive core: `http` (allowlisted), serviced by the standard `wasi:http/outgoing-handler` interface rather than a `nexum:host` one, and `client`, the `videre:venue/client` intent surface (see [Layer 3](#layer-3-domain-extensions-venue-adapters)). ## 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)"] + LEND["lend-venue - a lending market (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 (planned)"] end subgraph L1["Layer 1: Universal Runtime Interfaces"] @@ -67,11 +68,11 @@ 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 at all: a domain enters the system 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 - see [Layer 3](#layer-3-domain-extensions-venue-adapters). ## Layer 1: Universal Interfaces @@ -374,7 +375,7 @@ Every platform implements this trivially. On server: `tracing` crate. On mobile: ### Universal World Definition ```wit -package nexum:host@0.2.0; +package nexum:host@0.1.0; interface types { type chain-id = u64; @@ -576,54 +577,48 @@ price-dashboard/ 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 +## Layer 3: Domain Extensions (Venue Adapters) -Domain-specific interfaces extend the universal layer for particular use cases. The pattern: +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`). This is the domain-extension mechanism - the domain's wire protocol, body codec, and error projection live inside the adapter component, and nothing domain-specific enters the host or any module world. ```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; +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>; } -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 (`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 whole 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 therefore 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 - the engine itself stays venue- and cow-free. -interface vault { /* ... */ } -interface strategy { /* ... */ } +### The legacy read path: `shepherd:cow` -world yield-module { - include nexum:host/event-module; - import vault; - import strategy; -} -``` +The retired predecessor to venue adapters was a Layer-3 *world* extension: the `shepherd:cow/cow-api` interface (orderbook passthrough plus `submit-order`), a `shepherd` world including `event-module` and importing it, and a host-side extension cone implementing the interface over a cached orderbook client. That model put the domain in the module's own world and a backend in every host that ran it; each new domain would have needed its own world, host cone, and SDK glue. -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 path is deleted: the `cow-api` interface, the `shepherd`/`cow-ext` worlds, and the host cone are gone, and 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 @@ -637,23 +632,29 @@ wit/ │ ├── 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) │ ├── 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: query-module world (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 and reusable. The `videre` packages are the venue-neutral intent contract. `shepherd-cow` carries only the CoW event ABIs. New domains add adapter components, not packages: the universal and venue layers are closed. (The `ui` interface and `app-module` world are design-only and ship no WIT yet.) ## 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. +This is the current design (docs 01-07), adapted for the layered WIT. Shepherd is the Nexum composition root that registers the videre venue platform and bundles the `cow-venue` adapter. | Interface | Implementation | |-----------|---------------| @@ -663,7 +664,7 @@ This is the current design (docs 01-07), adapted for the layered WIT. Shepherd i | `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`) | +| `videre:venue/client` | videre-host `VenueRegistry` -> installed venue adapter components (CoW via the bundled `cow-venue` adapter over allowlisted `wasi:http`) | | Event sources | `eth_subscribe` (blocks, logs), cron (Tokio interval), Waku relay (messages) | | WASM engine | wasmtime 45.x (Component Model, fuel, epoch metering) | @@ -867,7 +868,7 @@ The super app adds a capability-grant layer on top of the WIT world. When a modu ✓ 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 + ✓ client - submit intents to installed venues (cow) [Allow] [Deny] ``` @@ -880,7 +881,7 @@ Any platform that wants to run modules must implement the **Host Adapter** - the ### 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. +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 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. @@ -970,40 +971,18 @@ The content hash is the trust anchor. The transport is interchangeable. ## 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: +The SDK mirrors the architecture, one crate per layer, with no re-export between them. See [doc 05](05-sdk-design.md) for the full treatment. -```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 +- **`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` / `ChainError`, the `bind_host_via_wit_bindgen!` adapter macro, the `#[nexum_sdk::module]` attribute macro, chain / config / address helpers, the `http` fetch seam over wasi:http, the keeper store primitives, 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`, `Messaging`, and `Signer` typed wrappers as future direction. Any module author - CoW, DeFi, gaming, whatever - uses this. - 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. +- **`videre-sdk` (shipped)** - the venue layer, serving both venue sides: the `VenueAdapter` trait under `#[videre_sdk::venue]` for adapter authors, and the `IntentBody` codec, typed `VenueClient` and `#[videre_sdk::keeper]` for keeper authors, plus the generic sweep assembler and the `videre-test` conformance kit alongside. -- **`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. +- **Per-venue crates (shipped for CoW)** - each domain ships as crates on the venue layer, not as an SDK layer: `cow-venue` (body codec, typed client, adapter component) and `composable-cow` (conditional-order keeper machinery). A new domain adds its own. -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. +A generic automation module depends only on `nexum-sdk`; a keeper adds `videre-sdk` and the venue's body crate; a venue adapter depends on `videre-sdk` and its own crate. 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 @@ -1025,10 +1004,11 @@ For the full 0.1 → 0.2 rename and behaviour change list, see the [Migration Gu | `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. | +| `videre:types` / `videre:value-flow` / `videre:venue` WIT packages | Venue-neutral intent contract: types, asset vocabulary, client + adapter faces, venue-adapter world | +| `shepherd:cow` WIT package | CoW event-ABI package of record (`cow-events` only; the legacy `cow-api` read path and `shepherd` world are retired) | +| Venue adapter components | The domain-extension mechanism: `#[videre_sdk::venue]` components installed into the videre platform (`cow-venue` shipped) | +| `nexum-sdk` crate (shipped) | Universal Rust SDK: host-trait seam (ADR-0009), Fault / ChainError, bind macro, module macro, chain / config / address helpers, keeper store primitives, guest `http` helper, tracing facade | +| `videre-sdk` crate (shipped) | Venue Rust SDK: VenueAdapter + venue macro, IntentBody codec, typed VenueClient + keeper macro, sweep assembler; `videre-test` conformance kit alongside | | Content-addressed distribution | Platform-agnostic (Swarm/IPFS, ENS discovery, hash verification) | | Host Adapter | Platform-specific implementation of universal interfaces | 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..5dee12b9 100644 --- a/docs/adr/0001-engine-toml-separate-from-nexum-toml.md +++ b/docs/adr/0001-engine-toml-separate-from-nexum-toml.md @@ -30,7 +30,7 @@ The engine config carries the path to each module's manifest; the two never coll ## 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`. +- 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. - 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. 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..c78d14c6 100644 --- a/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md +++ b/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md @@ -36,3 +36,9 @@ This is not a parallel fork. `bleu/cow-rs:main` IS the head branch of upstream P - 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. + +## Addendum (2026-07): patch channel moved to nullislabs/cow-rs + +The patch target has since moved from `bleu/cow-rs` to `https://github.com/nullislabs/cow-rs` (rev `17fc0c5`). The fork carries two changes the workspace needs ahead of a published `cowprotocol` 0.2.0: the `OrderCreationAppData` hash-only submission shape (`OrderCreation::new_app_data_hash_only`, watch-tower parity for conditional-order submission) and the WASI clock fix that keeps `js_sys` out of non-browser wasm builds. The latest crates.io release (`0.2.0-alpha.1`) has neither. + +The decision and its consequences are otherwise unchanged: one workspace-level `[patch.crates-io]` line, advanced by bumping the rev, dropped entirely once a published `cowprotocol` release carries the hash-only constructor. The comment above `[patch.crates-io]` in the workspace `Cargo.toml` states the current drop condition. diff --git a/docs/adr/0005-cow-api-via-cached-orderbookapi.md b/docs/adr/0005-cow-api-via-cached-orderbookapi.md index 37493689..6586e26a 100644 --- a/docs/adr/0005-cow-api-via-cached-orderbookapi.md +++ b/docs/adr/0005-cow-api-via-cached-orderbookapi.md @@ -1,10 +1,16 @@ --- -status: proposed +status: superseded implemented-in: nullislabs/shepherd#8 --- # `cow-api` host backend routes both `request` and `submit-order` through `cowprotocol::OrderBookApi` +> **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. + ## 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. diff --git a/docs/adr/0006-cow-twap-ethflow-host-helpers.md b/docs/adr/0006-cow-twap-ethflow-host-helpers.md index 96f8d02b..43600c13 100644 --- a/docs/adr/0006-cow-twap-ethflow-host-helpers.md +++ b/docs/adr/0006-cow-twap-ethflow-host-helpers.md @@ -1,9 +1,15 @@ --- -status: proposed +status: superseded --- # TWAP and EthFlow run as guest modules using low-level host primitives (no specialised `shepherd:cow` interfaces) +> **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. + ## 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. @@ -39,7 +45,7 @@ An EthFlow module's `on_event(log)` handler decodes the `OrderPlacement` event w ## Consequences -- `shepherd:cow@0.2.0` keeps `cow-api` as its only interface. No new WIT files in this ADR. +- `shepherd:cow@0.1.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. 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..9ff06fa3 100644 --- a/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md +++ b/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md @@ -44,3 +44,5 @@ Lower-priority follow-ons (`OrderUid::from_slice`, retry middleware on `OrderBoo - 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. + +_Errata: `crates/nexum-engine` was renamed to `crates/nexum-runtime` + `crates/nexum-cli` in the 0.2 refactor. The `cowprotocol` crate now publishes to crates.io (the workspace pins `0.2.0`), so the PR-#5-head consumption model above is historical; a `[patch.crates-io]` git override to `nullislabs/cow-rs` remains active pending a release with the hash-only `OrderCreationAppData` constructor - see the comment above the patch block in the root `Cargo.toml` for the current state._ diff --git a/docs/adr/0009-host-trait-surface.md b/docs/adr/0009-host-trait-surface.md index ef4aae32..fde9b463 100644 --- a/docs/adr/0009-host-trait-surface.md +++ b/docs/adr/0009-host-trait-surface.md @@ -96,6 +96,8 @@ This interacts with `wit_bindgen::generate!` in a way worth pinning here, becaus **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. +**Update: the hardening shipped** (earlier than planned, in the M1 SDK-surfaces work). `#[nexum_sdk::module]` derives a per-module world from the manifest's `[capabilities]`, so a macro-built component's imports equal its declarations by construction, an undeclared capability is a compile-time error (its bindings do not exist), and `enforce_capabilities` is a backstop rather than an elision consumer. The paragraphs above stay accurate for hand-rolled modules still compiled against the supertype world (twap-monitor, ethflow-watcher, stop-loss). + +Until those migrate, **a hand-rolled 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._ diff --git a/docs/adr/0013-composable-cow-structured-poll.md b/docs/adr/0013-composable-cow-structured-poll.md index 70222401..63764ab3 100644 --- a/docs/adr/0013-composable-cow-structured-poll.md +++ b/docs/adr/0013-composable-cow-structured-poll.md @@ -16,7 +16,7 @@ The blocker is deployment. The fork is `abiVersion 2.0.0-dev`, `deployments/netw ## 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 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 `videre:venue/client`; `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. diff --git a/docs/deployment.md b/docs/deployment.md index db9be4f0..88213615 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -153,26 +153,19 @@ 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"] +A `Dockerfile` + `docker-compose.yml` ship at the repo root. See +[`docs/deployment/docker.md`](deployment/docker.md) for the full +container workflow. The quick start: + +```sh +cp .env.example .env +$EDITOR .env # paste wss:// RPC URLs +docker compose up -d ``` +The image is published to +`ghcr.io/nullislabs/shepherd:` on every merged commit to `main`. + Mount the `state_dir` as a volume so the redb file survives container restarts. diff --git a/docs/deployment/multi-chain.md b/docs/deployment/multi-chain.md new file mode 100644 index 00000000..b15e3a80 --- /dev/null +++ b/docs/deployment/multi-chain.md @@ -0,0 +1,309 @@ +# Multi-chain deployment patterns + +This guide covers running Shepherd against multiple EVM chains simultaneously. +The engine dispatches each module only to the chains it subscribes to, so a +single `nexum` process can serve modules watching Mainnet, Gnosis Chain, +Arbitrum One, and Base at the same time. + +That covers keeper modules, which subscribe per chain. It does not extend to +venue adapters. The CoW adapter fixes its orderbook chain at `init` from its +own manifest `[config] chain`, and registers under the fixed venue id `cow` +(`CowVenue::ID`), which carries no chain component. Two cow adapters installed +in one process would therefore register under the same id, with nothing at the +venue registry to tell them apart. + +Single-process multi-chain *submission* is an explicit non-goal for M4. Run one +engine process per submitting chain, each paired with the matching adapter +manifest (`module.toml` for Mainnet, `module.sepolia.toml` for Sepolia). +Modules that only watch chains are unaffected and may span chains freely. + +--- + +## Chain support matrix + +The table lists the chains this repo's modules target. The CoW Protocol +orderbook supports more (the `cowprotocol` crate's `Chain` enum also carries +BNB, Polygon, Avalanche, Linea, and Plasma); the same wiring pattern applies +to any of them. Shepherd can subscribe to block and log events on any EVM +chain; add only the chains your modules actually use. + +| Chain | Chain ID | Orderbook slug | Barn (staging) | Notes | +|-------|----------|----------------|----------------|-------| +| Ethereum Mainnet | 1 | `mainnet` | ✓ | Primary production chain | +| Gnosis Chain | 100 | `xdai` | ✓ | Second-most-active CoW deployment | +| Base | 8453 | `base` | ✗ | | +| Arbitrum One | 42161 | `arbitrum_one` | ✓ | | +| Sepolia | 11155111 | `sepolia` | ✓ | Recommended testnet for soak runs | + +The CoW Protocol orderbook is not deployed on every EVM chain. A `[chains.]` +entry in `engine.toml` for a chain with no orderbook deployment will open block +and log subscriptions, but any module that calls `cow-api` will fail at runtime. + +--- + +## `engine.toml` — per-chain RPC wiring + +Add one `[chains.]` table per chain. Log-subscription modules require a +WebSocket (`wss://`) transport; request-only modules can use HTTP. + +```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 (soak / staging) +rpc_url = "${SEPOLIA_RPC_URL}" +``` + +`${VAR}` tokens are substituted at engine boot from environment variables. A +missing variable fails fast with the exact variable name. In Docker Compose, +forward the variables from the host `.env` file: + +```yaml +# docker-compose.yml (engine service environment section) +environment: + MAINNET_RPC_URL: + GNOSIS_RPC_URL: + BASE_RPC_URL: + ARBITRUM_RPC_URL: + SEPOLIA_RPC_URL: +``` + +### Opt out of the WebSocket requirement + +By default the engine logs an ERROR at boot when a chain is configured with an +HTTP URL because `block` and `log` subscriptions need WebSocket. If a chain is +used only for `chain::request` (poll-only modules with no `[[subscription]]`), +suppress it: + +```toml +[chains.1] +rpc_url = "https://eth.llamarpc.com" +require_ws = false +``` + +--- + +## CoW Protocol orderbook URLs + +The engine's `cow-api` extension resolves the orderbook URL automatically from +the chain ID using the canonical `https://api.cow.fi//` pattern. No +extra config is needed for production. + +Override individual chains to point at a staging ("barn") instance or a local +mock: + +```toml +[extensions.cow.orderbook_urls] +# Point chain 11155111 at the barn (staging) orderbook: +11155111 = "https://barn.api.cow.fi/sepolia/" + +# Point chain 1 at a local Wiremock during integration testing: +1 = "http://localhost:9999/" +``` + +**Canonical URLs by chain:** + +| 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/` | — | +| 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 chain) + +These addresses are the same across all supported chains: + +| Contract | Address | +|----------|---------| +| `GPv2Settlement` | `0x9008D19f58AAbD9eD0D60971565AA8510560ab41` | +| `GPv2VaultRelayer` | `0xC92E8bdf79f0507f65a392b0ab4667716BFE0110` | +| `ComposableCoW` | `0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74` | + +### EthFlow + +The **current** production EthFlow deployment is address-identical on every +chain CoW Protocol supports (the `cowprotocol` crate pins it as +`ETH_FLOW_PRODUCTION`, sourced from +[`cowprotocol/ethflowcontract`](https://github.com/cowprotocol/ethflowcontract) +`networks.prod.json`): + +``` +0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC (all supported chains) +``` + +This is what `modules/ethflow-watcher/module.toml` wires for Sepolia, and the +same value carries to Mainnet, Gnosis Chain, Arbitrum One, and Base today. + +Unlike ComposableCoW, however, the sameness is **not a CREATE2 guarantee** - +EthFlow has legacy per-version deployments at other addresses (e.g. the +v1.0.0 contracts), and a future version may land at a new address on a +per-chain schedule. When porting `ethflow-watcher` to a new chain or bumping +the contract version, verify the `[[subscription]] address` against +`networks.prod.json` for that chain rather than assuming the constant holds. + +--- + +## Module manifests — the `[[subscription]]` duplication pattern + +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. + +### twap-monitor on Mainnet + Gnosis + +```toml +# module.toml for twap-monitor (multi-chain) + +# ComposableCoW.ConditionalOrderCreated on Mainnet +[[subscription]] +kind = "chain-log" +chain_id = 1 +address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" +event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361" + +# New-block ticks on Mainnet (drives the poll loop) +[[subscription]] +kind = "block" +chain_id = 1 + +# ComposableCoW.ConditionalOrderCreated on Gnosis Chain +[[subscription]] +kind = "chain-log" +chain_id = 100 +address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" +event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361" + +# New-block ticks on Gnosis Chain +[[subscription]] +kind = "block" +chain_id = 100 +``` + +The module's `on_event` receives every event tagged with its `chain_id`; use +the chain ID to dispatch to the correct `chain::request` target and the correct +`cow-api` submission path. + +### ethflow-watcher on Mainnet + Sepolia + +```toml +# module.toml for ethflow-watcher (multi-chain) + +# CoWSwapEthFlow.OrderPlacement on Mainnet +# IMPORTANT: verify this address against cowprotocol/ethflowcontract +# networks.prod.json before deploying — EthFlow is NOT CREATE2-stable. +[[subscription]] +kind = "chain-log" +chain_id = 1 +address = "" +event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" + +# CoWSwapEthFlow.OrderPlacement on Sepolia +[[subscription]] +kind = "chain-log" +chain_id = 11155111 +address = "0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC" +event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" +``` + +--- + +## Event topic reference + +These are keccak256 hashes of the event signatures. They are the same on every +chain; only the contract `address` changes for EthFlow. Package of record: +`wit/shepherd-cow/cow-events.wit`. + +| Event | Topic-0 | +|-------|---------| +| `ComposableCoW.ConditionalOrderCreated(address,(address,bytes32,bytes))` | `0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361` | +| `CoWSwapEthFlow.OrderPlacement(address,(address,address,address,uint256,uint256,uint32,bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)` | `0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9` | + +--- + +## Resource sizing (per additional chain) + +Each new chain adds: + +- **1 block subscription** (always-on WS stream, ~zero CPU when idle). +- **N log subscriptions**, where N = number of modules with a `chain-log` + subscription on that chain. +- **M `eth_call`s per block** for polling modules (e.g. TWAP), where M scales + linearly with the number of active registered orders on that chain. + +RPC provider sizing: budget **≥ 25 sustained req/s per chain** (a paid +Alchemy / Infura / QuickNode tier - free tiers throttle `eth_subscribe` +under exactly this load). Dedicated endpoints per chain are preferable to +shared-rate plans when running `block` subscriptions simultaneously. + +Monitor `shepherd_chain_request_total{outcome="err"}` per `chain_id` — a +sustained rate above 5% on any chain indicates RPC degradation. + +--- + +## Full multi-chain `engine.docker.toml` example + +```toml +[engine] +state_dir = "/var/lib/shepherd" +log_level = "info" + +[engine.metrics] +enabled = true +bind_addr = "0.0.0.0:9100" + +[chains.1] +rpc_url = "${MAINNET_RPC_URL}" + +[chains.100] +rpc_url = "${GNOSIS_RPC_URL}" + +[chains.8453] +rpc_url = "${BASE_RPC_URL}" + +[chains.42161] +rpc_url = "${ARBITRUM_RPC_URL}" + +[chains.11155111] +rpc_url = "${SEPOLIA_RPC_URL}" + +[extensions.cow.orderbook_urls] +# Uncomment to override individual chains with barn or a local mock: +# 11155111 = "https://barn.api.cow.fi/sepolia/" + +[[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" +``` + +--- + +## See also + +- [`docs/deployment.md`](../deployment.md) — `engine.toml` reference and + single-module quickstart +- [`docs/production.md`](../production.md) — systemd, Docker, RPC provider + recommendations, and alerting rules +- [`docs/deployment/docker.md`](./docker.md) — container image layout +- [`modules/twap-monitor/module.toml`](../../modules/twap-monitor/module.toml) + — canonical subscription example +- [`modules/ethflow-watcher/module.toml`](../../modules/ethflow-watcher/module.toml) + — EthFlow subscription with per-chain address caveat diff --git a/docs/design/linker-extension-seam.md b/docs/design/linker-extension-seam.md index 76525a92..f24d4b19 100644 --- a/docs/design/linker-extension-seam.md +++ b/docs/design/linker-extension-seam.md @@ -97,11 +97,14 @@ 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 +## Normative rule: import narrowing and boot ordering + +Modules built through `#[nexum_sdk::module]` compile against a per-module +world derived from their manifest's `[capabilities]`, so a module that +never declares cow-api has no cow-api import and boots with a core-only +linker by construction. Hand-rolled modules compiled against the supertype +world reach the same shape a weaker way: the `wasm-tools` pipeline elides +any WIT import the produced component does not exercise. 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 diff --git a/docs/diagrams/diagrams.md b/docs/diagrams/diagrams.md index a9d6c21a..8e67ba53 100644 --- a/docs/diagrams/diagrams.md +++ b/docs/diagrams/diagrams.md @@ -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"] @@ -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,13 +182,13 @@ 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`. | +| **shepherd:cow@0.1.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). | | **(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`. | 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/load-testnet-runbook.md b/docs/operations/load-testnet-runbook.md index 717f74b4..d4bfb659 100644 --- a/docs/operations/load-testnet-runbook.md +++ b/docs/operations/load-testnet-runbook.md @@ -166,9 +166,10 @@ Look at: ## 4. What this does NOT prove - WS reconnect resilience (7-day soak). -- Diverse appData / order-shape correctness (the backtest). +- Diverse appData / order-shape correctness (the watcher replays real + placements from contract genesis at startup). - Multi-day memory drift (7-day soak). -- Real-orderbook 4xx variety (the backtest). +- Real-orderbook 4xx variety (only a live-orderbook run exercises this). - Provider rate-limit handling on the live network. This test answers exactly one question: "How many TWAP+EthFlow events diff --git a/docs/operations/m3-edge-case-validation.md b/docs/operations/m3-edge-case-validation.md index c31cda38..62b681ab 100644 --- a/docs/operations/m3-edge-case-validation.md +++ b/docs/operations/m3-edge-case-validation.md @@ -79,7 +79,7 @@ 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 + 1: component imports `cow-api` (shepherd:cow/cow-api@0.1.0) but it is not listed in [capabilities].required or [capabilities].optional ``` diff --git a/docs/production.md b/docs/production.md index 1392dde5..9d65236e 100644 --- a/docs/production.md +++ b/docs/production.md @@ -140,45 +140,14 @@ 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.1 Dockerfile + +The official multi-stage `Dockerfile` ships at the repo root - it +builds the `nexum` binary, compiles all five module wasms, and runs as +a non-root user under `tini`. Build it with `docker build -t shepherd .` +or let the root `docker-compose.yml` build it for you. The full image +reference (published tags, pinning, .env wiring) is in +[`docs/deployment/docker.md`](deployment/docker.md). ### 3.2 docker-compose.yml diff --git a/docs/qa-signoff.md b/docs/qa-signoff.md index 808d03e6..8953a78b 100644 --- a/docs/qa-signoff.md +++ b/docs/qa-signoff.md @@ -12,7 +12,7 @@ | `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. | +| `warn(unused_crate_dependencies)` on every crate root | ✅ | sdk, sdk-test, nexum-runtime, 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. | diff --git a/docs/sdk.md b/docs/sdk.md index e752c5fa..1e93cd36 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -1,66 +1,81 @@ -# 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::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 sweep. +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/`, +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: +`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. +"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 +[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`): @@ -70,8 +85,8 @@ seam one-for-one. 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 @@ -93,23 +108,27 @@ seam one-for-one. failures. See `modules/examples/http-probe` for a complete module. -## Companions: nexum-sdk-test and shepherd-sdk-test +## Companions: nexum-sdk-test and videre-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 +Add `nexum-sdk-test` as a dev-dep on the module crate to write strategy +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](../crates/nexum-sdk-test/src/lib.rs), -[shepherd-sdk-test](../crates/shepherd-sdk-test/src/lib.rs)) for the -usage pattern. +[videre-test](../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 +and `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/tutorial-first-module.md b/docs/tutorial-first-module.md index 9c4c5604..6dc7d2ae 100644 --- a/docs/tutorial-first-module.md +++ b/docs/tutorial-first-module.md @@ -304,8 +304,8 @@ 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 +// `WitBindgenHost`, the fault and level `From` impls, `HostLogSink`, +// and `install_tracing` are generated below. Single source // of truth in `nexum-sdk` + `shepherd-sdk`. shepherd_sdk::bind_cow_host_via_wit_bindgen!(); @@ -316,7 +316,7 @@ 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)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "stop-loss init: owner={:#x} trigger={} sell={:#x} buy={:#x}", cfg.owner, @@ -333,7 +333,7 @@ impl Guest for StopLoss { return Ok(()); }; if let types::Event::Block(block) = event { - strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_fault_into_wit)?; + strategy::on_block(&WitBindgenHost, block.chain_id, cfg)?; } Ok(()) } @@ -344,7 +344,7 @@ export!(StopLoss); The macro generates `WitBindgenHost`, the `ChainHost` / `LocalStoreHost` / `LoggingHost` / `CowApiHost` impls, the -`Fault` conversions (`convert_fault`, `sdk_fault_into_wit`), and +`Fault` `From` impls (both directions), 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 diff --git a/engine.docker.toml b/engine.docker.toml index 72141a15..80331746 100644 --- a/engine.docker.toml +++ b/engine.docker.toml @@ -77,3 +77,16 @@ 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..31960778 100644 --- a/engine.e2e.toml +++ b/engine.e2e.toml @@ -65,3 +65,13 @@ manifest = "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 = "crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.example.toml b/engine.example.toml index 39624528..69184ca4 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 = "crates/cow-venue/module.toml" +# http_allow = ["api.cow.fi"] diff --git a/engine.load.toml b/engine.load.toml index 7999669d..4f8072b2 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" +# 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 = "./crates/cow-venue/module.load.toml" +http_allow = ["localhost"] + [[modules]] path = "./target/wasm32-wasip2/release/ethflow_watcher.wasm" manifest = "./modules/ethflow-watcher/module.toml" diff --git a/engine.m2.toml b/engine.m2.toml index cce435c3..11f29389 100644 --- a/engine.m2.toml +++ b/engine.m2.toml @@ -33,3 +33,11 @@ manifest = "modules/twap-monitor/module.toml" [[modules]] path = "target/wasm32-wasip2/release/ethflow_watcher.wasm" manifest = "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 = "crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.m3.toml b/engine.m3.toml index d5fb7e69..fc21fefe 100644 --- a/engine.m3.toml +++ b/engine.m3.toml @@ -3,7 +3,7 @@ # 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). +# diffing, pool submit through the cow adapter with PreSign). # # Usage: # just run-m3 @@ -11,7 +11,8 @@ # 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. @@ -34,3 +35,13 @@ manifest = "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 stop-loss submits 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 = "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..7efd05cc 100644 --- a/engine.soak.docker.toml +++ b/engine.soak.docker.toml @@ -46,3 +46,12 @@ 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..ba3e78d7 100644 --- a/engine.soak.toml +++ b/engine.soak.toml @@ -9,6 +9,8 @@ # cargo build --target wasm32-wasip2 --release \ # -p twap-monitor -p ethflow-watcher -p price-alert \ # -p balance-tracker -p stop-loss +# 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 @@ -66,3 +68,13 @@ manifest = "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 = "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..019fb0a6 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 @@ -32,8 +43,8 @@ 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. @@ -46,8 +57,8 @@ build-m3: # (Sepolia, 3 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. @@ -63,14 +74,25 @@ build-e2e: build-m2 build-m3 # 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 # 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 @@ -86,7 +108,7 @@ ci: 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 + -p balance-tracker -p stop-loss -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 cargo test --workspace --all-features --no-fail-fast diff --git a/modules/ethflow-watcher/Cargo.toml b/modules/ethflow-watcher/Cargo.toml index 3beb4710..1902df84 100644 --- a/modules/ethflow-watcher/Cargo.toml +++ b/modules/ethflow-watcher/Cargo.toml @@ -6,15 +6,13 @@ 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"] +# `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" } +videre-sdk = { path = "../../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"] } @@ -22,5 +20,4 @@ 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/module.toml b/modules/ethflow-watcher/module.toml index 45b6519a..cb99e038 100644 --- a/modules/ethflow-watcher/module.toml +++ b/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 +# strategy.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/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index 3286a566..35b800a8 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -1,23 +1,21 @@ //! # 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. +//! canonical CoWSwap EthFlow contracts, computes each placement's +//! orderbook UID, and puts it under the host's status watch through the +//! typed cow venue client. The registry polls the cow adapter and fans +//! transitions back as `intent-status` events; the module journals +//! `observed:{uid}` on the first one. Observe-only: it never submits +//! (see `strategy.rs`). //! //! ## 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`. +//! - `strategy.rs` holds the pure logic and unit tests against the +//! `nexum_sdk::host` and `videre_sdk` client seams. It does not know +//! `wit-bindgen` exists. +//! - `lib.rs` (this file) is the per-cdylib glue: the +//! `#[videre_sdk::keeper]` handler impl that binds the world derived +//! from `module.toml` and delegates each event to `strategy`. // wit_bindgen::generate! expands to host-import shims whose arity // matches the WIT signatures, which can exceed clippy's @@ -25,57 +23,48 @@ #![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. +// 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 +// strategy 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 _; -#[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; +mod glue { + use cow_venue::client::CowClient; -#[cfg(target_arch = "wasm32")] -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); + use crate::strategy; -#[cfg(target_arch = "wasm32")] -struct EthFlowWatcher; + 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(()) - } + #[videre_sdk::keeper] + impl 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 { + async fn on_chain_logs(batch: nexum::host::types::ChainLogs) -> Result<(), Fault> { 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)?; + strategy::on_chain_logs(&WitBindgenHost, &CowClient::new(), batch.chain_id, &logs) + .await?; + Ok(()) + } + + fn on_intent_status(update: videre_sdk::IntentStatusUpdate) -> Result<(), Fault> { + strategy::on_intent_status( + &WitBindgenHost, + &update.venue, + &update.receipt, + &update.status, + )?; + Ok(()) } - // 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 index 99e25ba1..69456554 100644 --- a/modules/ethflow-watcher/src/strategy.rs +++ b/modules/ethflow-watcher/src/strategy.rs @@ -1,11 +1,11 @@ //! 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`. +//! Every interaction with the world flows through two seams: the +//! `nexum_sdk::host` traits for the `observed:` journal and the typed +//! [`CowClient`] over [`VenueTransport`] for the venue. The `lib.rs` +//! glue binds both to the module's own imports; tests drive the same +//! functions with `nexum_sdk_test::MockHost` and an in-memory spy +//! transport. //! //! ## Design (redesign) //! @@ -13,36 +13,36 @@ //! 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. +//! events natively and writes server-only fields the public POST body +//! does not carry. //! -//! This strategy therefore **observes + verifies** instead of -//! submitting: +//! This strategy therefore **observes + verifies** through the venue +//! registry: //! //! 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. +//! 2. Compute the orderbook UID from the on-chain order shape: the +//! externally-obtained receipt at the cow venue. +//! 3. `observe` the receipt through the venue registry, which polls the +//! cow adapter's `status` and fans transitions back as +//! `intent-status` events. On the first one, record `observed:{uid}` +//! in the keeper idempotency journal so log re-delivery is a no-op. +//! A refused observe is logged and left unjournalled, so the next +//! re-delivery retries. 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; +use nexum_sdk::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::Journal; -use shepherd_sdk::cow::{CowApiError, CowHost, gpv2_to_order_data}; +use videre_sdk::client::{Venue, VenueTransport}; +use videre_sdk::status_body::StatusBody; /// Decoded payload of a `CoWSwapOnchainOrders.OrderPlacement` log. /// `GPv2OrderData` is ~300 bytes; box it so the struct stays @@ -70,16 +70,51 @@ pub(crate) struct DecodedPlacement { } /// 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> { +/// and put each decoded 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, chain_id, &placement)?; + observe_placement(host, venue, chain_id, &placement).await?; } } Ok(()) } +/// A registry status transition for a watched receipt. Foreign venues +/// are ignored; the first transition for a cow receipt records the +/// `observed:{uid}` marker (the orderbook indexed the placement), 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 ---- /// Decode a raw event log against `CoWSwapOnchainOrders.OrderPlacement`. @@ -89,13 +124,16 @@ pub fn on_chain_logs(host: &H, chain_id: u64, logs: &[Log]) -> Resul /// `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 +/// - topic-0 does not match 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, @@ -106,57 +144,43 @@ pub(crate) fn decode_order_placement(log: &Log) -> Option { }) } -// ---- observe + verify (redesign) ---- +// ---- observe + verify (venue registry) ---- -/// Compute the orderbook UID for the placement and confirm the -/// orderbook's native EthFlow indexer picked it up. -fn observe_placement( +/// Compute the orderbook UID for the placement and put it under the +/// host's status watch. A refused observe (venue down, watch set full) +/// is logged and left unjournalled, so re-delivery retries. +async fn observe_placement( host: &H, + venue: &CowClient, 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(()); - } + 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 verified, do not re-check on log re-delivery + // 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(()); } - 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. + match venue.observe(uid.as_slice()).await { + Ok(()) => { tracing::info!( - "ethflow not yet indexed {uid_hex} (sender={:#x}); will recheck on re-delivery", + "ethflow watching {uid_hex} (sender={:#x})", placement.sender, ); } Err(err) => { tracing::warn!( - "ethflow indexer check failed {uid_hex}: {err} (sender={:#x})", + "ethflow watch failed {uid_hex}: {err} (sender={:#x})", placement.sender, ); } @@ -165,30 +189,140 @@ fn observe_placement( } /// 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. +/// The 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)) + let order = assembly::gpv2_to_order_data(&placement.order)?; + Some(assembly::order_uid(chain, &order, placement.contract)) } #[cfg(test)] mod tests { - use super::*; - use alloy_primitives::{U256, address, b256, hex}; + 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::{Fault, LocalStoreHost as _}; - use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::HttpFailure; - use shepherd_sdk_test::MockHost; + use nexum_sdk::host::LocalStoreHost as _; + use nexum_sdk_test::{MockHost, capture_tracing}; + use videre_sdk::client::VenueId; + use videre_sdk::rt::complete; + 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: which verb, and for `observe` the + /// routed 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 and + /// defaults to accepted once the script drains. The other verbs + /// refuse: the observe-only strategy must never reach them. + /// Cloneable over shared state so the test keeps a handle after + /// one moves into the client. + #[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 strategy 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()); + complete(on_chain_logs(host, &client, chain_id, logs)) + .expect("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"), @@ -243,11 +377,14 @@ mod tests { .into() } - fn computed_uid(placement: &DecodedPlacement) -> String { - format!( - "{}", - compute_uid(SEPOLIA, placement).expect("sepolia + canonical markers") - ) + 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") } // ---- decode (invariants preserved) ---- @@ -255,9 +392,7 @@ mod tests { #[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"); + 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); @@ -291,11 +426,7 @@ mod tests { #[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 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()); @@ -308,273 +439,202 @@ mod tests { #[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(); + let decoded = decode_order_placement(&sample_log()).unwrap(); assert!(compute_uid(9999, &decoded).is_none()); } - // ---- observe + verify dispatch (Host-trait integration) ---- + // ---- observe via the venue registry (transport integration) ---- - /// 200 from `GET /api/v1/orders/{uid}` → `observed:{uid}` written - /// + Info log + zero submit attempts. + /// A placement registers exactly one status watch at the cow venue + /// with the computed UID as the receipt, and journals nothing yet: + /// the marker waits for the first status transition. #[test] - fn placement_log_marks_observed_on_orderbook_200() { + fn placement_log_registers_the_uid_watch() { 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()), - ); + let spy = SpyVenues::default(); + let uid = sample_uid(); - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + run_logs(&host, &spy, SEPOLIA, &[sample_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" + spy.calls(), + vec![Call::Observe("cow".to_owned(), uid.as_slice().to_vec())], + "exactly one observe, nothing else", ); - } - - /// 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" + host.store.snapshot().is_empty(), + "observed:{{uid}} waits for the first status transition", ); } - /// Non-404 error from the orderbook check → Warn log + no marker. + /// A refused observe warns, journals nothing, and the next + /// re-delivery retries the watch. #[test] - fn placement_log_warns_on_orderbook_other_error() { + fn watch_refusal_warns_and_redelivery_retries() { 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 spy = SpyVenues::default(); + spy.script_observe(Err(VenueFault::Unavailable("venue down".to_owned()))); - 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])); + let (result, logs) = capture_tracing(|| run_logs(&host, &spy, SEPOLIA, &[sample_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")); + 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); } /// Idempotency: a placement that already has `observed:{uid}` in - /// local store does NOT trigger a fresh GET on re-delivery. + /// local store does NOT touch the venue 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); + let spy = SpyVenues::default(); + let uid = sample_uid(); host.store .set(&format!("observed:{uid}"), b"") .expect("seed observed marker"); - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + run_logs(&host, &spy, SEPOLIA, &[sample_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" + assert!( + spy.calls().is_empty(), + "observed:{{uid}} must short-circuit before the venue call", ); } /// Defensive: unsupported chain id surfaces a Warn but does not - /// panic and does not touch the orderbook. + /// panic and does not touch the venue. #[test] - fn unsupported_chain_logs_warn_without_orderbook_call() { + fn unsupported_chain_logs_warn_without_venue_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); + let spy = SpyVenues::default(); // 9999 is not in cowprotocol::Chain. - let (result, logs) = capture_tracing(|| on_chain_logs(&host, 9999, &[log])); + let (result, logs) = capture_tracing(|| run_logs(&host, &spy, 9999, &[sample_log()])); result.unwrap(); - assert_eq!(host.cow_api.request_calls().len(), 0); - assert_eq!(host.cow_api.call_count(), 0); + 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") }); } - /// 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. + /// The strategy is observer-only: no call path reaches quote, + /// submit, status, or cancel. Belt-and-suspenders regression guard. #[test] - fn strategy_never_calls_submit_order() { + fn strategy_never_submits() { 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())); + let spy = SpyVenues::default(); - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); - assert_eq!( - host.cow_api.call_count(), - 0, - "submit_order count must stay at zero - ethflow-watcher is observer-only" + assert!( + spy.calls().iter().all(|c| matches!(c, Call::Observe(..))), + "observe is the only verb the strategy may use", ); } - /// 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", - ); - } + // ---- intent-status transitions ---- - /// 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.) + /// The first cow transition journals `observed:{uid}` and logs it. #[test] - fn manifest_topic0_matches_order_placement_signature_hash() { - let manifest = include_str!("../module.toml"); - let expected = format!("0x{:x}", OrderPlacement::SIGNATURE_HASH); + 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!( - manifest.contains(&expected), - "module.toml event_signature must equal OrderPlacement::SIGNATURE_HASH ({expected})", + 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); } - /// 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. + /// Later transitions keep the single marker and stay Ok. #[test] - fn placement_log_warns_on_429_rate_limit() { + fn repeated_transitions_keep_one_marker() { 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); + let uid = sample_uid(); - host.cow_api - .respond_to_request(Err(CowApiError::Http(HttpFailure { - status: 429, - body: Some("Too Many Requests".to_string()), - }))); + 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(); - let (result, logs) = capture_tracing(|| on_chain_logs(&host, SEPOLIA, &[log])); - result.unwrap(); + assert_eq!(host.store.snapshot().len(), 1); + } - 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")); + /// 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()); } - /// 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. + /// A cow receipt that is not a 56-byte UID warns without a marker. #[test] - fn placement_log_marks_observed_on_malformed_response_body() { + fn non_uid_receipt_warns_without_marker() { 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); + 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")); + } - host.cow_api - .respond_to_request(Ok("not-valid-json{{{{".to_string())); + /// A status body the SDK cannot decode is a typed fault, never a + /// silent 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()); + } - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + // ---- package-of-record parity ---- + /// Guard: the `sol!` decoder's topic-0 matches the + /// `shepherd:cow/cow-events` package of record. A typo or ABI + /// 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!( - host.store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "200 with malformed body must still write observed: — strategy does not parse the response", + wit.contains(&expected), + "sol! topic-0 must match the shepherd:cow/cow-events pin ({expected})", + ); + } + + /// Read the shipped `module.toml` and assert its pinned + /// `event_signature` equals the decoder topic-0 - catches a + /// manifest/code drift the wit assertion cannot see. + #[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/modules/example/Cargo.toml b/modules/example/Cargo.toml index c9a0ff70..19311814 100644 --- a/modules/example/Cargo.toml +++ b/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/src/lib.rs b/modules/example/src/lib.rs index 6ed4deb6..5597f442 100644 --- a/modules/example/src/lib.rs +++ b/modules/example/src/lib.rs @@ -1,24 +1,24 @@ +//! # example (reference Shepherd module) +//! +//! The minimal reference module: one handler per event, each logging a +//! one-line summary through the raw host `logging` binding. It carries +//! no strategy layer and no `[config]` behaviour, so it doubles as the +//! smallest end-to-end demonstration of `#[nexum_sdk::module]` - the +//! attribute supplies the wit-bindgen call, the host adapter, the +//! `Guest`/`on-event` dispatch, and `export!`, leaving only the +//! handlers. + // 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; +use nexum::host::{logging, 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 { +#[nexum_sdk::module] +impl ExampleModule { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { let name = config .iter() @@ -32,38 +32,50 @@ impl Guest for ExampleModule { 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), - ); - } - } + 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(()) } -} -export!(ExampleModule); + 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/src/lib.rs b/modules/examples/balance-tracker/src/lib.rs index 55aec121..ee3ae410 100644 --- a/modules/examples/balance-tracker/src/lib.rs +++ b/modules/examples/balance-tracker/src/lib.rs @@ -9,10 +9,10 @@ //! ## Module layout //! //! - `strategy.rs` holds the pure logic and tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` +//! the `nexum_sdk::host` trait seam. 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. +//! - `lib.rs` (this file) declares the handlers and defers the +//! per-cdylib glue to `#[nexum_sdk::module]`. //! //! ## Config //! @@ -27,31 +27,24 @@ #![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(); +// `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; -impl Guest for BalanceTracker { +#[nexum_sdk::module] +impl BalanceTracker { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "balance-tracker init: {} addresses, threshold={} wei", cfg.addresses.len(), @@ -61,15 +54,10 @@ impl Guest for BalanceTracker { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { + fn on_block(block: types::Block) -> 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(()) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(Into::into) } } - -export!(BalanceTracker); diff --git a/modules/examples/balance-tracker/src/strategy.rs b/modules/examples/balance-tracker/src/strategy.rs index 91e25193..f1f5bf73 100644 --- a/modules/examples/balance-tracker/src/strategy.rs +++ b/modules/examples/balance-tracker/src/strategy.rs @@ -1,11 +1,13 @@ //! Pure strategy 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`. +//! Every interaction with the world flows through the host trait +//! seam exposed by `nexum-sdk`, bounded to exactly the interfaces the +//! module declares ([`ChainHost`] + [`LocalStoreHost`]) - 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 @@ -15,7 +17,7 @@ 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 @@ -35,7 +37,11 @@ pub struct Settings { /// 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> { +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}"); @@ -47,7 +53,7 @@ pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Result /// 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( +fn check_one( host: &H, chain_id: u64, addr: Address, @@ -74,7 +80,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(|| { @@ -149,7 +155,7 @@ 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}; diff --git a/modules/examples/echo-client/Cargo.toml b/modules/examples/echo-client/Cargo.toml new file mode 100644 index 00000000..c5c20896 --- /dev/null +++ b/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 = "../../../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/modules/examples/echo-client/module.toml b/modules/examples/echo-client/module.toml new file mode 100644 index 00000000..9dee6673 --- /dev/null +++ b/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/modules/examples/echo-client/src/lib.rs b/modules/examples/echo-client/src/lib.rs new file mode 100644 index 00000000..33025943 --- /dev/null +++ b/modules/examples/echo-client/src/lib.rs @@ -0,0 +1,90 @@ +//! # 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 `videre:venue/client` to the `echo-venue` +//! adapter and logs the receipt, and it logs each `intent-status` transition the +//! registry fans back from that venue. Paired with the echo-venue adapter it +//! is the smallest end-to-end demonstration of the intent core: module -> +//! host registry -> venue adapter, and the status event back. +//! +//! It declares two capabilities (`client`, `logging`), so the built +//! component imports `videre:venue/client` and `nexum:host/logging` and +//! nothing else: the per-module world matches the manifest by construction. + +// 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; the module submits +/// to and observes exactly this venue. +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/modules/examples/echo-keeper/Cargo.toml b/modules/examples/echo-keeper/Cargo.toml new file mode 100644 index 00000000..ff843e85 --- /dev/null +++ b/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 = "../../../crates/nexum-sdk" } +videre-sdk = { path = "../../../crates/videre-sdk" } +wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/examples/echo-keeper/module.toml b/modules/examples/echo-keeper/module.toml new file mode 100644 index 00000000..0620286c --- /dev/null +++ b/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/modules/examples/echo-keeper/src/lib.rs b/modules/examples/echo-keeper/src/lib.rs new file mode 100644 index 00000000..b840f7c0 --- /dev/null +++ b/modules/examples/echo-keeper/src/lib.rs @@ -0,0 +1,109 @@ +//! # echo-keeper (reference videre keeper module) +//! +//! The blessed 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. Where echo-client hand-writes byte marshalling over the +//! raw `videre:venue/client` import, this module is +//! `#[videre_sdk::keeper]`: the macro wires the world and the client +//! import, and the author never sees wire bytes. +//! +//! It declares two capabilities (`client`, `logging`), so the built +//! component imports `videre:venue/client` and `nexum:host/logging` and +//! nothing else: the per-module world matches the manifest by +//! construction. + +// 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 id the paired adapter +/// answers for 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. The echo venue accepts any +/// bytes, so v1 is just the block number: enough to exercise the typed +/// codec end to end. +#[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/modules/examples/echo-venue/Cargo.toml b/modules/examples/echo-venue/Cargo.toml new file mode 100644 index 00000000..a1d818f1 --- /dev/null +++ b/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/modules/examples/echo-venue/module.toml b/modules/examples/echo-venue/module.toml new file mode 100644 index 00000000..bd17af64 --- /dev/null +++ b/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/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs new file mode 100644 index 00000000..2a3d8d50 --- /dev/null +++ b/modules/examples/echo-venue/src/lib.rs @@ -0,0 +1,181 @@ +//! # echo-venue (reference Shepherd venue adapter) +//! +//! The minimal reference venue adapter: it accepts any body, echoes it back +//! as the receipt, and settles instantly (every receipt it issued reports +//! `fulfilled`). It carries no real venue protocol, so it doubles as the +//! smallest end-to-end demonstration of `#[videre_sdk::venue]` - the +//! attribute takes the `impl VenueAdapter` block and supplies the +//! per-cdylib wit-bindgen for a world derived from `module.toml` plus the +//! export glue - and as the `videre-test` conformance target (see the +//! tests below). +//! +//! It declares one capability (`chain`), so the built component imports +//! `nexum:host/chain` and nothing else: the per-component world matches +//! the manifest by construction. + +// 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 adapter's +/// pure header derivation is held to a hand-written golden. The macro +/// remaps the type interfaces onto the SDK bindings, so the derivation +/// feeds the kit directly through its `From` mirror. +#[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/modules/examples/http-probe/src/lib.rs b/modules/examples/http-probe/src/lib.rs index bb9ab42e..672f0001 100644 --- a/modules/examples/http-probe/src/lib.rs +++ b/modules/examples/http-probe/src/lib.rs @@ -14,9 +14,8 @@ //! - `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. +//! - `lib.rs` (this file) declares the handlers and defers the +//! per-cdylib glue to `#[nexum_sdk::module]`. //! //! ## Settings //! @@ -36,31 +35,24 @@ #![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(); +// 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; -impl Guest for HttpProbe { +#[nexum_sdk::module] +impl HttpProbe { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "http-probe init: probe_url={} denied_url={} every_n_blocks={}", cfg.probe_url, @@ -71,16 +63,10 @@ impl Guest for HttpProbe { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { + fn on_block(block: types::Block) -> 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(()) + strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number).map_err(Into::into) } } - -export!(HttpProbe); diff --git a/modules/examples/http-probe/src/strategy.rs b/modules/examples/http-probe/src/strategy.rs index 09eb6980..3d3d80b7 100644 --- a/modules/examples/http-probe/src/strategy.rs +++ b/modules/examples/http-probe/src/strategy.rs @@ -86,7 +86,9 @@ fn fetch_err(url: &str, error: &FetchError) -> Fault { 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), } } diff --git a/modules/examples/price-alert/src/lib.rs b/modules/examples/price-alert/src/lib.rs index e9d922d0..f8e3a825 100644 --- a/modules/examples/price-alert/src/lib.rs +++ b/modules/examples/price-alert/src/lib.rs @@ -16,8 +16,8 @@ //! - `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. +//! - `lib.rs` (this file) declares the handlers and defers the +//! per-cdylib glue to `#[nexum_sdk::module]`. //! //! ## Settings //! @@ -42,30 +42,24 @@ #![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(); +// `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; -impl Guest for PriceAlert { +#[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)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "price-alert init: oracle={:#x} threshold={} direction={:?} every_n_blocks={}", cfg.oracle_address, @@ -77,16 +71,10 @@ impl Guest for PriceAlert { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { + fn on_block(block: types::Block) -> 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(()) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number).map_err(Into::into) } } - -export!(PriceAlert); diff --git a/modules/examples/price-alert/src/strategy.rs b/modules/examples/price-alert/src/strategy.rs index 0dcccd95..a6c773e8 100644 --- a/modules/examples/price-alert/src/strategy.rs +++ b/modules/examples/price-alert/src/strategy.rs @@ -1,16 +1,19 @@ //! Pure strategy logic for the price-alert module. //! -//! Every interaction with the world flows through the [`Host`] trait +//! 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`. +//! hand the same function a `nexum_sdk_test::MockHost`. The bound is +//! `ChainHost + LoggingHost`, the module's two declared capabilities: +//! its world imports nothing else, so the full `Host` supertrait (which +//! adds local-store) is unimplementable here by design. 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 @@ -44,7 +47,7 @@ pub enum Direction { /// 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( +pub fn on_block( host: &H, chain_id: u64, settings: &Settings, diff --git a/modules/examples/stop-loss/Cargo.toml b/modules/examples/stop-loss/Cargo.toml index 0dab8781..f0b92be4 100644 --- a/modules/examples/stop-loss/Cargo.toml +++ b/modules/examples/stop-loss/Cargo.toml @@ -4,22 +4,23 @@ 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}." +description = "Shepherd example module: stop-loss order submitter. Watches a Chainlink oracle, submits a CoW order intent through the venue registry when price drops below a configured trigger, dedups via the venue-and-body intent-id." [lib] crate-type = ["cdylib"] [dependencies] +cow-venue = { path = "../../../crates/cow-venue", features = ["client"] } nexum-sdk = { path = "../../../crates/nexum-sdk" } -shepherd-sdk = { path = "../../../crates/shepherd-sdk" } +videre-sdk = { path = "../../../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"] } 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" } +# The chain-edge projections back the pinned-UID regression test. +cow-venue = { path = "../../../crates/cow-venue", features = ["client", "assembly"] } 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`. diff --git a/modules/examples/stop-loss/module.toml b/modules/examples/stop-loss/module.toml index 58065270..b8387417 100644 --- a/modules/examples/stop-loss/module.toml +++ b/modules/examples/stop-loss/module.toml @@ -1,7 +1,7 @@ # 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. +# CoW order intent through the venue registry when the price drops below the +# configured trigger. Demonstrates eth_call + the typed venue client + +# local-store dedup. [module] name = "stop-loss" @@ -9,10 +9,16 @@ version = "0.1.0" component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" [capabilities] -required = ["logging", "chain", "local-store", "cow-api"] +# - logging -> structured runtime logs +# - chain -> eth_call into the Chainlink aggregator +# - local-store -> submitted: / dropped: dedup markers +# - client -> videre:venue/client submit path to the cow adapter +required = ["logging", "chain", "local-store", "client"] optional = [] [capabilities.http] +# All outbound HTTP is the cow adapter's; the module makes no direct +# `http` calls. allow = [] # --- subscriptions ---------------------------------------------------- @@ -21,6 +27,11 @@ allow = [] kind = "block" chain_id = 11155111 # Sepolia +# The one body-schema version this module encodes; install refuses the +# module unless every installed venue adapter decodes it. +[venue] +body_version = 1 + # --- config ----------------------------------------------------------- [config] @@ -35,14 +46,14 @@ decimals = "8" # 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. +# (on-chain, outside this module); the cow adapter posts the unsigned +# body pre-sign 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. +# with TransferSimulationFailed, which classifies as retry-next-block; +# that itself is a valid terminal marker and proves the full submit +# path E2E. owner = "0x7bF140727D27ea64b607E042f1225680B40ECa6A" # WETH9 Sepolia (`wss://sepolia.etherscan.io/token/0xfff9976782d46cc05630d1f6ebab18b2324d6b14`). sell_token = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14" diff --git a/modules/examples/stop-loss/src/lib.rs b/modules/examples/stop-loss/src/lib.rs index b5df56f9..fa226ed9 100644 --- a/modules/examples/stop-loss/src/lib.rs +++ b/modules/examples/stop-loss/src/lib.rs @@ -1,51 +1,46 @@ //! # 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. +//! drops at or below `trigger_price`, the module submits a CoW order +//! intent through the venue registry using the parameters from +//! `module.toml::[config]` and persists a `submitted:` marker to dedup +//! re-poll attempts. The cow adapter posts the unsigned order +//! pre-sign; the owner is expected to call +//! `GPv2Signing.setPreSignature` on-chain ahead of the trigger so the +//! orderbook activates 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. - +//! - `strategy.rs` holds the pure logic and unit tests against the +//! `nexum_sdk::host` trait seams and the videre `VenueTransport` +//! seam. It does not know `wit-bindgen` exists. +//! - `lib.rs` (this file) is the `#[videre_sdk::keeper]` glue: the +//! macro derives the component world from `module.toml`, emits the +//! `WitBindgenHost` adapter, and dispatches each event variant to +//! `strategy` with the typed [`CowClient`] over the module's own +//! `videre:venue/client` import. + +// 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` + `shepherd-sdk`. -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); +use cow_venue::CowClient; static SETTINGS: OnceLock = OnceLock::new(); struct StopLoss; -impl Guest for StopLoss { +#[videre_sdk::keeper] +impl StopLoss { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "stop-loss init: owner={:#x} trigger={} sell={:#x} buy={:#x}", cfg.owner, @@ -57,15 +52,11 @@ impl Guest for StopLoss { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { + fn on_block(block: nexum::host::types::Block) -> 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)?; - } + strategy::on_block(&WitBindgenHost, &CowClient::new(), block.chain_id, cfg)?; Ok(()) } } - -export!(StopLoss); diff --git a/modules/examples/stop-loss/src/strategy.rs b/modules/examples/stop-loss/src/strategy.rs index bcec815b..7891f40d 100644 --- a/modules/examples/stop-loss/src/strategy.rs +++ b/modules/examples/stop-loss/src/strategy.rs @@ -1,20 +1,19 @@ //! 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`. +//! a CoW order intent through the typed venue client, dedups via +//! local-store. Every interaction with the world flows through the +//! `nexum_sdk::host` trait seams and the videre [`VenueTransport`] +//! under the typed [`CowClient`], so tests drive it against +//! `nexum_sdk_test::MockHost` and a scripted transport. use alloy_primitives::I256; +use cow_venue::{BuyToken, CowClient, CowIntent, CowIntentBody, OrderBody, SellToken, intent_id}; 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, -}; +use nexum_sdk::host::{ChainHost, Fault, LocalStoreHost, LoggingHost}; +use nexum_sdk::keeper::RetryAction; +use nexum_sdk::prelude::{Address, U256, hex}; +use videre_sdk::keeper::retry_action; +use videre_sdk::{ClientError, SubmitOutcome, VenueTransport, rt}; /// Resolved configuration parsed from `module.toml::[config]`. #[derive(Clone, Debug)] @@ -23,7 +22,8 @@ pub struct Settings { 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). + /// Order owner (= the `setPreSignature` caller and buy-token + /// receiver). pub owner: Address, /// Sell side of the order. pub sell_token: Address, @@ -40,10 +40,19 @@ pub struct Settings { /// 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> { +/// (oracle RPC error, decode failure, venue refusal). 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, + venue: &CowClient, + chain_id: u64, + settings: &Settings, +) -> Result<(), Fault> +where + H: ChainHost + LoggingHost + LocalStoreHost, + T: VenueTransport, +{ 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 @@ -58,126 +67,98 @@ pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Res 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, + // Derive the venue-and-body intent-id up-front so the dedup guard + // runs before any network work. + let intent = build_intent(settings); + let id = match intent_id(&intent) { + Ok(id) => id, Err(e) => { - tracing::warn!(error = %e, "stop-loss skipped (build)"); + tracing::error!(error = %e, "intent body encode failed"); return Ok(()); } }; - let uid_hex = format!("{uid}"); - let dedup_key = format!("submitted:{uid_hex}"); + let dedup_key = format!("submitted:{id}"); if host.get(&dedup_key)?.is_some() { - tracing::info!(uid = %uid_hex, "stop-loss already submitted, idle"); + tracing::info!(intent = %id, "stop-loss already submitted, idle"); return Ok(()); } - let dropped_key = format!("dropped:{uid_hex}"); + let dropped_key = format!("dropped:{id}"); if host.get(&dropped_key)?.is_some() { - tracing::info!(uid = %uid_hex, "stop-loss previously dropped, idle"); + tracing::info!(intent = %id, "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(()); - } + let Some(outcome) = rt::complete(venue.submit(&intent)) else { + // Guest transports never suspend; retry on the next block. + tracing::error!("stop-loss submit future suspended; retrying next block"); + 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"")?; + match outcome { + Ok(SubmitOutcome::Accepted(receipt)) => { + host.set(&dedup_key, b"")?; tracing::warn!( price = %price, trigger = %settings.trigger_price_scaled, - uid = %server_uid, + receipt = %hex::encode_prefixed(&receipt), "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(SubmitOutcome::RequiresSigning(_)) => { + // The orderbook holds the order as signature-pending; the + // owner activates it with the on-chain `setPreSignature` + // call made ahead of the trigger. Journalled so the next + // block idles instead of re-posting. + host.set(&dedup_key, b"")?; + tracing::warn!( + price = %price, + trigger = %settings.trigger_price_scaled, + "stop-loss TRIGGERED (pre-sign pending on-chain activation)", + ); } + Err(ClientError::Body(e)) => { + tracing::error!(error = %e, "intent body encode failed"); + } + Err(ClientError::Venue(fault)) => match retry_action(&fault) { + RetryAction::TryNextBlock | RetryAction::Backoff { .. } => { + tracing::warn!(error = %fault, "stop-loss retry on next block"); + } + RetryAction::Drop => { + host.set(&dropped_key, b"")?; + tracing::warn!(intent = %id, error = %fault, "stop-loss dropped"); + } + // `RetryAction` is `#[non_exhaustive]`; treat unknown + // future variants like `TryNextBlock` rather than + // silently dropping the order on an SDK bump. + _ => { + tracing::warn!( + error = %fault, + "stop-loss unknown retry-action - retry on next block", + ); + } + }, + // `ClientError` is non-exhaustive; retry on the next block. + Err(e) => tracing::error!(error = %e, "stop-loss submit failed"), } 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, +/// Assemble the order intent from settings: an unsigned order the cow +/// adapter posts pre-sign. The owner receives the buy token and the +/// app-data hash pins the canonical empty document. +fn build_intent(settings: &Settings) -> CowIntentBody { + let order = OrderBody::sell( + SellToken(settings.sell_token.into_array()), + settings.sell_amount.to_be_bytes(), + ) + .for_at_least( + BuyToken(settings.buy_token.into_array()), + settings.buy_amount.to_be_bytes(), ) - .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)) + .valid_to(settings.valid_to) + .receiver(settings.owner.into_array()) + .app_data(cowprotocol::EMPTY_APP_DATA_HASH.0) + .build(); + CowIntentBody::V1(CowIntent::Order(order)) } /// Parse `module.toml::[config]` into a typed [`Settings`]. @@ -252,19 +233,78 @@ fn config_err(e: ConfigError) -> Fault { #[cfg(test)] mod tests { - use super::*; + use std::cell::RefCell; + use std::collections::VecDeque; + 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; + use nexum_sdk::host::ChainError; + use nexum_sdk_test::{MockHost, capture_tracing}; + use videre_sdk::client::sealed::SealedTransport; + use videre_sdk::{IntentStatus, Quotation, UnsignedTx, VenueFault, VenueId}; + + use super::*; const SEPOLIA: u64 = 11_155_111; + /// Scripted venue transport: one submit outcome per queued entry, + /// every submit recorded. + #[derive(Default)] + struct MockVenue { + outcomes: RefCell>>, + submits: RefCell)>>, + } + + impl MockVenue { + fn enqueue_submit(&self, outcome: Result) { + self.outcomes.borrow_mut().push_back(outcome); + } + + 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) + } + fn settings_below(trigger_scaled: i128) -> Settings { Settings { oracle_address: "0x694AA1769357215DE4FAC081bf1f309aDC325306" @@ -306,12 +346,11 @@ mod tests { host.chain.respond_to("eth_call", ¶ms, response); } - fn programmed_uid(settings: &Settings) -> String { - let (_creation, uid) = build_creation(SEPOLIA, settings).unwrap(); - format!("{uid}") + fn programmed_id(settings: &Settings) -> String { + intent_id(&build_intent(settings)).unwrap() } - /// Regression test pinning the OrderUid produced by the + /// Regression test pinning the orderbook UID derived from 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- @@ -339,8 +378,17 @@ mod tests { buy_amount: U256::from(20_000_000_000_000_000_000_u128), valid_to: u32::MAX, }; + let CowIntentBody::V1(CowIntent::Order(body)) = build_intent(&settings) else { + panic!("stop-loss emits an unsigned order intent"); + }; + let order = cow_venue::assembly::body_to_order_data(&body); + let uid = cow_venue::assembly::order_uid( + cowprotocol::Chain::try_from(SEPOLIA).unwrap(), + &order, + settings.owner, + ); assert_eq!( - programmed_uid(&settings), + format!("{uid}"), "0xc2b9cb4ea1ee5a86d8049ac09d8f494bf04cca0a68407285f31e2e6379800be87bf140727d27ea64b607e042f1225680b40eca6affffffff", ); } @@ -348,6 +396,7 @@ mod tests { #[test] fn idle_when_price_above_trigger() { let host = MockHost::new(); + let venue = MockVenue::default(); let s = settings_below(/*trigger*/ 250_000_000_000); program_oracle( &host, @@ -355,9 +404,9 @@ mod tests { Ok(oracle_response_json(300_000_000_000)), ); - on_block(&host, SEPOLIA, &s).unwrap(); + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(venue.submit_count(), 0); assert_eq!(host.store.len(), 0); assert_eq!( host.chain.call_count(), @@ -369,27 +418,28 @@ mod tests { #[test] fn triggers_and_submits_once_then_dedups() { let host = MockHost::new(); + let venue = MockVenue::default(); 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())); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(vec![0xAA; 56]))); // First block: submits. - on_block(&host, SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + assert_eq!(venue.submit_count(), 1); + let id = programmed_id(&s); assert!( host.store .snapshot() - .contains_key(&format!("submitted:{uid}")) + .contains_key(&format!("submitted:{id}")) ); // 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); + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + assert_eq!(venue.submit_count(), 1); assert_eq!( host.chain.call_count(), 2, @@ -397,48 +447,73 @@ mod tests { ); } + /// The adapter posts the unsigned order pre-sign and asks for the + /// on-chain activation: the intent is journalled so the next block + /// idles instead of re-posting. #[test] - fn permanent_submit_error_marks_dropped() { + fn requires_signing_outcome_records_the_marker_and_idles() { let host = MockHost::new(); + let venue = MockVenue::default(); let s = settings_below(250_000_000_000); program_oracle( &host, s.oracle_address, Ok(oracle_response_json(200_000_000_000)), ); + venue.enqueue_submit(Ok(SubmitOutcome::RequiresSigning(UnsignedTx { + chain: SEPOLIA, + to: vec![0x11; 20], + value: Vec::new(), + data: vec![0x22], + }))); - // 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); + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + + let id = programmed_id(&s); assert!( host.store .snapshot() - .contains_key(&format!("dropped:{uid}")) + .contains_key(&format!("submitted:{id}")) ); + + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + assert_eq!(venue.submit_count(), 1); + } + + #[test] + fn permanent_submit_error_marks_dropped() { + let host = MockHost::new(); + let venue = MockVenue::default(); + let s = settings_below(250_000_000_000); + program_oracle( + &host, + s.oracle_address, + Ok(oracle_response_json(200_000_000_000)), + ); + + // A structured permanent refusal - `Denied` classifies as + // `Drop` in the videre retry table. + venue.enqueue_submit(Err(VenueFault::Denied("InvalidSignature: bad sig".into()))); + + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + let id = programmed_id(&s); + assert!(host.store.snapshot().contains_key(&format!("dropped:{id}"))); assert!( !host .store .snapshot() - .contains_key(&format!("submitted:{uid}")) + .contains_key(&format!("submitted:{id}")) ); // Second block: dropped marker idles the loop. - on_block(&host, SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); // no resubmit + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + assert_eq!(venue.submit_count(), 1); // no resubmit } #[test] fn transient_submit_error_leaves_state_unchanged() { let host = MockHost::new(); + let venue = MockVenue::default(); let s = settings_below(250_000_000_000); program_oracle( &host, @@ -446,26 +521,21 @@ mod tests { 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, - }))); + venue.enqueue_submit(Err(VenueFault::Unavailable("orderbook http 502".into()))); - let (result, logs) = capture_tracing(|| on_block(&host, SEPOLIA, &s)); + let (result, logs) = capture_tracing(|| on_block(&host, &client(&venue), 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"); + assert_eq!(venue.submit_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 venue = MockVenue::default(); let s = settings_below(250_000_000_000); program_oracle( &host, @@ -473,9 +543,9 @@ mod tests { Err(ChainError::Fault(Fault::Timeout)), ); - on_block(&host, SEPOLIA, &s).unwrap(); + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(venue.submit_count(), 0); assert_eq!(host.store.len(), 0); assert!(host.logging.contains("oracle eth_call failed")); } diff --git a/modules/fixtures/clock-reader/src/lib.rs b/modules/fixtures/clock-reader/src/lib.rs index d2d7a54b..2f0cea8b 100644 --- a/modules/fixtures/clock-reader/src/lib.rs +++ b/modules/fixtures/clock-reader/src/lib.rs @@ -16,8 +16,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/src/lib.rs b/modules/fixtures/flaky-bomb/src/lib.rs index 63157a8b..d1b9598a 100644 --- a/modules/fixtures/flaky-bomb/src/lib.rs +++ b/modules/fixtures/flaky-bomb/src/lib.rs @@ -21,8 +21,11 @@ #![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; diff --git a/modules/fixtures/flaky-venue/Cargo.toml b/modules/fixtures/flaky-venue/Cargo.toml new file mode 100644 index 00000000..4987897e --- /dev/null +++ b/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/modules/fixtures/flaky-venue/module.toml b/modules/fixtures/flaky-venue/module.toml new file mode 100644 index 00000000..c7667b9d --- /dev/null +++ b/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/modules/fixtures/flaky-venue/src/lib.rs b/modules/fixtures/flaky-venue/src/lib.rs new file mode 100644 index 00000000..10716274 --- /dev/null +++ b/modules/fixtures/flaky-venue/src/lib.rs @@ -0,0 +1,79 @@ +//! # flaky-venue (test fixture) +//! +//! A venue adapter whose `submit` panics (traps the store) while the chain +//! head reads as the poison sentinel `0xdead`, and accepts once the head +//! moves on. The controlling test programs the mock chain backend, so the +//! trap is deterministic and the recovery is host-driven: the supervisor's +//! sweep must reinstantiate the adapter before a submit succeeds again. +//! +//! Not a production adapter. Lives under `modules/fixtures/` so it is +//! obviously 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/modules/fixtures/fuel-bomb/src/lib.rs b/modules/fixtures/fuel-bomb/src/lib.rs index eb158a26..3e6b6b3b 100644 --- a/modules/fixtures/fuel-bomb/src/lib.rs +++ b/modules/fixtures/fuel-bomb/src/lib.rs @@ -13,8 +13,11 @@ #![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/src/lib.rs b/modules/fixtures/memory-bomb/src/lib.rs index 5feeadc3..948b65e1 100644 --- a/modules/fixtures/memory-bomb/src/lib.rs +++ b/modules/fixtures/memory-bomb/src/lib.rs @@ -12,8 +12,11 @@ #![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/src/lib.rs b/modules/fixtures/panic-bomb/src/lib.rs index 086738f3..56eaa348 100644 --- a/modules/fixtures/panic-bomb/src/lib.rs +++ b/modules/fixtures/panic-bomb/src/lib.rs @@ -13,8 +13,11 @@ #![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/slow-host/src/lib.rs b/modules/fixtures/slow-host/src/lib.rs index 5ed99ae1..97b420be 100644 --- a/modules/fixtures/slow-host/src/lib.rs +++ b/modules/fixtures/slow-host/src/lib.rs @@ -26,8 +26,11 @@ #![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::{chain, logging, types}; diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index 1910ae93..53e29409 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -9,17 +9,20 @@ repository.workspace = true crate-type = ["cdylib"] [dependencies] +composable-cow = { path = "../../crates/composable-cow", features = ["sweep"] } +cow-venue = { path = "../../crates/cow-venue", features = ["client"] } nexum-sdk = { path = "../../crates/nexum-sdk" } -shepherd-sdk = { path = "../../crates/shepherd-sdk" } +videre-sdk = { path = "../../crates/videre-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"] } -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" } +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 = "../../crates/nexum-sdk-test" } diff --git a/modules/twap-monitor/module.toml b/modules/twap-monitor/module.toml index e49b8dec..575806f6 100644 --- a/modules/twap-monitor/module.toml +++ b/modules/twap-monitor/module.toml @@ -1,5 +1,5 @@ # twap-monitor: poll registered ComposableCoW conditional orders and -# submit ready ones via the CoW Protocol orderbook. +# submit ready ones to the CoW venue through the venue registry. [module] name = "twap-monitor" @@ -14,28 +14,51 @@ component = "sha256:000000000000000000000000000000000000000000000000000000000000 # - 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"] +# - client -> videre:venue/client submit path to the cow adapter +required = ["logging", "local-store", "chain", "client"] optional = [] [capabilities.http] -# All outbound HTTP goes through `cow-api` (which routes through the -# host's pinned orderbook URL); no direct `http` calls. +# 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))"). -# Both `address` and `event_signature` are pinned so the supervisor -# does not deliver unrelated logs to the module. +# keccak256("ConditionalOrderCreated(address,(address,bytes32,bytes))"), +# parity-tested against the sol! decoder in strategy.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 strategy.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/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index e6f4fa62..71bf20a5 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -1,21 +1,20 @@ -//! # twap-monitor (Shepherd module) +//! # twap-monitor (Shepherd keeper module) //! -//! Indexes `ComposableCoW.ConditionalOrderCreated` logs and polls each -//! watched conditional order on every block, submitting tranches to -//! the CoW orderbook as they go live. +//! Indexes `ComposableCoW.ConditionalOrderCreated` and v2 +//! `ConditionalOrderRemoved` logs and polls each watched conditional +//! order on every block, submitting tranches to the CoW venue through +//! the venue registry 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`. +//! - `strategy.rs` holds the pure logic and unit tests against the +//! `nexum_sdk::host` trait seams and the videre `VenueTransport` +//! seam. It does not know `wit-bindgen` exists. +//! - `lib.rs` (this file) is the `#[videre_sdk::keeper]` glue: the +//! macro derives the component world from `module.toml`, emits the +//! `WitBindgenHost` adapter, and dispatches each event variant to +//! `strategy` with the typed [`CowClient`] over the module's own +//! `videre:venue/client` import. // wit_bindgen::generate! expands to host-import shims whose arity // matches the WIT signatures, which can exceed clippy's @@ -23,49 +22,45 @@ #![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 cow_venue::CowClient; 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 { +#[videre_sdk::keeper] +impl 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. - _ => {} - } + fn on_chain_logs(batch: types::ChainLogs) -> Result<(), Fault> { + let logs: Vec = batch.logs.into_iter().map(Into::into).collect(); + strategy::on_chain_logs(&WitBindgenHost, &logs)?; + Ok(()) + } + + fn on_block(block: types::Block) -> Result<(), Fault> { + let info = strategy::BlockInfo { + chain_id: block.chain_id, + number: block.number, + timestamp: block.timestamp, + }; + strategy::on_block(&WitBindgenHost, &CowClient::new(), info)?; Ok(()) } -} -export!(TwapMonitor); + 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/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index 44d789d2..92c5970c 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -1,26 +1,35 @@ //! 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`. +//! Every interaction with the world flows through a trait seam: the +//! `nexum_sdk::host` traits for chain and store access and the videre +//! [`VenueTransport`] under the typed [`CowClient`] for submission - +//! no direct calls to wit-bindgen-generated free functions live here. +//! The `lib.rs` glue hands [`on_chain_logs`] / [`on_block`] the +//! `WitBindgenHost` adapter and the client over the module's own +//! `videre:venue/client` import; tests under `#[cfg(test)]` hand the +//! same functions a `nexum_sdk_test::MockHost` and a scripted +//! transport. +//! +//! The module owns decode and evaluate only: log decoding into the +//! keeper watch set, and the `getTradeableOrderWithSignature` poll +//! behind [`ConditionalSource`]. Gate discipline, the `submitted:` +//! journal, submission through the venue registry, and retry dispatch live in +//! the shared composition (`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, Chain, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, - GPv2OrderData, OrderCreation, Signature, + 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, Fault}; -use shepherd_sdk::cow::{ - CowApiError, CowHost, PollOutcome, RetryAction, classify_api_error, decode_revert, - gpv2_to_order_data, -}; +use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost}; +use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet, watch_key}; +use videre_sdk::VenueTransport; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -55,95 +64,217 @@ mod abi { } } -/// 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> { +/// Indexer entry: decode every ComposableCoW registration and removal +/// chain-log in a dispatch batch. A `ConditionalOrderCreated` persists +/// its watch stamped with the log's chain position; a v2 +/// `ConditionalOrderRemoved` drops the watch (with its gates) only +/// when it postdates that stamp. The two subscription streams merge in +/// arrival order, not chain order, so the stamp is what 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)?; + 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(()) } -/// 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) +/// Poll entry: run the keeper over every gate-ready watch through the +/// shared composition, submitting through the typed client onto the +/// venue seam. The block timestamp arrives in 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) } // ---- indexing path ---- +/// 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`. The header pins where the indexed +/// `ConditionalOrderCreated` 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; the pin is parity-tested +/// against the `shepherd:cow/cow-events` package of record. 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)) } -/// `set` overwrites in place, so re-indexing the same log (re-org -/// replay, overlapping subscription windows) produces no observable -/// side effect. -fn persist_watch( +/// The watch set overwrites in place and the stamp keeps the latest +/// indexed position, so re-indexing (re-org replay, overlapping +/// subscription windows, 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 params_hash = keccak256(&encoded); - let key = watch_key(&owner, ¶ms_hash); - host.set(&key, &encoded)?; + 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(()) } -// ---- poll path ---- +/// Topic-0 gates before the ABI decode; the pin is parity-tested +/// against the `shepherd:cow/cow-events` package of record. +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)) +} -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; +/// `singleOrderHash` is `keccak256(abi.encode(params))`, exactly the +/// hash the watch key carries. The removal lands only when it provably +/// postdates the watch's stamp: `remove(hash)` + `create(same params)` +/// in one call re-registers the same hash, and the earlier remove can +/// arrive after the later create, so an unprovable ordering keeps the +/// watch (a wrongly-kept watch self-heals through the poll's drop +/// path; a wrongly-dropped one is never polled again). A removal for +/// an order this keeper never indexed (or already dropped) replays +/// cleanly as 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}"); } - let Some(value) = host.get(&key)? else { - continue; + _ => tracing::info!("kept {key}: removal does not postdate its create"), + } + Ok(()) +} + +// ---- poll path ---- + +/// TWAP conditional source: decode the stored row's +/// `ConditionalOrderParams` and evaluate +/// `getTradeableOrderWithSignature` on chain. A row this source cannot +/// decode polls again next block rather than tearing down the sweep. +struct TwapSource; + +impl ConditionalSource 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(&value) else { - tracing::warn!("watch {key} carried unparseable params; skipping"); - continue; + 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) = owner_hex.parse::
() else { - continue; + 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, 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)?; - } - } + 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" } - Ok(()) } -fn poll_one( +fn poll_one( host: &H, chain_id: u64, owner: &Address, params: &ConditionalOrderParams, -) -> PollOutcome { +) -> Verdict { let call = abi::getTradeableOrderWithSignatureCall { owner: *owner, params: abi::Params { @@ -158,364 +289,179 @@ fn poll_one( 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 + .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 -/// `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 { +/// `Post { order, signature, .. }`. The wire format is the canonical +/// Solidity return tuple `abi.encode(order, signature)`, so the +/// two-tuple parameter decode lines up. The deployed 1.x contract +/// carries no next-poll hint, so `next_poll_timestamp` is synthetic +/// (`0`). +fn decode_return(data: &[u8]) -> Option { let (order, signature) = <(GPv2OrderData, Bytes)>::abi_decode_params(data).ok()?; - Some(PollOutcome::Ready { + Some(Verdict::Post { order: Box::new(order), signature, + next_poll_timestamp: 0, }) } -fn outcome_label(o: &PollOutcome) -> &'static str { +fn outcome_label(o: &Verdict) -> &'static str { match o { - PollOutcome::Ready { .. } => "Ready", - PollOutcome::TryAtEpoch(_) => "TryAtEpoch", - PollOutcome::TryOnBlock(_) => "TryOnBlock", - PollOutcome::TryNextBlock => "TryNextBlock", - PollOutcome::DontTryAgain => "DontTryAgain", + Verdict::Post { .. } => "Post", + Verdict::WaitTimestamp { .. } => "WaitTimestamp", + Verdict::WaitBlock { .. } => "WaitBlock", + Verdict::TryNextBlock { .. } => "TryNextBlock", + Verdict::Invalid { .. } => "Invalid", + Verdict::NeedsInput { .. } => "NeedsInput", } } -// ---- key conventions ---- - -fn watch_key(owner: &Address, params_hash: &B256) -> String { - format!("watch:{owner:#x}:{params_hash:#x}") -} +// ---- test-only seam mirrors ---- +// +// Thin views over the keeper / venue canon so the dispatch tests can +// seed and inspect the store in the exact shapes production writes. +#[cfg(test)] fn parse_watch_key(key: &str) -> Option<(&str, &str)> { - let rest = key.strip_prefix("watch:")?; - let (owner, hash) = rest.split_once(':')?; - Some((owner, hash)) + let watch = WatchRef::parse(key)?; + Some((watch.owner_hex(), watch.hash_hex())) } -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), +#[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: owner.into_array(), + signature: signature.to_vec(), + }))) } -/// 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) +#[cfg(test)] +fn compute_intent_id(order: &GPv2OrderData, signature: &Bytes, owner: Address) -> Option { + cow_venue::intent_id(&signed_intent_body(order, signature, owner)?).ok() } -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(()); - } +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::VecDeque; - // 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(()); - } + 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, }; - 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))) -} + use super::*; -// ---- OrderPostError -> retry action ---- + const SEPOLIA: u64 = 11_155_111; -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}"); + /// Scripted [`VenueTransport`]: one submit outcome per queued entry, + /// every submit recorded. Quote, status, and cancel are off the + /// module's 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); } - 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}"); + + fn submits(&self) -> Vec<(String, Vec)> { + self.submits.borrow().clone() } - // `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"); + + fn submit_count(&self) -> usize { + self.submits.borrow().len() } } - 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, -} + impl SealedTransport for &MockVenue {} -/// 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, - } -} + impl VenueTransport for &MockVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } -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(()) + 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(), + )) + }) } - 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(()) + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") } - 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(()) + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") } } -} - -#[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; + /// Dispatch one block through `on_block` with the typed client over + /// the scripted transport. + fn dispatch(host: &MockHost, venue: &MockVenue, block: BlockInfo) -> Result<(), Fault> { + on_block(host, &CowClient::with_transport(venue), block) + } /// `validTo` a given number of seconds from now. The constructor's /// client-side max-horizon policy reads the wall clock (not the @@ -576,7 +522,7 @@ mod tests { fn decodes_well_formed_log() { let owner = address!("00112233445566778899aabbccddeeff00112233"); let params = sample_params(); - let log = make_log(owner, ¶ms); + let log = make_log(owner, ¶ms, at(1, 0)); let (decoded_owner, decoded_params) = decode_conditional_order_created(&log).expect("decode succeeds"); @@ -615,45 +561,20 @@ mod tests { let wire = (order.clone(), sig.clone()).abi_encode_params(); match decode_return(&wire).expect("decode succeeds") { - PollOutcome::Ready { + 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, 0, "legacy path carries no hint"); } - other => panic!("expected Ready, got {other:?}"), + other => panic!("expected Post, 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"); @@ -664,70 +585,50 @@ mod tests { 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), - ); - } + // ---- MockHost + MockVenue dispatch tests ---- - #[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), - ); + /// Chain position shorthand for the log builders. + fn at(block: u64, index: u64) -> LogPosition { + LogPosition { block, index } } - #[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 { + /// Assemble a mined ComposableCoW log at `position` through the + /// same WIT-edge path the bind macro uses at runtime. + 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![ - ConditionalOrderCreated::SIGNATURE_HASH.to_vec(), - owner_topic, - ]; - let data = params.abi_encode(); + let topics = vec![topic0.to_vec(), owner_topic]; nexum_sdk::events::ChainLogParts { address: COMPOSABLE_COW.as_slice(), topics: &topics, - data: &data, + 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 { @@ -751,11 +652,13 @@ mod tests { } /// Pre-seed a `watch:` row identical to what the indexer would - /// write. + /// write for a create mined 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, &encoded).unwrap(); + host.store + .set(&key, &encode_row(Some(at(1, 0)), &encoded)) + .unwrap(); key } @@ -772,13 +675,17 @@ mod tests { let host = MockHost::new(); let owner = address!("00112233445566778899aabbccddeeff00112233"); let params = sample_params(); - let log = make_log(owner, ¶ms); + 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); - assert!(host.store.snapshot().contains_key(&expected_key)); + 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] @@ -790,16 +697,192 @@ mod tests { let owner = address!("00112233445566778899aabbccddeeff00112233"); let params = sample_params(); - on_chain_logs(&host, &[make_log(owner, ¶ms)]).unwrap(); + 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)]).unwrap(); + 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); @@ -813,19 +896,20 @@ mod tests { ) .unwrap(); - on_block(&host, sample_block(100)).unwrap(); + dispatch(&host, &venue, 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); + assert_eq!(venue.submit_count(), 0); } #[test] - fn poll_ready_submits_order_and_persists_submitted_uid() { + 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); @@ -838,45 +922,52 @@ mod tests { programmed_eth_call_params(owner, ¶ms), Ok(quoted_hex(&wire)), ); - host.cow_api.respond(Ok("0xfeedface".to_string())); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(hex!("feedface").to_vec()))); - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); - result.unwrap(); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); - let expected_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); + 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); - assert_eq!(host.cow_api.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_uid}")), - "expected submitted:{{client_uid}} marker" + .contains_key(&format!("submitted:{expected_id}")), + "expected submitted:{{intent_id}} marker" ); assert!( !host.store.snapshot().contains_key("submitted:0xfeedface"), - "marker must key on the client UID, not the divergent server UID" + "marker must key on the pre-submit intent-id, not the venue receipt" ); - // 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`. + /// posted it), the second tick must NOT submit again. Without the + /// guard the venue refuses the duplicate and a Warn fires for what + /// is in fact correct, finished work. The guard is the + /// `submitted:{intent_id}` short-circuit at the top of + /// `submit_ready`. #[test] - fn poll_ready_skips_submit_when_submitted_uid_already_in_store() { + 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); @@ -892,14 +983,14 @@ mod tests { // 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"); + // 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_uid}"), b"") + .set(&format!("submitted:{already_submitted}"), b"") .expect("seed submitted marker"); - on_block(&host, sample_block(1_000)).unwrap(); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); assert_eq!( host.chain.call_count(), @@ -907,28 +998,20 @@ mod tests { "poll still consults the chain to see Ready", ); assert_eq!( - host.cow_api.call_count(), + venue.submit_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", + "the venue must NOT be touched when submitted:{{intent_id}} already exists", ); } - /// 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. + /// A Ready order with a non-empty `appData` digest rides the intent + /// body verbatim: assembly into the orderbook wire shape is the + /// adapter's, so the keeper ships exactly the digest the chain + /// returned - watch-tower parity. #[test] - fn poll_ready_submits_non_empty_app_data_hash_only() { - use alloy_primitives::keccak256; + 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); @@ -944,42 +1027,40 @@ mod tests { programmed_eth_call_params(owner, ¶ms), Ok(quoted_hex(&wire)), ); - host.cow_api.respond(Ok("0xfeedface".to_string())); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(hex!("feedface").to_vec()))); - on_block(&host, sample_block(1_000)).unwrap(); + dispatch(&host, &venue, 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"); + 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!( - 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}" + signed.order.app_data, app_data_hash.0, + "the digest goes out verbatim in the body", ); - let expected_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); + let expected_id = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); assert!( host.store .snapshot() - .contains_key(&format!("submitted:{expected_uid}")), - "submitted:{{client_uid}} marker must be written after a successful submit" + .contains_key(&format!("submitted:{expected_id}")), + "submitted:{{intent_id}} marker must be written after a successful submit" ); } #[test] - fn submit_transient_error_leaves_state_unchanged_for_next_block() { + 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); @@ -993,17 +1074,13 @@ mod tests { 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))); + // 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. @@ -1027,9 +1104,51 @@ mod tests { }); } + /// The venue's throttle hint survives the venue seam: a rate-limited + /// refusal backs the watch off on the epoch clock instead of + /// hot-looping the submit every block. + #[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_permanent_error_drops_watch() { + 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); @@ -1043,21 +1162,16 @@ mod tests { 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, - }))); + // 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()))); - on_block(&host, sample_block(1_000)).unwrap(); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); let store = host.store.snapshot(); assert!( !store.contains_key(&watch_key_str), - "permanent error must drop the watch" + "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}"))); @@ -1066,16 +1180,17 @@ mod tests { } #[test] - fn poll_dont_try_again_drops_watch_and_gates() { - // When `decode_revert` produces `DontTryAgain`, the lifecycle + 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; - use shepherd_sdk::cow::IConditionalOrder; 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); @@ -1101,7 +1216,8 @@ mod tests { })), ); - on_block(&host, sample_block(1_000)).unwrap(); + let (result, logs) = capture_tracing(|| dispatch(&host, &venue, sample_block(1_000))); + result.unwrap(); assert!(!host.store.snapshot().contains_key(&watch_key_str)); assert!( @@ -1110,35 +1226,58 @@ mod tests { .snapshot() .contains_key(&format!("next_block:{owner_hex}:{hash_hex}")), ); - assert_eq!( - host.cow_api.call_count(), - 0, - "revert-to-drop path never submits" + 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}")) + }); } - /// 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. + /// The supervisor builds its log filters from this manifest's + /// chain-log `event_signature` pins + /// (`nexum-runtime::supervisor` -> `build_alloy_filter`), so a + /// drift from a decoder topic-0 subscribes to one topic and decodes + /// another, and the module silently sees nothing. Compares the two + /// sets rather than testing membership, so a missing pin and a pin + /// the decoders cannot handle both fail too. #[test] - fn topic0_matches_conditional_order_created_canonical_signature() { - assert_eq!( + 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, - 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})", + ConditionalOrderRemoved::SIGNATURE_HASH, + ]); + assert_eq!( + pinned, decoded, + "module.toml chain-log topics and the sol! decoder topic-0s have diverged", ); } } diff --git a/scripts/check-cow-orderbook-only.sh b/scripts/check-cow-orderbook-only.sh new file mode 100755 index 00000000..52229ed2 --- /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: 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" crates/cow-venue +case $? in + 0) fail "composable symbols leak into crates/cow-venue" ;; + 1) pass "cow-venue symbol scan empty" ;; + *) fail "symbol scan errored (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..fe52c029 --- /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" 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 (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" crates/nexum-runtime/src +case $? in + 0) fail "charter symbols leak into nexum-runtime" ;; + 1) pass "symbol scan empty" ;; + *) fail "symbol scan errored (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' 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 (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..29f156bf 100755 --- a/scripts/e2e-report-gen.sh +++ b/scripts/e2e-report-gen.sh @@ -155,7 +155,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", ] diff --git a/scripts/e2e-run.sh b/scripts/e2e-run.sh index ce24b343..8ec2f4e1 100755 --- a/scripts/e2e-run.sh +++ b/scripts/e2e-run.sh @@ -7,7 +7,7 @@ # 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 +# 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` @@ -52,7 +52,7 @@ log "building 5 modules + engine (this can take a minute on first run)" 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 +63,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/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/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/tools/load-gen/Cargo.toml b/tools/load-gen/Cargo.toml index 9652bdc8..4c62cd8a 100644 --- a/tools/load-gen/Cargo.toml +++ b/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/tools/load-gen/src/main.rs index 1546ac42..15a05760 100644 --- a/tools/load-gen/src/main.rs +++ b/tools/load-gen/src/main.rs @@ -399,7 +399,7 @@ fn encode_twap_create(salt: B256, block_ts: u64) -> Bytes { /// 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). +/// expected Info-level log). fn encode_ethflow_create_order(eoa: Address, sell_amount: u128, quote_id: i64) -> Bytes { let order = EthFlowOrderData { buyToken: COW_TOKEN, diff --git a/tools/orderbook-mock/src/main.rs b/tools/orderbook-mock/src/main.rs index 0ad9d4aa..8e7450d5 100644 --- a/tools/orderbook-mock/src/main.rs +++ b/tools/orderbook-mock/src/main.rs @@ -1,7 +1,7 @@ //! Mock CoW orderbook for shepherd load tests. //! -//! Serves the one endpoint shepherd's `cow-api` host backend hits on -//! every order submission: +//! Serves the one endpoint the cow venue adapter 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 @@ -16,8 +16,8 @@ //! //! 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`. +//! about the orderbook's own behaviour. Real-orderbook fidelity comes +//! from running against the live testnet orderbook. #![cfg_attr(not(test), warn(unused_crate_dependencies))] @@ -147,7 +147,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 { 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..8521b37a 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,15 @@ 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; } 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..e8b69c81 --- /dev/null +++ b/wit/videre-types/types.wit @@ -0,0 +1,83 @@ +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, + } + + 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; +}