diff --git a/Cargo.lock b/Cargo.lock index d63586bc..e25aca46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5225,6 +5225,7 @@ name = "shepherd-sdk" version = "0.1.0" dependencies = [ "alloy-primitives", + "alloy-sol-types", "composable-cow", "cow-venue", "cowprotocol", diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index 5fe969d0..a6cc1049 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -32,6 +32,7 @@ tracing.workspace = true # `capture_tracing` observes the keeper run's diagnostics in the # acceptance tests. nexum-sdk-test = { path = "../nexum-sdk-test" } +alloy-sol-types.workspace = true proptest.workspace = true # Dev-only cycle (this crate <- shepherd-sdk-test): cargo permits it, # and the keeper run acceptance-tests against the composed MockHost diff --git a/crates/shepherd-sdk/src/cow/events.rs b/crates/shepherd-sdk/src/cow/events.rs new file mode 100644 index 00000000..20acdd42 --- /dev/null +++ b/crates/shepherd-sdk/src/cow/events.rs @@ -0,0 +1,111 @@ +//! CoW on-chain event ABIs, mirroring `shepherd:cow/cow-events`. +//! +//! `wit/shepherd-cow/cow-events.wit` is the package of record; the +//! constants here are parity-tested against it, the `cowprotocol` +//! `sol!` types, and each keeper's `module.toml`. + +use alloy_primitives::{B256, b256}; + +/// One on-chain event surface: the canonical Solidity signature and +/// its keccak256 topic-0. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EventAbi { + /// Canonical Solidity event signature. + pub signature: &'static str, + /// keccak256 of [`Self::signature`]: the log's topic-0. + pub topic0: B256, +} + +/// `ComposableCoW.ConditionalOrderCreated`. +pub const CONDITIONAL_ORDER_CREATED: EventAbi = EventAbi { + signature: "ConditionalOrderCreated(address,(address,bytes32,bytes))", + topic0: b256!("2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361"), +}; + +/// `CoWSwapOnchainOrders.OrderPlacement` (EthFlow). +pub const ORDER_PLACEMENT: EventAbi = EventAbi { + signature: "OrderPlacement(address,(address,address,address,uint256,uint256,uint32,bytes32,\ + uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)", + topic0: b256!("cf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9"), +}; + +/// Every event surface the keepers decode. +pub const ALL: &[EventAbi] = &[CONDITIONAL_ORDER_CREATED, ORDER_PLACEMENT]; + +#[cfg(test)] +mod tests { + use alloy_primitives::keccak256; + use alloy_sol_types::SolEvent; + use cowprotocol::{CoWSwapOnchainOrders, ComposableCoW}; + + use super::*; + + #[test] + fn topic0_is_keccak_of_signature() { + for abi in ALL { + assert_eq!(abi.topic0, keccak256(abi.signature), "{}", abi.signature); + } + } + + #[test] + fn matches_the_sol_decoder_types() { + assert_eq!( + ComposableCoW::ConditionalOrderCreated::SIGNATURE, + CONDITIONAL_ORDER_CREATED.signature, + ); + assert_eq!( + ComposableCoW::ConditionalOrderCreated::SIGNATURE_HASH, + CONDITIONAL_ORDER_CREATED.topic0, + ); + assert_eq!( + CoWSwapOnchainOrders::OrderPlacement::SIGNATURE, + ORDER_PLACEMENT.signature, + ); + assert_eq!( + CoWSwapOnchainOrders::OrderPlacement::SIGNATURE_HASH, + ORDER_PLACEMENT.topic0, + ); + } + + #[test] + fn wit_package_of_record_pins_every_surface() { + let wit = include_str!("../../../../wit/shepherd-cow/cow-events.wit"); + let flat: String = wit + .lines() + .map(|l| l.trim().trim_start_matches("/// ")) + .collect(); + for abi in ALL { + assert!( + flat.contains(abi.signature), + "cow-events.wit must pin the signature {}", + abi.signature, + ); + assert!( + flat.contains(&format!("{:#x}", abi.topic0)), + "cow-events.wit must pin the topic-0 {:#x}", + abi.topic0, + ); + } + } + + /// Layering gate: no generic WIT package references `shepherd:cow`. + #[test] + fn generic_wit_packages_never_reference_shepherd_cow() { + let wit_root = std::path::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/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 620e379d..84a97643 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -17,6 +17,7 @@ //! keeper run is generic over the host traits alone. pub mod error; +pub mod events; pub mod order; pub mod run; diff --git a/docs/deployment/multi-chain.md b/docs/deployment/multi-chain.md index ed627e11..5ed09852 100644 --- a/docs/deployment/multi-chain.md +++ b/docs/deployment/multi-chain.md @@ -213,7 +213,8 @@ event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362b ## 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. +chain; only the contract `address` changes for EthFlow. Package of record: +`wit/shepherd-cow/cow-events.wit`. | Event | Topic-0 | |-------|---------| diff --git a/modules/ethflow-watcher/module.toml b/modules/ethflow-watcher/module.toml index 45b6519a..47cc57c0 100644 --- a/modules/ethflow-watcher/module.toml +++ b/modules/ethflow-watcher/module.toml @@ -26,7 +26,9 @@ allow = [] # 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 diff --git a/modules/ethflow-watcher/src/strategy.rs b/modules/ethflow-watcher/src/strategy.rs index 99e25ba1..4d24db00 100644 --- a/modules/ethflow-watcher/src/strategy.rs +++ b/modules/ethflow-watcher/src/strategy.rs @@ -42,7 +42,7 @@ use cowprotocol::{ use nexum_sdk::events::Log; use nexum_sdk::host::Fault; use nexum_sdk::keeper::Journal; -use shepherd_sdk::cow::{CowApiError, CowHost, gpv2_to_order_data}; +use shepherd_sdk::cow::{CowApiError, CowHost, events, gpv2_to_order_data}; /// Decoded payload of a `CoWSwapOnchainOrders.OrderPlacement` log. /// `GPv2OrderData` is ~300 bytes; box it so the struct stays @@ -89,13 +89,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(&events::ORDER_PLACEMENT.topic0) { + return None; + } let decoded = OrderPlacement::decode_log(&log.inner).ok()?; Some(DecodedPlacement { contract, @@ -178,7 +181,7 @@ fn compute_uid(chain_id: u64, placement: &DecodedPlacement) -> Option #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{U256, address, b256, hex}; + use alloy_primitives::{U256, address, hex}; use alloy_sol_types::SolValue; use cowprotocol::{BuyTokenDestination, OnchainSigningScheme, OrderKind, SellTokenSource}; use nexum_sdk::Level; @@ -495,29 +498,29 @@ mod tests { ); } - /// 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. + /// 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_order_placement_canonical_signature() { assert_eq!( OrderPlacement::SIGNATURE_HASH, - b256!("cf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9"), - "module.toml event_signature must equal keccak256 of the canonical ABI signature", + events::ORDER_PLACEMENT.topic0, + "sol! topic-0 must match the shepherd:cow/cow-events pin", ); } /// 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.) + /// equals the package-of-record topic-0 - catches a manifest/code + /// drift the decoder assertion cannot see. #[test] fn manifest_topic0_matches_order_placement_signature_hash() { let manifest = include_str!("../module.toml"); - let expected = format!("0x{:x}", OrderPlacement::SIGNATURE_HASH); + let expected = format!("{:#x}", events::ORDER_PLACEMENT.topic0); assert!( manifest.contains(&expected), - "module.toml event_signature must equal OrderPlacement::SIGNATURE_HASH ({expected})", + "module.toml event_signature must equal the shepherd:cow/cow-events pin ({expected})", ); } diff --git a/modules/twap-monitor/module.toml b/modules/twap-monitor/module.toml index e49b8dec..3ef1883d 100644 --- a/modules/twap-monitor/module.toml +++ b/modules/twap-monitor/module.toml @@ -26,9 +26,10 @@ 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))"), +# pinned in wit/shepherd-cow/cow-events.wit and parity-tested 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 diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index 0223f5b8..a9bcee04 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -24,7 +24,7 @@ use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, Fault}; use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; -use shepherd_sdk::cow::{CowHost, run}; +use shepherd_sdk::cow::{CowHost, events, run}; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -84,7 +84,12 @@ pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { // ---- indexing path ---- +/// Topic-0 resolves from the `shepherd:cow/cow-events` package of +/// record before the ABI decode. fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOrderParams)> { + if log.topics().first() != Some(&events::CONDITIONAL_ORDER_CREATED.topic0) { + return None; + } let decoded = ConditionalOrderCreated::decode_log(&log.inner).ok()?; Some((decoded.data.owner, decoded.data.params)) } @@ -800,28 +805,29 @@ mod tests { }); } - /// 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. + /// 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 registration event. #[test] fn topic0_matches_conditional_order_created_canonical_signature() { assert_eq!( ConditionalOrderCreated::SIGNATURE_HASH, - b256!("2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361"), - "module.toml event_signature must equal keccak256 of the canonical ABI signature", + events::CONDITIONAL_ORDER_CREATED.topic0, + "sol! topic-0 must match the shepherd:cow/cow-events pin", ); } /// 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.) + /// equals the package-of-record topic-0 - catches a manifest/code + /// drift the decoder assertion cannot see. #[test] fn manifest_topic0_matches_conditional_order_created_signature_hash() { let manifest = include_str!("../module.toml"); - let expected = format!("0x{:x}", ConditionalOrderCreated::SIGNATURE_HASH); + let expected = format!("{:#x}", events::CONDITIONAL_ORDER_CREATED.topic0); assert!( manifest.contains(&expected), - "module.toml event_signature must equal ConditionalOrderCreated::SIGNATURE_HASH ({expected})", + "module.toml event_signature must equal the shepherd:cow/cow-events pin ({expected})", ); } } diff --git a/wit/shepherd-cow/cow-api.wit b/wit/shepherd-cow/cow-api.wit index 4d0df3ae..0dbaa940 100644 --- a/wit/shepherd-cow/cow-api.wit +++ b/wit/shepherd-cow/cow-api.wit @@ -1,5 +1,7 @@ package shepherd:cow@0.1.0; +/// Legacy host-extension surface: retiring. Submission moves to the +/// generic videre pool seam; deleted at the fork-gated poll wire-swap. interface cow-api { use nexum:host/types@0.1.0.{chain-id, fault}; diff --git a/wit/shepherd-cow/cow-events.wit b/wit/shepherd-cow/cow-events.wit new file mode 100644 index 00000000..b5f54bc1 --- /dev/null +++ b/wit/shepherd-cow/cow-events.wit @@ -0,0 +1,20 @@ +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, + /// 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 index d78889c8..2a81b5f7 100644 --- a/wit/shepherd-cow/cow-ext.wit +++ b/wit/shepherd-cow/cow-ext.wit @@ -3,6 +3,7 @@ package shepherd:cow@0.1.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. +/// Retiring with `cow-api`. world cow-ext { import cow-api; }