From 7f7dd727142b756b936f0bbb75bf409e483a7be4 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 02:47:32 +0000 Subject: [PATCH 01/19] feat(test-harness): scaffold wavs-test-harness crate (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new workspace member `packages/test-harness` (crate name `wavs-test-harness`) with empty module tree for chain control, fixtures, service lifecycle, lifecycle waiters, and envelope helpers. Subsequent commits fill in each module. Features are additive (`fork`, `inproc` on by default; `subprocess` preview off by default). Consumers should add `wavs-test-harness` as a [dev-dependencies] entry only — this keeps `utils/test-utils` out of production builds. Verified via `cargo tree -e features` that the feature does not propagate to `wavs` or `layer-tests` default builds. Tracks Lay3rLabs/WAVS#1147. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 29 ++++++++++++ Cargo.toml | 2 + packages/test-harness/Cargo.toml | 53 ++++++++++++++++++++++ packages/test-harness/README.md | 49 ++++++++++++++++++++ packages/test-harness/src/chain/mod.rs | 4 ++ packages/test-harness/src/envelope/mod.rs | 3 ++ packages/test-harness/src/fixtures/mod.rs | 3 ++ packages/test-harness/src/lib.rs | 22 +++++++++ packages/test-harness/src/lifecycle/mod.rs | 3 ++ packages/test-harness/src/service/mod.rs | 4 ++ 10 files changed, 172 insertions(+) create mode 100644 packages/test-harness/Cargo.toml create mode 100644 packages/test-harness/README.md create mode 100644 packages/test-harness/src/chain/mod.rs create mode 100644 packages/test-harness/src/envelope/mod.rs create mode 100644 packages/test-harness/src/fixtures/mod.rs create mode 100644 packages/test-harness/src/lib.rs create mode 100644 packages/test-harness/src/lifecycle/mod.rs create mode 100644 packages/test-harness/src/service/mod.rs diff --git a/Cargo.lock b/Cargo.lock index cf6ba7c30..a47f1a263 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14592,6 +14592,35 @@ dependencies = [ "wavs-types", ] +[[package]] +name = "wavs-test-harness" +version = "2.8.0" +dependencies = [ + "alloy-contract", + "alloy-network", + "alloy-node-bindings", + "alloy-primitives", + "alloy-provider", + "alloy-rpc-types-eth", + "alloy-signer", + "alloy-signer-local", + "alloy-sol-types", + "anyhow", + "async-trait", + "futures", + "serde", + "serde_json", + "temp-env", + "tempfile", + "thiserror 2.0.18", + "tokio 1.50.0", + "toml 0.9.12+spec-1.1.0", + "tracing", + "tracing-subscriber", + "utils", + "wavs-types", +] + [[package]] name = "wavs-types" version = "2.8.0" diff --git a/Cargo.toml b/Cargo.toml index 3fa1183c6..2cc1a1374 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "packages/engine", "packages/gui/shared", "packages/layer-tests", + "packages/test-harness", "packages/types", "packages/utils", "packages/version-pins", @@ -282,6 +283,7 @@ awsm_web = {version = "0.45.0", default-features = false, features = ["dom", "fi # local utils = { path = "packages/utils" } wavs = { path = "packages/wavs" } +wavs-test-harness = { path = "packages/test-harness" } wavs-engine = { path = "packages/engine" } wavs-types = { path = "packages/types" } wavs-cli = { path = "packages/cli" } diff --git a/packages/test-harness/Cargo.toml b/packages/test-harness/Cargo.toml new file mode 100644 index 000000000..4d4d9e9d7 --- /dev/null +++ b/packages/test-harness/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "wavs-test-harness" +description = "Reusable integration test harness for WAVS apps — chain control, service lifecycle, and contract assertions" +version.workspace = true +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +repository.workspace = true +license.workspace = true +publish = false + +# Features are additive. `fork` and `inproc` are on by default because the harness +# is only useful when at least one runner tier is enabled. `subprocess` is preview +# and stays off by default to keep the dep graph small. +# +# Consumers should add `wavs-test-harness` as a [dev-dependencies] entry only — +# never as a regular dependency — to keep the `utils/test-utils` feature out of +# their production builds. +[features] +default = ["fork", "inproc"] +fork = [] +inproc = [] +subprocess = [] + +[dependencies] +utils = { workspace = true, features = ["test-utils"] } +wavs-types = { workspace = true, features = ["full"] } + +anyhow = { workspace = true } +async-trait = { workspace = true } +futures = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +alloy-contract = { workspace = true } +alloy-network = { workspace = true } +alloy-node-bindings = { workspace = true } +alloy-primitives = { workspace = true } +alloy-provider = { workspace = true } +alloy-signer = { workspace = true } +alloy-signer-local = { workspace = true } +alloy-sol-types = { workspace = true } +alloy-rpc-types-eth = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +tempfile = { workspace = true } +temp-env = { workspace = true } diff --git a/packages/test-harness/README.md b/packages/test-harness/README.md new file mode 100644 index 000000000..57bce02ea --- /dev/null +++ b/packages/test-harness/README.md @@ -0,0 +1,49 @@ +# wavs-test-harness + +Reusable integration test harness for WAVS apps. + +Tracks issue [Lay3rLabs/WAVS#1147](https://github.com/Lay3rLabs/WAVS/issues/1147). + +## Status + +Scaffold. Only the module layout exists today. See `src/lib.rs` for the planned +surface area. Each step in the plan delivers one module. + +## What this crate is for + +Downstream app repos — for example `wavs-defi`, `wavs-aave-guardian`, +`wavs-prediction-market` — write integration tests that drive the full WAVS path: + +``` +trigger -> operator component -> aggregator (sig + quorum) -> service handler -> app contract +``` + +`wavs-test-harness` provides the chain control, service-lifecycle, and assertion +primitives so each downstream test only has to specify its app-specific +deployment and assertions. + +## Tier matrix (planned) + +| Tier | Stages run | Stages mocked | Use case | +|---|---|---|---| +| **Logic** | trigger + contract calls | compute/aggregate/submit | unit-level vault tests with mock signatures | +| **InProc** (default) | compute (real WASM) + aggregate (in-memory quorum) + submit | dispatcher internals | deterministic PR tier on local Anvil | +| **Subprocess** (preview) | everything in the real `wavs` binary | nothing | nightly fork + pre-release | + +## Tier selection + +Add `wavs-test-harness` as a `[dev-dependencies]` entry only, never as a regular +dependency. This keeps `utils/test-utils` out of production builds. + +```toml +[dev-dependencies] +wavs-test-harness = { git = "https://github.com/Lay3rLabs/WAVS", rev = "" } +``` + +Branch references are not supported in CI — pin to a commit SHA. + +## Layer-tests relationship + +`packages/layer-tests` continues to run as-is. Once this harness stabilizes, +`layer-tests` is expected to migrate onto it. Treat this crate as canonical for +new integration tests. diff --git a/packages/test-harness/src/chain/mod.rs b/packages/test-harness/src/chain/mod.rs new file mode 100644 index 000000000..5e84d9997 --- /dev/null +++ b/packages/test-harness/src/chain/mod.rs @@ -0,0 +1,4 @@ +//! Chain control primitives: local Anvil, pinned forks, snapshots, impersonation, +//! time control, and sanitized logging. +//! +//! Submodules land in Step 2. diff --git a/packages/test-harness/src/envelope/mod.rs b/packages/test-harness/src/envelope/mod.rs new file mode 100644 index 000000000..3dbb8318b --- /dev/null +++ b/packages/test-harness/src/envelope/mod.rs @@ -0,0 +1,3 @@ +//! Signed-envelope helpers verified against downstream service handlers. +//! +//! Submodules land in Step 6. diff --git a/packages/test-harness/src/fixtures/mod.rs b/packages/test-harness/src/fixtures/mod.rs new file mode 100644 index 000000000..584a310ac --- /dev/null +++ b/packages/test-harness/src/fixtures/mod.rs @@ -0,0 +1,3 @@ +//! TOML-backed chain profiles and typed address lookups. +//! +//! Submodules land in Step 3. diff --git a/packages/test-harness/src/lib.rs b/packages/test-harness/src/lib.rs new file mode 100644 index 000000000..e5d3203a0 --- /dev/null +++ b/packages/test-harness/src/lib.rs @@ -0,0 +1,22 @@ +//! # wavs-test-harness +//! +//! Reusable integration test harness for WAVS apps. +//! +//! Today this crate is a scaffold — only the public module layout exists. Subsequent +//! commits will fill in: +//! +//! - [`chain`]: local Anvil + pinned-fork support, snapshot/revert, impersonation, time control. +//! - [`fixtures`]: TOML chain profiles and typed address lookup. +//! - [`service`]: WAVS service lifecycle runner (in-process and subprocess tiers). +//! - [`lifecycle`]: trigger emission, quorum/submission waiters, contract assertions. +//! - [`envelope`]: signed-envelope helpers verified against downstream handlers. +//! +//! See `README.md` for the tier matrix, fixture file format, and downstream-consumer example. +//! +//! Tracks issue [Lay3rLabs/WAVS#1147](https://github.com/Lay3rLabs/WAVS/issues/1147). + +pub mod chain; +pub mod envelope; +pub mod fixtures; +pub mod lifecycle; +pub mod service; diff --git a/packages/test-harness/src/lifecycle/mod.rs b/packages/test-harness/src/lifecycle/mod.rs new file mode 100644 index 000000000..c4f1303aa --- /dev/null +++ b/packages/test-harness/src/lifecycle/mod.rs @@ -0,0 +1,3 @@ +//! Trigger emission, quorum/submission waiters, and contract assertions. +//! +//! Submodules land in Step 5. diff --git a/packages/test-harness/src/service/mod.rs b/packages/test-harness/src/service/mod.rs new file mode 100644 index 000000000..e485daa6c --- /dev/null +++ b/packages/test-harness/src/service/mod.rs @@ -0,0 +1,4 @@ +//! WAVS service lifecycle: spec, operator registration, in-process runner, +//! subprocess runner (preview), and tier selection. +//! +//! Submodules land in Steps 4–5 (in-process) and Step 7 (subprocess). From 0721885f4c79d58579a0fbd5019f29f78e4f4fb1 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 02:55:07 +0000 Subject: [PATCH 02/19] =?UTF-8?q?feat(test-harness):=20chain=20control=20l?= =?UTF-8?q?ayer=20=E2=80=94=20anvil,=20fork,=20snapshot,=20impersonate=20(?= =?UTF-8?q?#1147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the chain control submodule tree: - `chain::anvil`: re-exports `safe_spawn_anvil` from `utils::test_utils`; adds `spawn_local()` convenience returning provider + AnvilInstance. - `chain::fork`: `spawn_fork(opts)` with `FORK_RPC_URL` env fallback, pinned block, and redacted RPC logging via `chain::logging::redact_url`. Gated on the `fork` feature. - `chain::snapshot`: explicit `snapshot()` / `revert()` plus an RAII `SnapshotGuard` that warns on Drop if not explicitly reverted (async-drop is unstable). - `chain::impersonate`: `set_balance`, `impersonate_funded`, `enable_auto_impersonate`, `stop_impersonating`. `ONE_ETH` constant. - `chain::time`: `mine_blocks`, `set_automine`, `increase_time`, `set_next_block_timestamp`. - `chain::logging`: `redact_url`, `redact_key` for sanitized log lines. Enables the `anvil-api` + `anvil-node` features on `alloy-provider` in this crate's manifest so the AnvilApi extension trait is available without bleeding into workspace-wide deps. Verification: `cargo test -p wavs-test-harness` passes — 3 logging unit tests and 5 chain smoke tests covering local spawn, mining, snapshot/revert, impersonation, and fork-options env validation. Refs Lay3rLabs/WAVS#1147. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/test-harness/Cargo.toml | 2 +- packages/test-harness/src/chain/anvil.rs | 24 ++++++ packages/test-harness/src/chain/fork.rs | 85 +++++++++++++++++++ .../test-harness/src/chain/impersonate.rs | 55 ++++++++++++ packages/test-harness/src/chain/logging.rs | 54 ++++++++++++ packages/test-harness/src/chain/mod.rs | 20 ++++- packages/test-harness/src/chain/snapshot.rs | 70 +++++++++++++++ packages/test-harness/src/chain/time.rs | 47 ++++++++++ packages/test-harness/tests/chain_smoke.rs | 63 ++++++++++++++ 9 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 packages/test-harness/src/chain/anvil.rs create mode 100644 packages/test-harness/src/chain/fork.rs create mode 100644 packages/test-harness/src/chain/impersonate.rs create mode 100644 packages/test-harness/src/chain/logging.rs create mode 100644 packages/test-harness/src/chain/snapshot.rs create mode 100644 packages/test-harness/src/chain/time.rs create mode 100644 packages/test-harness/tests/chain_smoke.rs diff --git a/packages/test-harness/Cargo.toml b/packages/test-harness/Cargo.toml index 4d4d9e9d7..e9bab8636 100644 --- a/packages/test-harness/Cargo.toml +++ b/packages/test-harness/Cargo.toml @@ -41,7 +41,7 @@ alloy-contract = { workspace = true } alloy-network = { workspace = true } alloy-node-bindings = { workspace = true } alloy-primitives = { workspace = true } -alloy-provider = { workspace = true } +alloy-provider = { workspace = true, features = ["anvil-api", "anvil-node"] } alloy-signer = { workspace = true } alloy-signer-local = { workspace = true } alloy-sol-types = { workspace = true } diff --git a/packages/test-harness/src/chain/anvil.rs b/packages/test-harness/src/chain/anvil.rs new file mode 100644 index 000000000..652febede --- /dev/null +++ b/packages/test-harness/src/chain/anvil.rs @@ -0,0 +1,24 @@ +//! Local Anvil spawn primitives. +//! +//! The harness reuses [`safe_spawn_anvil`] from `utils::test_utils` (which retries +//! on port collisions). This module re-exports it and adds a convenience that also +//! returns a connected provider so callers don't have to wire up `ProviderBuilder`. + +use alloy_network::Ethereum; +use alloy_provider::{ext::AnvilApi, Provider, ProviderBuilder}; +use anyhow::Result; + +#[allow(unused_imports)] +pub use utils::test_utils::anvil::{safe_spawn_anvil, safe_spawn_anvil_extra}; + +use alloy_node_bindings::AnvilInstance; + +/// Spawn a fresh local Anvil and return a connected provider alongside the instance. +/// +/// Port collisions are handled automatically (see [`safe_spawn_anvil`]). +pub async fn spawn_local() -> Result<(impl Provider + AnvilApi + Clone, AnvilInstance)> { + let anvil = safe_spawn_anvil(); + #[allow(deprecated)] + let provider = ProviderBuilder::new().on_http(anvil.endpoint_url()); + Ok((provider, anvil)) +} diff --git a/packages/test-harness/src/chain/fork.rs b/packages/test-harness/src/chain/fork.rs new file mode 100644 index 000000000..21098a092 --- /dev/null +++ b/packages/test-harness/src/chain/fork.rs @@ -0,0 +1,85 @@ +//! Pinned mainnet-fork spawn for fork-tier tests. +//! +//! The fork URL and (optionally) pinned block number are read either from an explicit +//! [`ForkOptions`] struct or from the `FORK_RPC_URL` env var. The URL is never logged +//! verbatim — only a redacted suffix via [`crate::chain::logging::redact_url`]. +//! +//! Modelled after `wavs-defi/crates/integration-tests/tests/common/anvil.rs`. + +use alloy_network::Ethereum; +use alloy_node_bindings::{Anvil, AnvilInstance}; +use alloy_provider::{ext::AnvilApi, Provider, ProviderBuilder}; +use anyhow::{anyhow, Context, Result}; + +use crate::chain::logging::redact_url; +use utils::test_utils::anvil::safe_spawn_anvil_extra; + +#[cfg(feature = "fork")] +pub const DEFAULT_FORK_RPC_ENV: &str = "FORK_RPC_URL"; + +/// Options for spawning a forked Anvil instance. +#[derive(Debug, Clone, Default)] +pub struct ForkOptions { + /// Upstream RPC URL. If `None`, reads `FORK_RPC_URL` from the environment. + pub rpc_url: Option, + /// Pin to a specific block number. Recommended for determinism. + pub block_number: Option, + /// Anvil spawn timeout in milliseconds. Defaults to 60 000 ms. + pub timeout_ms: Option, +} + +impl ForkOptions { + /// Build options from the environment (`FORK_RPC_URL`) and a pinned block. + pub fn from_env(block_number: Option) -> Result { + let rpc_url = std::env::var(DEFAULT_FORK_RPC_ENV) + .with_context(|| format!("{DEFAULT_FORK_RPC_ENV} must be set for fork-tier tests"))?; + Ok(Self { + rpc_url: Some(rpc_url), + block_number, + timeout_ms: None, + }) + } +} + +/// Spawn a forked Anvil instance and return a connected provider. +/// +/// Logs the RPC URL only as a redacted suffix. +#[cfg(feature = "fork")] +pub async fn spawn_fork( + opts: ForkOptions, +) -> Result<(impl Provider + AnvilApi + Clone, AnvilInstance)> { + let rpc_url = match opts.rpc_url { + Some(u) => u, + None => std::env::var(DEFAULT_FORK_RPC_ENV).with_context(|| { + format!("{DEFAULT_FORK_RPC_ENV} must be set or pass ForkOptions::rpc_url") + })?, + }; + if rpc_url.is_empty() { + return Err(anyhow!("fork RPC URL is empty")); + } + let block_number = opts.block_number; + let timeout_ms = opts.timeout_ms.unwrap_or(60_000); + + let redacted = redact_url(&rpc_url); + match block_number { + Some(b) => tracing::info!(rpc = %redacted, block = b, "spawning forked anvil"), + None => tracing::info!(rpc = %redacted, "spawning forked anvil at latest"), + } + + // Capture by reference / value so the retry closure stays `Fn`. + let rpc_ref = rpc_url.as_str(); + let anvil = safe_spawn_anvil_extra(|a: Anvil| { + let mut a = a.fork(rpc_ref).timeout(timeout_ms); + if let Some(b) = block_number { + a = a.fork_block_number(b); + } + a + }); + + tracing::info!(endpoint = %anvil.endpoint(), "anvil fork ready"); + + #[allow(deprecated)] + let provider = ProviderBuilder::new().on_http(anvil.endpoint_url()); + + Ok((provider, anvil)) +} diff --git a/packages/test-harness/src/chain/impersonate.rs b/packages/test-harness/src/chain/impersonate.rs new file mode 100644 index 000000000..7ee428ffa --- /dev/null +++ b/packages/test-harness/src/chain/impersonate.rs @@ -0,0 +1,55 @@ +//! Account impersonation and funding helpers for forked / Anvil chains. +//! +//! All helpers use Anvil's cheat-code RPC methods; they will fail on chains that +//! don't expose `anvil_*` namespaced methods. + +use alloy_network::Ethereum; +use alloy_primitives::{Address, U256}; +use alloy_provider::{ext::AnvilApi, Provider}; +use anyhow::Result; + +/// 1 ETH in wei. +pub const ONE_ETH: U256 = U256::from_limbs([1_000_000_000_000_000_000u64, 0, 0, 0]); + +/// Set an account's native-token balance. +pub async fn set_balance( + provider: &(impl Provider + AnvilApi), + account: Address, + wei: U256, +) -> Result<()> { + provider.anvil_set_balance(account, wei).await?; + Ok(()) +} + +/// Fund an account with 1 ETH and start impersonating it. +/// +/// Caller is responsible for calling [`stop_impersonating`] when done. +pub async fn impersonate_funded( + provider: &(impl Provider + AnvilApi), + account: Address, +) -> Result<()> { + provider.anvil_set_balance(account, ONE_ETH).await?; + provider.anvil_impersonate_account(account).await?; + tracing::debug!(%account, "impersonate_funded"); + Ok(()) +} + +/// Enable auto-impersonation for the lifetime of the test. +/// +/// After this call, any `from` field on a transaction request is honored without a +/// signer. Use sparingly — it removes the safety of explicit impersonation. +pub async fn enable_auto_impersonate( + provider: &(impl Provider + AnvilApi), +) -> Result<()> { + provider.anvil_auto_impersonate_account(true).await?; + Ok(()) +} + +/// Stop impersonating an account. +pub async fn stop_impersonating( + provider: &(impl Provider + AnvilApi), + account: Address, +) -> Result<()> { + provider.anvil_stop_impersonating_account(account).await?; + Ok(()) +} diff --git a/packages/test-harness/src/chain/logging.rs b/packages/test-harness/src/chain/logging.rs new file mode 100644 index 000000000..edcc08163 --- /dev/null +++ b/packages/test-harness/src/chain/logging.rs @@ -0,0 +1,54 @@ +//! Sanitized logging for chain operations. +//! +//! Never log raw RPC URLs or private keys. Use [`redact_url`] / [`redact_key`] anywhere +//! a sensitive value crosses into a log line. + +/// Redact an RPC URL down to a non-identifying suffix. +/// +/// Returns `…` for strings longer than 8 characters, otherwise `…`. +/// This is enough to disambiguate which provider is in use without leaking the API key. +pub fn redact_url(url: &str) -> String { + if url.len() > 8 { + format!("…{}", &url[url.len() - 8..]) + } else { + format!("…{url}") + } +} + +/// Redact a private key or mnemonic down to a non-identifying prefix. +/// +/// Returns `…` (after stripping any leading `0x`). For short or empty input, +/// returns a generic placeholder. +pub fn redact_key(key: &str) -> String { + let trimmed = key.strip_prefix("0x").unwrap_or(key); + if trimmed.len() >= 6 { + format!("{}…", &trimmed[..6]) + } else { + "".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_long_urls_to_suffix() { + let s = redact_url("https://api.example.com/v1/abc123XYZ"); + assert_eq!(s, "…bc123XYZ"); + assert!(!s.contains("example")); + } + + #[test] + fn redacts_keys_to_short_prefix() { + let s = redact_key("0xdeadbeefcafebabe1234567890"); + assert!(s.starts_with("deadbe")); + assert!(!s.contains("cafebabe")); + } + + #[test] + fn redacts_short_inputs_safely() { + assert_eq!(redact_url("x"), "…x"); + assert_eq!(redact_key("0xab"), ""); + } +} diff --git a/packages/test-harness/src/chain/mod.rs b/packages/test-harness/src/chain/mod.rs index 5e84d9997..c85fd71a6 100644 --- a/packages/test-harness/src/chain/mod.rs +++ b/packages/test-harness/src/chain/mod.rs @@ -1,4 +1,20 @@ //! Chain control primitives: local Anvil, pinned forks, snapshots, impersonation, //! time control, and sanitized logging. -//! -//! Submodules land in Step 2. + +pub mod anvil; +#[cfg(feature = "fork")] +pub mod fork; +pub mod impersonate; +pub mod logging; +pub mod snapshot; +pub mod time; + +pub use anvil::{safe_spawn_anvil, spawn_local}; +#[cfg(feature = "fork")] +pub use fork::{spawn_fork, ForkOptions, DEFAULT_FORK_RPC_ENV}; +pub use impersonate::{ + enable_auto_impersonate, impersonate_funded, set_balance, stop_impersonating, ONE_ETH, +}; +pub use logging::{redact_key, redact_url}; +pub use snapshot::{revert, snapshot, SnapshotGuard}; +pub use time::{increase_time, mine_blocks, set_automine, set_next_block_timestamp}; diff --git a/packages/test-harness/src/chain/snapshot.rs b/packages/test-harness/src/chain/snapshot.rs new file mode 100644 index 000000000..cf97ea875 --- /dev/null +++ b/packages/test-harness/src/chain/snapshot.rs @@ -0,0 +1,70 @@ +//! EVM snapshot / revert primitives. +//! +//! Provides explicit [`snapshot`] / [`revert`] functions plus a [`SnapshotGuard`] RAII +//! handle. The guard logs a warning if dropped without an explicit revert — async-drop +//! support is not stable, so consumers are expected to call `.revert(&provider).await` +//! before letting the guard go out of scope. + +use alloy_network::Ethereum; +use alloy_primitives::U256; +use alloy_provider::{ext::AnvilApi, Provider}; +use anyhow::Result; + +/// Take a chain snapshot. Returns the snapshot id. +pub async fn snapshot(provider: &(impl Provider + AnvilApi)) -> Result { + let id = provider.anvil_snapshot().await?; + tracing::debug!(snapshot_id = %id, "evm_snapshot"); + Ok(id) +} + +/// Revert the chain to a previous snapshot. Returns `true` on success. +pub async fn revert( + provider: &(impl Provider + AnvilApi), + id: U256, +) -> Result { + let ok = provider.anvil_revert(id).await?; + tracing::debug!(snapshot_id = %id, ok, "evm_revert"); + Ok(ok) +} + +/// RAII guard around a snapshot id. +/// +/// Call [`SnapshotGuard::revert`] explicitly before the guard drops. The guard cannot +/// auto-revert in `Drop` because that would require blocking on an async call; instead +/// it logs a warning if dropped without being consumed. +pub struct SnapshotGuard { + id: Option, +} + +impl SnapshotGuard { + /// Take a snapshot and return a guard wrapping the id. + pub async fn take(provider: &(impl Provider + AnvilApi)) -> Result { + let id = snapshot(provider).await?; + Ok(Self { id: Some(id) }) + } + + /// Revert to the captured snapshot. Consumes the guard. + pub async fn revert( + mut self, + provider: &(impl Provider + AnvilApi), + ) -> Result { + let id = self.id.take().expect("guard already consumed"); + revert(provider, id).await + } + + /// The underlying snapshot id, if not yet consumed. + pub fn id(&self) -> Option { + self.id + } +} + +impl Drop for SnapshotGuard { + fn drop(&mut self) { + if let Some(id) = self.id { + tracing::warn!( + snapshot_id = %id, + "SnapshotGuard dropped without revert — chain state may carry across tests" + ); + } + } +} diff --git a/packages/test-harness/src/chain/time.rs b/packages/test-harness/src/chain/time.rs new file mode 100644 index 000000000..4b72c3bbc --- /dev/null +++ b/packages/test-harness/src/chain/time.rs @@ -0,0 +1,47 @@ +//! Block-time and mining control. +//! +//! Half the flake in fork-tier tests comes from "did the next block tick yet" — these +//! helpers make the answer deterministic. + +use alloy_network::Ethereum; +use alloy_provider::{ext::AnvilApi, Provider}; +use anyhow::Result; + +/// Mine `count` blocks immediately. +pub async fn mine_blocks( + provider: &(impl Provider + AnvilApi), + count: u64, +) -> Result<()> { + provider.anvil_mine(Some(count), None).await?; + Ok(()) +} + +/// Toggle auto-mining. When off, blocks are produced only via [`mine_blocks`] or +/// [`set_block_timestamp`] + a transaction. +pub async fn set_automine( + provider: &(impl Provider + AnvilApi), + on: bool, +) -> Result<()> { + provider.anvil_set_auto_mine(on).await?; + Ok(()) +} + +/// Advance the chain's clock by `seconds` and mine one block to commit it. +pub async fn increase_time( + provider: &(impl Provider + AnvilApi), + seconds: u64, +) -> Result<()> { + provider.anvil_increase_time(seconds).await?; + provider.anvil_mine(Some(1), None).await?; + Ok(()) +} + +/// Set the timestamp of the next block. +pub async fn set_next_block_timestamp( + provider: &(impl Provider + AnvilApi), + ts: u64, +) -> Result<()> { + provider.anvil_set_next_block_timestamp(ts).await?; + Ok(()) +} + diff --git a/packages/test-harness/tests/chain_smoke.rs b/packages/test-harness/tests/chain_smoke.rs new file mode 100644 index 000000000..98357a2ee --- /dev/null +++ b/packages/test-harness/tests/chain_smoke.rs @@ -0,0 +1,63 @@ +//! Smoke tests for the chain control layer against a local Anvil. +//! +//! These tests do not require any external RPC and should run in any CI tier. + +use alloy_primitives::{address, U256}; +use alloy_provider::Provider; +use wavs_test_harness::chain; + +#[tokio::test] +async fn spawn_local_anvil_round_trip() { + let (provider, _anvil) = chain::spawn_local().await.expect("spawn local"); + let block = provider.get_block_number().await.expect("block_number"); + assert_eq!(block, 0, "fresh anvil should be at block 0"); +} + +#[tokio::test] +async fn mine_blocks_advances_chain() { + let (provider, _anvil) = chain::spawn_local().await.unwrap(); + chain::mine_blocks(&provider, 5).await.unwrap(); + let block = provider.get_block_number().await.unwrap(); + assert_eq!(block, 5); +} + +#[tokio::test] +async fn snapshot_revert_round_trip() { + let (provider, _anvil) = chain::spawn_local().await.unwrap(); + chain::mine_blocks(&provider, 3).await.unwrap(); + let snap = chain::SnapshotGuard::take(&provider).await.unwrap(); + chain::mine_blocks(&provider, 10).await.unwrap(); + let before_revert = provider.get_block_number().await.unwrap(); + assert_eq!(before_revert, 13); + assert!(snap.revert(&provider).await.unwrap()); + let after_revert = provider.get_block_number().await.unwrap(); + assert_eq!(after_revert, 3, "should revert to snapshot block"); +} + +#[tokio::test] +async fn impersonate_and_set_balance() { + let (provider, _anvil) = chain::spawn_local().await.unwrap(); + let alice = address!("00000000000000000000000000000000000000aa"); + + chain::set_balance(&provider, alice, U256::from(42u64) * chain::ONE_ETH) + .await + .unwrap(); + let bal = provider.get_balance(alice).await.unwrap(); + assert_eq!(bal, U256::from(42u64) * chain::ONE_ETH); + + chain::impersonate_funded(&provider, alice).await.unwrap(); + chain::stop_impersonating(&provider, alice).await.unwrap(); +} + +#[test] +fn fork_options_from_env_requires_fork_rpc_url() { + // With no FORK_RPC_URL set, from_env must fail with a non-leaking error. + temp_env::with_var_unset("FORK_RPC_URL", || { + let res = wavs_test_harness::chain::ForkOptions::from_env(Some(123)); + assert!(res.is_err()); + let msg = format!("{}", res.unwrap_err()); + assert!(msg.contains("FORK_RPC_URL")); + // The error must not leak any URL — only the env var name. + assert!(!msg.contains("https://")); + }); +} From 71425d2beba37820609731401ca47a64c23cb547 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 02:57:27 +0000 Subject: [PATCH 03/19] feat(test-harness): TOML chain profiles + typed Addresses (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `fixtures::ChainProfile` with serde TOML loading matching the schema from the issue: [chain] name, chain_id, fork_block, rpc_env [addresses] flat name→address map [accounts] funded_key_env + flat name→address map Ships three bundled profiles via `include_str!`: - `local.toml` — chain_id 31337, no fork, no env required - `base.toml` — chain_id 8453, FORK_RPC_URL, all wavs-defi protocol addresses (Aerodrome, Avantis, Chainlink, Pyth, tokens, USDC whale, Avantis gov + operator) - `mainnet.toml` — chain_id 1, FORK_RPC_URL, minimal token + AAVE v3 set `ChainProfile::load("base")` resolves to a baked-in profile; `ChainProfile::from_path(...)` and `from_str(...)` support arbitrary user profiles. `resolve_rpc_url()` reads the env var named by `chain.rpc_env`, errors descriptively if unset or empty, and never logs the value itself. `Addresses` is a `BTreeMap` newtype with `get`, `require` (descriptive error listing known keys), `iter`, and `len`. Used by both the `[addresses]` table and the inner addresses of `[accounts]`. Verification: `cargo test -p wavs-test-harness` passes 9 fixture unit tests (profile load, address lookup, env resolution, error messages) plus the 5 chain smoke tests from Step 2. Refs Lay3rLabs/WAVS#1147. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test-harness/fixtures/chains/base.toml | 34 ++++ .../test-harness/fixtures/chains/local.toml | 14 ++ .../test-harness/fixtures/chains/mainnet.toml | 22 +++ .../test-harness/src/fixtures/addresses.rs | 50 +++++ packages/test-harness/src/fixtures/mod.rs | 8 +- packages/test-harness/src/fixtures/profile.rs | 181 ++++++++++++++++++ 6 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 packages/test-harness/fixtures/chains/base.toml create mode 100644 packages/test-harness/fixtures/chains/local.toml create mode 100644 packages/test-harness/fixtures/chains/mainnet.toml create mode 100644 packages/test-harness/src/fixtures/addresses.rs create mode 100644 packages/test-harness/src/fixtures/profile.rs diff --git a/packages/test-harness/fixtures/chains/base.toml b/packages/test-harness/fixtures/chains/base.toml new file mode 100644 index 000000000..b25ee51b1 --- /dev/null +++ b/packages/test-harness/fixtures/chains/base.toml @@ -0,0 +1,34 @@ +# Base mainnet profile for fork-tier tests. +# +# Addresses sourced from wavs-defi/CLAUDE.md and config/smart_vault.template.json. +# Pinned-block defaults match the convention in wavs-defi's foundry.toml. + +[chain] +name = "base" +chain_id = 8453 +fork_block = 29000000 +rpc_env = "FORK_RPC_URL" + +[addresses] +# Tokens +usdc = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" +weth = "0x4200000000000000000000000000000000000006" + +# Aerodrome (concentrated liquidity) +aerodrome_pool_weth_usdc = "0xb2cc224c1c9feE385f8ad6a55b4d94e92359dc59" +aerodrome_nft_manager = "0x827922686190790b37229fd06084350E74485b72" +aerodrome_router = "0xBE6D8f0d05cC4be24d5167a3eF062215bE6D18a5" + +# Avantis (perp DEX) +avantis_trading = "0x44914408af82bC9983bbb330e3578E1105e11d4e" +avantis_trading_storage = "0x8a311D7048c35985aa31C131B9A13e03a5f7422d" +avantis_gov = "0x3775AF82c6C5705140944cD515fE899214Fb0288" + +# Oracles +chainlink_eth_usd = "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70" +pyth = "0x8250f4aF4B972684F7b336503E2D6dFeDeB1487a" + +[accounts] +funded_key_env = "FUNDED_KEY" +usdc_whale = "0xEe7aE85f2Fe2239E27D9c1E23fFFe168D63b4055" +avantis_operator = "0xaa0b5a5828af43dc9ba15fbdd53d324811e4e92f" diff --git a/packages/test-harness/fixtures/chains/local.toml b/packages/test-harness/fixtures/chains/local.toml new file mode 100644 index 000000000..c2dce5b5f --- /dev/null +++ b/packages/test-harness/fixtures/chains/local.toml @@ -0,0 +1,14 @@ +# Local Anvil profile — no fork, no env vars required. + +[chain] +name = "local" +chain_id = 31337 +# fork_block intentionally omitted — local Anvil starts at block 0. +# rpc_env intentionally omitted — local Anvil is spawned in-process. + +[addresses] +# Empty. Local tests deploy their own contracts. + +[accounts] +# Anvil ships with a deterministic default mnemonic; the harness uses +# `chain::anvil::spawn_local()` which does not require a funded_key_env. diff --git a/packages/test-harness/fixtures/chains/mainnet.toml b/packages/test-harness/fixtures/chains/mainnet.toml new file mode 100644 index 000000000..c0a65db3a --- /dev/null +++ b/packages/test-harness/fixtures/chains/mainnet.toml @@ -0,0 +1,22 @@ +# Ethereum mainnet profile for fork-tier tests. +# +# Address set is intentionally minimal. Downstream apps should layer on +# protocol-specific addresses by composing their own profile or by passing an +# augmented profile via ChainProfile::from_path. + +[chain] +name = "mainnet" +chain_id = 1 +rpc_env = "FORK_RPC_URL" + +[addresses] +usdc = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +weth = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" +usdt = "0xdAC17F958D2ee523a2206206994597C13D831ec7" +dai = "0x6B175474E89094C44Da98b954EedeAC495271d0F" + +# Aave v3 mainnet +aave_v3_pool = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" + +[accounts] +funded_key_env = "FUNDED_KEY" diff --git a/packages/test-harness/src/fixtures/addresses.rs b/packages/test-harness/src/fixtures/addresses.rs new file mode 100644 index 000000000..44cfb39b4 --- /dev/null +++ b/packages/test-harness/src/fixtures/addresses.rs @@ -0,0 +1,50 @@ +//! Typed address lookup keyed by symbolic name. +//! +//! Wraps a `BTreeMap` so iteration order is deterministic. Addresses +//! parse from standard checksummed or lowercase hex (alloy `Address` `FromStr`). + +use std::collections::BTreeMap; + +use alloy_primitives::Address; +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; + +/// A flat name → address lookup table loaded from a TOML `[addresses]` or +/// `[accounts]` section. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Addresses(BTreeMap); + +impl Addresses { + /// Look up an address by symbolic name (case-sensitive). + pub fn get(&self, name: &str) -> Option
{ + self.0.get(name).copied() + } + + /// Look up an address or return a descriptive error. + pub fn require(&self, name: &str) -> Result
{ + self.get(name).ok_or_else(|| { + let mut keys: Vec<&str> = self.0.keys().map(String::as_str).collect(); + keys.sort(); + anyhow!( + "no address named `{name}` in profile; known: [{}]", + keys.join(", ") + ) + }) + } + + /// Iterate over all entries. + pub fn iter(&self) -> impl Iterator { + self.0.iter().map(|(k, v)| (k.as_str(), *v)) + } + + /// Total number of addresses. + pub fn len(&self) -> usize { + self.0.len() + } + + /// True iff no addresses are present. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} diff --git a/packages/test-harness/src/fixtures/mod.rs b/packages/test-harness/src/fixtures/mod.rs index 584a310ac..1cc6eb959 100644 --- a/packages/test-harness/src/fixtures/mod.rs +++ b/packages/test-harness/src/fixtures/mod.rs @@ -1,3 +1,7 @@ //! TOML-backed chain profiles and typed address lookups. -//! -//! Submodules land in Step 3. + +pub mod addresses; +pub mod profile; + +pub use addresses::Addresses; +pub use profile::{AccountsSection, ChainProfile, ChainSection}; diff --git a/packages/test-harness/src/fixtures/profile.rs b/packages/test-harness/src/fixtures/profile.rs new file mode 100644 index 000000000..8c5ac8127 --- /dev/null +++ b/packages/test-harness/src/fixtures/profile.rs @@ -0,0 +1,181 @@ +//! [`ChainProfile`] — TOML-backed description of a chain (local, base, mainnet, …). +//! +//! Format mirrors the schema in issue #1147: +//! +//! ```toml +//! [chain] +//! name = "base" +//! chain_id = 8453 +//! fork_block = 29000000 +//! rpc_env = "FORK_RPC_URL" +//! +//! [addresses] +//! usdc = "0x..." +//! +//! [accounts] +//! funded_key_env = "FUNDED_KEY" +//! usdc_whale = "0x..." +//! ``` +//! +//! Three profiles ship with the crate: `local`, `base`, `mainnet`. Consumers may +//! also load arbitrary profiles via [`ChainProfile::from_path`] or +//! [`ChainProfile::from_str`]. + +use std::path::Path; + +use alloy_primitives::Address; +use anyhow::{anyhow, Context, Result}; +use serde::Deserialize; + +use super::addresses::Addresses; + +/// Top-level `[chain]` table. +#[derive(Debug, Clone, Deserialize)] +pub struct ChainSection { + pub name: String, + pub chain_id: u64, + #[serde(default)] + pub fork_block: Option, + #[serde(default)] + pub rpc_env: Option, +} + +/// Top-level `[accounts]` table. `funded_key_env` names the env var that holds the +/// deployer private key. Additional named accounts (whales, governance addresses) +/// are exposed as a flat lookup via [`AccountsSection::address`]. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct AccountsSection { + #[serde(default)] + pub funded_key_env: Option, + #[serde(flatten, default)] + pub addresses: Addresses, +} + +impl AccountsSection { + /// Look up a named account address (e.g. `usdc_whale`, `avantis_gov`). + pub fn address(&self, name: &str) -> Result
{ + self.addresses.require(name) + } +} + +/// A complete chain profile loaded from a TOML file. +#[derive(Debug, Clone, Deserialize)] +pub struct ChainProfile { + pub chain: ChainSection, + #[serde(default)] + pub addresses: Addresses, + #[serde(default)] + pub accounts: AccountsSection, +} + +const LOCAL_TOML: &str = include_str!("../../fixtures/chains/local.toml"); +const BASE_TOML: &str = include_str!("../../fixtures/chains/base.toml"); +const MAINNET_TOML: &str = include_str!("../../fixtures/chains/mainnet.toml"); + +impl ChainProfile { + /// Load a bundled profile by name (`local`, `base`, `mainnet`). + pub fn load(name: &str) -> Result { + let raw = match name { + "local" => LOCAL_TOML, + "base" => BASE_TOML, + "mainnet" => MAINNET_TOML, + other => { + return Err(anyhow!( + "unknown bundled profile `{other}` — known: local, base, mainnet" + )) + } + }; + Self::from_str(raw).with_context(|| format!("parse bundled profile `{name}`")) + } + + /// Load a profile from an arbitrary path. + pub fn from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let raw = std::fs::read_to_string(path) + .with_context(|| format!("read profile {}", path.display()))?; + Self::from_str(&raw).with_context(|| format!("parse profile {}", path.display())) + } + + /// Parse a profile from a TOML string. Prefer [`Self::load`] or [`Self::from_path`]. + pub fn from_str(s: &str) -> Result { + Ok(toml::from_str(s)?) + } + + /// Resolve the fork RPC URL from the env var declared in `chain.rpc_env`. + /// + /// Returns `None` if `rpc_env` is unset on the profile. Returns `Err` if the env + /// var is named but missing or empty. + pub fn resolve_rpc_url(&self) -> Result> { + let Some(var) = &self.chain.rpc_env else { + return Ok(None); + }; + let val = std::env::var(var).with_context(|| format!("{var} must be set"))?; + if val.is_empty() { + return Err(anyhow!("{var} is empty")); + } + Ok(Some(val)) + } + + /// Convenience accessor for a named protocol address. + pub fn address(&self, name: &str) -> Result
{ + self.addresses.require(name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_profile_loads() { + let p = ChainProfile::load("local").expect("local"); + assert_eq!(p.chain.name, "local"); + assert_eq!(p.chain.chain_id, 31337); + assert!(p.chain.rpc_env.is_none()); + } + + #[test] + fn base_profile_has_known_addresses() { + let p = ChainProfile::load("base").expect("base"); + assert_eq!(p.chain.name, "base"); + assert_eq!(p.chain.chain_id, 8453); + assert_eq!(p.chain.rpc_env.as_deref(), Some("FORK_RPC_URL")); + + let usdc = p.address("usdc").expect("usdc address"); + let weth = p.address("weth").expect("weth address"); + assert_ne!(usdc, weth); + + let whale = p.accounts.address("usdc_whale").expect("usdc_whale"); + assert_ne!(whale, Address::ZERO); + } + + #[test] + fn mainnet_profile_loads() { + let p = ChainProfile::load("mainnet").expect("mainnet"); + assert_eq!(p.chain.chain_id, 1); + } + + #[test] + fn unknown_profile_errors() { + let e = ChainProfile::load("unknown").unwrap_err(); + assert!(format!("{e}").contains("unknown")); + } + + #[test] + fn resolve_rpc_url_reads_env() { + let p = ChainProfile::load("base").unwrap(); + temp_env::with_var("FORK_RPC_URL", Some("https://api.example.com/k"), || { + let url = p.resolve_rpc_url().unwrap(); + assert_eq!(url.as_deref(), Some("https://api.example.com/k")); + }); + } + + #[test] + fn resolve_rpc_url_errors_if_missing() { + let p = ChainProfile::load("base").unwrap(); + temp_env::with_var_unset("FORK_RPC_URL", || { + let res = p.resolve_rpc_url(); + assert!(res.is_err()); + }); + } +} From 2b4051eb13477841f8eb6bee1ddd622add8b7787 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 03:05:49 +0000 Subject: [PATCH 04/19] feat(test-harness): service spec + operator middleware re-exports (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the declarative service layer that runners consume: - `service::config::ServiceSpec` — fluent builder with component_wasm, aggregator_wasm, config_vars (`BTreeMap` for deterministic order), and operator_count. `validate()` catches missing fields and non-existent paths. - `service::operators` — re-exports the stable middleware mocks from `utils::test_utils::middleware`: `EvmMiddleware`, `EvmMiddlewareType`, `EigenlayerMiddleware`, `PoaMiddleware`, `MockEvmServiceManager`, `AvsOperator`, plus the Anvil deployer constants. New `OperatorSet` aggregate ties a deployed service-manager to its operator list. The mocks deploy real service-manager contracts via Docker images (ghcr.io/lay3rlabs/wavs-middleware, /poa-middleware); the doc string flags this so PR-tier consumers know they need a Docker daemon. A logic-tier path (no operator registration) is reserved for runners that need only WASM output validation. Verification: `cargo test -p wavs-test-harness` passes 13 unit tests including 4 new ServiceSpec tests (missing-field, missing-path, config-var round-trip, default operator count) plus chain smokes. Refs Lay3rLabs/WAVS#1147. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/test-harness/src/service/config.rs | 143 ++++++++++++++++++ packages/test-harness/src/service/mod.rs | 16 +- .../test-harness/src/service/operators.rs | 51 +++++++ 3 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 packages/test-harness/src/service/config.rs create mode 100644 packages/test-harness/src/service/operators.rs diff --git a/packages/test-harness/src/service/config.rs b/packages/test-harness/src/service/config.rs new file mode 100644 index 000000000..b994499fe --- /dev/null +++ b/packages/test-harness/src/service/config.rs @@ -0,0 +1,143 @@ +//! [`ServiceSpec`] — declarative description of the WAVS service a test wants to boot. +//! +//! `ServiceSpec` is a builder for the inputs the runner needs: which component WASM +//! drives the operator, which aggregator WASM drives the aggregator, what config +//! variables the component reads from `host::config_var()`, and how many operators +//! to register. +//! +//! The runner (in-process or subprocess) consumes a fully populated `ServiceSpec` +//! and is responsible for everything from registering operators through producing +//! signed submissions. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Result}; + +/// Declarative description of a service-under-test. +#[derive(Debug, Clone, Default)] +pub struct ServiceSpec { + component_wasm: Option, + aggregator_wasm: Option, + config_vars: BTreeMap, + operator_count: Option, +} + +impl ServiceSpec { + /// Empty spec — fields are filled in via the builder methods. + pub fn new() -> Self { + Self::default() + } + + /// Path to the operator component WASM (e.g. the compiled `delta_neutral_strategy`). + pub fn component_wasm(mut self, path: impl AsRef) -> Self { + self.component_wasm = Some(path.as_ref().to_path_buf()); + self + } + + /// Path to the aggregator component WASM. + pub fn aggregator_wasm(mut self, path: impl AsRef) -> Self { + self.aggregator_wasm = Some(path.as_ref().to_path_buf()); + self + } + + /// Set a config var the component reads via `host::config_var()`. + pub fn config_var(mut self, key: impl Into, value: impl Into) -> Self { + self.config_vars.insert(key.into(), value.into()); + self + } + + /// Set multiple config vars at once. + pub fn config_vars(mut self, pairs: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + for (k, v) in pairs { + self.config_vars.insert(k.into(), v.into()); + } + self + } + + /// Number of operators to register. Default 1. + pub fn operator_count(mut self, n: usize) -> Self { + self.operator_count = Some(n); + self + } + + /// Validate the spec is complete enough to boot a service. + pub fn validate(&self) -> Result<()> { + let cw = self + .component_wasm + .as_ref() + .ok_or_else(|| anyhow!("ServiceSpec missing component_wasm"))?; + if !cw.exists() { + return Err(anyhow!("component_wasm not found at {}", cw.display())); + } + let aw = self + .aggregator_wasm + .as_ref() + .ok_or_else(|| anyhow!("ServiceSpec missing aggregator_wasm"))?; + if !aw.exists() { + return Err(anyhow!("aggregator_wasm not found at {}", aw.display())); + } + if let Some(0) = self.operator_count { + return Err(anyhow!("operator_count cannot be 0")); + } + Ok(()) + } + + pub fn component_wasm_path(&self) -> Option<&Path> { + self.component_wasm.as_deref() + } + + pub fn aggregator_wasm_path(&self) -> Option<&Path> { + self.aggregator_wasm.as_deref() + } + + pub fn config_var_map(&self) -> &BTreeMap { + &self.config_vars + } + + pub fn operators(&self) -> usize { + self.operator_count.unwrap_or(1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_requires_component_wasm() { + let s = ServiceSpec::new(); + let e = s.validate().unwrap_err(); + assert!(format!("{e}").contains("component_wasm")); + } + + #[test] + fn validate_checks_paths_exist() { + let s = ServiceSpec::new() + .component_wasm("/no/such/component.wasm") + .aggregator_wasm("/no/such/aggregator.wasm"); + let e = s.validate().unwrap_err(); + assert!(format!("{e}").contains("not found")); + } + + #[test] + fn config_vars_round_trip() { + let s = ServiceSpec::new() + .config_var("FOO", "1") + .config_var("BAR", "2"); + let m = s.config_var_map(); + assert_eq!(m.get("FOO").map(String::as_str), Some("1")); + assert_eq!(m.get("BAR").map(String::as_str), Some("2")); + } + + #[test] + fn operator_count_defaults_to_one() { + assert_eq!(ServiceSpec::new().operators(), 1); + assert_eq!(ServiceSpec::new().operator_count(3).operators(), 3); + } +} diff --git a/packages/test-harness/src/service/mod.rs b/packages/test-harness/src/service/mod.rs index e485daa6c..82b7cd2a1 100644 --- a/packages/test-harness/src/service/mod.rs +++ b/packages/test-harness/src/service/mod.rs @@ -1,4 +1,14 @@ -//! WAVS service lifecycle: spec, operator registration, in-process runner, -//! subprocess runner (preview), and tier selection. +//! WAVS service lifecycle: spec, operator registration, runner tiers. //! -//! Submodules land in Steps 4–5 (in-process) and Step 7 (subprocess). +//! - [`config`]: declarative [`ServiceSpec`] builder consumed by all runners. +//! - [`operators`]: middleware mock re-exports and [`OperatorSet`] aggregate. +//! - `runner_inproc` / `runner_subprocess`: tier-specific runners (Steps 5 & 7). + +pub mod config; +pub mod operators; + +pub use config::ServiceSpec; +pub use operators::{ + AvsOperator, EigenlayerMiddleware, EvmMiddleware, EvmMiddlewareType, MockEvmServiceManager, + OperatorSet, PoaMiddleware, ANVIL_DEPLOYER_ADDRESS, ANVIL_DEPLOYER_KEY, +}; diff --git a/packages/test-harness/src/service/operators.rs b/packages/test-harness/src/service/operators.rs new file mode 100644 index 000000000..1498948db --- /dev/null +++ b/packages/test-harness/src/service/operators.rs @@ -0,0 +1,51 @@ +//! Operator registration helpers. +//! +//! Re-exports the stable middleware mocks from `utils::test_utils::middleware` +//! and adds a thin `OperatorSet` aggregate type. The mocks deploy real +//! service-manager contracts via Docker images (Eigenlayer / POA), so any test +//! using these helpers requires a working Docker daemon at the in-process tier. +//! +//! For tests that do not need the real middleware (e.g. asserting only that the +//! component WASM produces the expected output), use the `logic` tier — which +//! skips operator registration entirely. The logic tier is exposed by the +//! `service::runner` module. + +use alloy_primitives::Address; + +pub use utils::test_utils::middleware::evm::{ + EigenlayerMiddleware, EvmMiddleware, EvmMiddlewareType, PoaMiddleware, + ANVIL_DEPLOYER_ADDRESS, ANVIL_DEPLOYER_KEY, +}; +pub use utils::test_utils::middleware::operator::AvsOperator; +pub use utils::test_utils::mock_service_manager::MockEvmServiceManager; + +/// A registered set of operators tied to a deployed service-manager. +#[derive(Debug, Clone)] +pub struct OperatorSet { + pub service_manager: Address, + pub operators: Vec, +} + +impl OperatorSet { + pub fn new(service_manager: Address, operators: Vec) -> Self { + Self { + service_manager, + operators, + } + } + + /// Number of operators in the set. + pub fn len(&self) -> usize { + self.operators.len() + } + + /// True if no operators are registered. + pub fn is_empty(&self) -> bool { + self.operators.is_empty() + } + + /// Iterate over the operator signers. + pub fn signers(&self) -> impl Iterator + '_ { + self.operators.iter().map(|o| o.signer) + } +} From 6bad36af8ef9ef65d571296762fd3870a9a7e15d Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 03:26:58 +0000 Subject: [PATCH 05/19] feat(test-harness): in-process runner + lifecycle helpers (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the inproc tier: real WASM execution of operator and aggregator components via `wavs-engine`, without booting the full WAVS dispatcher. `service::runner_inproc::InProcRunner` (gated on the `inproc` feature): - `from_spec(&ServiceSpec)` — reads both WASM files into memory, builds a synthetic `Service` with `Trigger::Manual` and one workflow. - `run_component(input: Vec) -> Vec>` — executes the operator component once via `wavs_engine::worlds::operator::execute::execute` and returns the raw payloads. Modelled after `packages/engine/tests/helpers/exec.rs::try_execute_component_raw`, copied + adapted because that helper lives in `tests/` with no `[lib]` target. - `run_aggregator(EventId, AggregatorInput)` — executes the aggregator component once via `wavs_engine::worlds::aggregator::execute::execute_input`. - Component / aggregator stdout routed through tracing under `wavs_test_harness::component` and `::aggregator` targets. `lifecycle/`: - `trigger::manual_input_json`, `manual_input_raw` — build the input bytes the synthetic Manual trigger feeds the component. - `waiters::wait_for`, `wait_until` — copy of the polling pattern from `layer-tests/src/e2e/helpers.rs::evm_wait_for_task_to_land` generalized over an arbitrary async predicate. - `assertions::assert_within` — within-tolerance check for delta-style near-zero comparisons. Dependencies added: `wavs-engine`, `wasmtime`, `tempfile` (moved from dev-dependencies). `wavs-engine` has no extra features beyond its default, so adding it does not enable `dev` on `packages/wavs`. Verification: `cargo test -p wavs-test-harness` passes 19 tests including the new `inproc_smoke::echo_data_round_trip` which executes the real `examples/build/components/echo_data.wasm` artifact end-to-end via the runner in 0.9 s. The test skips gracefully if the example artifact is missing (fresh checkouts without `just wasi-build-native`). Refs Lay3rLabs/WAVS#1147. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/test-harness/Cargo.toml | 3 + .../test-harness/src/lifecycle/assertions.rs | 23 ++ packages/test-harness/src/lifecycle/mod.rs | 12 +- .../test-harness/src/lifecycle/trigger.rs | 20 ++ .../test-harness/src/lifecycle/waiters.rs | 65 +++++ packages/test-harness/src/service/mod.rs | 4 + .../test-harness/src/service/runner_inproc.rs | 264 ++++++++++++++++++ packages/test-harness/tests/inproc_smoke.rs | 58 ++++ 8 files changed, 446 insertions(+), 3 deletions(-) create mode 100644 packages/test-harness/src/lifecycle/assertions.rs create mode 100644 packages/test-harness/src/lifecycle/trigger.rs create mode 100644 packages/test-harness/src/lifecycle/waiters.rs create mode 100644 packages/test-harness/src/service/runner_inproc.rs create mode 100644 packages/test-harness/tests/inproc_smoke.rs diff --git a/packages/test-harness/Cargo.toml b/packages/test-harness/Cargo.toml index e9bab8636..62532888f 100644 --- a/packages/test-harness/Cargo.toml +++ b/packages/test-harness/Cargo.toml @@ -25,6 +25,9 @@ subprocess = [] [dependencies] utils = { workspace = true, features = ["test-utils"] } wavs-types = { workspace = true, features = ["full"] } +wavs-engine = { workspace = true } +wasmtime = { workspace = true } +tempfile = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } diff --git a/packages/test-harness/src/lifecycle/assertions.rs b/packages/test-harness/src/lifecycle/assertions.rs new file mode 100644 index 000000000..125057b57 --- /dev/null +++ b/packages/test-harness/src/lifecycle/assertions.rs @@ -0,0 +1,23 @@ +//! Lightweight assertion helpers for typical contract-state shapes. +//! +//! These complement the standard `assert!` / `assert_eq!` macros with helpers that +//! produce richer error messages for common shapes (timeouts, near-zero deltas). + +use std::fmt::Debug; + +/// Assert that `actual` is within `tolerance` of `expected`. Useful for delta / +/// price values that should be "approximately zero" or "approximately equal". +pub fn assert_within(actual: T, expected: T, tolerance: T) +where + T: PartialOrd + std::ops::Sub + Copy + Debug, +{ + let diff = if actual > expected { + actual - expected + } else { + expected - actual + }; + assert!( + diff <= tolerance, + "expected {actual:?} within {tolerance:?} of {expected:?} (diff {diff:?})" + ); +} diff --git a/packages/test-harness/src/lifecycle/mod.rs b/packages/test-harness/src/lifecycle/mod.rs index c4f1303aa..9ef7fbea0 100644 --- a/packages/test-harness/src/lifecycle/mod.rs +++ b/packages/test-harness/src/lifecycle/mod.rs @@ -1,3 +1,9 @@ -//! Trigger emission, quorum/submission waiters, and contract assertions. -//! -//! Submodules land in Step 5. +//! Trigger emission, polling waiters, and contract assertions. + +pub mod assertions; +pub mod trigger; +pub mod waiters; + +pub use assertions::assert_within; +pub use trigger::{manual_input_json, manual_input_raw}; +pub use waiters::{wait_for, wait_until}; diff --git a/packages/test-harness/src/lifecycle/trigger.rs b/packages/test-harness/src/lifecycle/trigger.rs new file mode 100644 index 000000000..19aed15c9 --- /dev/null +++ b/packages/test-harness/src/lifecycle/trigger.rs @@ -0,0 +1,20 @@ +//! Trigger emission for the in-process tier. +//! +//! The in-process runner uses [`wavs_types::Trigger::Manual`] for its synthetic +//! service, so triggers are driven explicitly by the test rather than picked up +//! from a chain. These helpers build the input bytes the component receives. + +use serde::Serialize; + +/// Serialize an arbitrary `Serialize` value to JSON bytes for use as a manual +/// trigger payload. Most reference components in `examples/components/` accept +/// JSON-encoded input. +pub fn manual_input_json(input: &T) -> anyhow::Result> { + Ok(serde_json::to_vec(input)?) +} + +/// Wrap raw bytes as a manual trigger payload (no encoding). Use for components +/// that expect a specific byte layout (e.g. ABI-encoded). +pub fn manual_input_raw(bytes: impl Into>) -> Vec { + bytes.into() +} diff --git a/packages/test-harness/src/lifecycle/waiters.rs b/packages/test-harness/src/lifecycle/waiters.rs new file mode 100644 index 000000000..0ca545924 --- /dev/null +++ b/packages/test-harness/src/lifecycle/waiters.rs @@ -0,0 +1,65 @@ +//! On-chain waiters: poll until a condition holds or a timeout elapses. +//! +//! Patterns copied from `packages/layer-tests/src/e2e/helpers.rs` +//! (`evm_wait_for_task_to_land`, `wait_for_evm_trigger_streams_to_finalize`) — +//! layer-tests has no `[lib]` target so wrapping is not currently possible. + +use std::future::Future; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Result}; + +/// Poll `f` until it returns `Some(value)` or `timeout` elapses. +/// +/// `interval` controls poll cadence (default 250 ms if `None`). +pub async fn wait_for( + timeout: Duration, + interval: Option, + mut f: F, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let interval = interval.unwrap_or(Duration::from_millis(250)); + let deadline = Instant::now() + timeout; + loop { + if let Some(v) = f().await { + return Ok(v); + } + if Instant::now() >= deadline { + return Err(anyhow!("timed out after {:?}", timeout)); + } + tokio::time::sleep(interval).await; + } +} + +/// Poll `f` until it returns `Ok(value)` matching `predicate`, or `timeout` elapses. +/// +/// Errors from `f` are swallowed (treated as "not yet ready") so that transient +/// RPC failures don't break the loop. +pub async fn wait_until( + timeout: Duration, + interval: Option, + mut f: F, + predicate: P, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, + P: Fn(&T) -> bool, +{ + let interval = interval.unwrap_or(Duration::from_millis(250)); + let deadline = Instant::now() + timeout; + loop { + if let Ok(v) = f().await { + if predicate(&v) { + return Ok(v); + } + } + if Instant::now() >= deadline { + return Err(anyhow!("predicate not satisfied within {:?}", timeout)); + } + tokio::time::sleep(interval).await; + } +} diff --git a/packages/test-harness/src/service/mod.rs b/packages/test-harness/src/service/mod.rs index 82b7cd2a1..4106c6b6a 100644 --- a/packages/test-harness/src/service/mod.rs +++ b/packages/test-harness/src/service/mod.rs @@ -6,8 +6,12 @@ pub mod config; pub mod operators; +#[cfg(feature = "inproc")] +pub mod runner_inproc; pub use config::ServiceSpec; +#[cfg(feature = "inproc")] +pub use runner_inproc::InProcRunner; pub use operators::{ AvsOperator, EigenlayerMiddleware, EvmMiddleware, EvmMiddlewareType, MockEvmServiceManager, OperatorSet, PoaMiddleware, ANVIL_DEPLOYER_ADDRESS, ANVIL_DEPLOYER_KEY, diff --git a/packages/test-harness/src/service/runner_inproc.rs b/packages/test-harness/src/service/runner_inproc.rs new file mode 100644 index 000000000..fa111b13c --- /dev/null +++ b/packages/test-harness/src/service/runner_inproc.rs @@ -0,0 +1,264 @@ +//! In-process runner — executes operator and aggregator WASM components directly +//! against `wavs-engine`, without booting the full WAVS dispatcher. +//! +//! This is the **inproc** tier: the operator and aggregator run real WASM, but +//! trigger dispatch and submission orchestration are driven explicitly by the +//! test via [`lifecycle`](crate::lifecycle) helpers. Dispatcher subsystems +//! (trigger manager, submission manager, signing) are bypassed. +//! +//! Modelled after `packages/engine/tests/helpers/exec.rs`. Copied + adapted +//! rather than wrapped because that helper lives in `tests/` and has no `[lib]` +//! target. + +use std::collections::BTreeMap; + +use anyhow::{anyhow, Context, Result}; +use wasmtime::{component::Component as WasmtimeComponent, Config as WTConfig, Engine as WTEngine}; +use wavs_engine::{ + backend::wasi_keyvalue::context::KeyValueCtx, + bindings::operator::world::host::LogLevel, + utils::error::EngineError, + worlds::{ + aggregator::execute::{execute_input as agg_execute_input, AggregatorAction}, + instance::{HostComponentLogger, InstanceData, InstanceDepsBuilder}, + operator::execute::execute as op_execute, + }, +}; +use wavs_types::{ + AggregatorInput, AllowedHostPermission, Component, ComponentDigest, ComponentSource, EventId, + Permissions, Service, ServiceId, ServiceManager, ServiceStatus, SignatureKind, Submit, Trigger, + TriggerAction, TriggerConfig, TriggerData, WasmResponse, WorkflowId, +}; + +use crate::service::ServiceSpec; +use utils::storage::db::WavsDb; + +/// In-process runner. Holds the WASM bytes for the operator + aggregator +/// components and a synthetic [`Service`] used for engine wiring. +pub struct InProcRunner { + component_bytes: Vec, + aggregator_bytes: Vec, + config: BTreeMap, + service: Service, + workflow_id: WorkflowId, +} + +impl InProcRunner { + /// Build a runner from a validated [`ServiceSpec`]. Reads both WASM files into + /// memory. The synthetic [`Service`] uses [`Trigger::Manual`] — tests drive + /// trigger emission explicitly. + pub fn from_spec(spec: &ServiceSpec) -> Result { + spec.validate()?; + let component_path = spec + .component_wasm_path() + .ok_or_else(|| anyhow!("component_wasm not set"))?; + let aggregator_path = spec + .aggregator_wasm_path() + .ok_or_else(|| anyhow!("aggregator_wasm not set"))?; + let component_bytes = std::fs::read(component_path) + .with_context(|| format!("read component wasm {}", component_path.display()))?; + let aggregator_bytes = std::fs::read(aggregator_path) + .with_context(|| format!("read aggregator wasm {}", aggregator_path.display()))?; + let config = spec.config_var_map().clone(); + + let digest = ComponentDigest::hash(&component_bytes); + let (service, workflow_id) = synthetic_service(digest, config.clone()); + + Ok(Self { + component_bytes, + aggregator_bytes, + config, + service, + workflow_id, + }) + } + + pub fn service(&self) -> &Service { + &self.service + } + + pub fn service_id(&self) -> ServiceId { + self.service.id().clone() + } + + pub fn workflow_id(&self) -> &WorkflowId { + &self.workflow_id + } + + /// Execute the operator component once with the given raw input bytes. + /// Returns the list of payload bytes emitted by the component. + pub async fn run_component(&self, input: Vec) -> Result>> { + let engine = build_engine()?; + let trigger_action = TriggerAction { + config: TriggerConfig { + service_id: self.service.id().clone(), + workflow_id: self.workflow_id.clone(), + trigger: self + .service + .workflows + .values() + .next() + .unwrap() + .trigger + .clone(), + }, + data: TriggerData::Raw(input), + }; + + let data_dir = tempfile::tempdir()?; + let keyvalue_ctx = KeyValueCtx::new(WavsDb::new()?, "test".to_string()); + + let mut deps = InstanceDepsBuilder { + workflow_id: self.workflow_id.clone(), + service: self.service.clone(), + data: InstanceData::new_operator(trigger_action.data.clone()), + component: WasmtimeComponent::new(&engine, &self.component_bytes) + .map_err(|e| anyhow!("instantiate operator component: {e}"))?, + engine: &engine, + data_dir: data_dir.path().to_path_buf(), + chain_configs: &Default::default(), + log: HostComponentLogger::OperatorHostComponentLogger(log_host), + keyvalue_ctx, + } + .build() + .map_err(|e| anyhow!("build operator deps: {e}"))?; + + let responses = op_execute( + &mut deps, + trigger_action, + WasmResponse::DEFAULT_MAX_PAYLOAD_SIZE, + WasmResponse::DEFAULT_MAX_SALT_SIZE, + ) + .await + .map_err(map_engine_error)?; + + Ok(responses.into_iter().map(|r| r.payload).collect()) + } + + /// Execute the aggregator component once on a single packet input. + /// Returns the list of [`AggregatorAction`]s the aggregator emitted. + /// + /// `event_id` identifies the trigger event this aggregation is for — pass a + /// fresh id per logical event to avoid collisions with the aggregator's + /// internal de-dup state. + pub async fn run_aggregator( + &self, + event_id: EventId, + input: AggregatorInput, + ) -> Result> { + let engine = build_engine()?; + let data_dir = tempfile::tempdir()?; + let keyvalue_ctx = KeyValueCtx::new(WavsDb::new()?, "test-agg".to_string()); + + // The aggregator's synthetic service uses its own digest. + let agg_digest = ComponentDigest::hash(&self.aggregator_bytes); + let (agg_service, agg_wf) = synthetic_service(agg_digest, self.config.clone()); + + let mut deps = InstanceDepsBuilder { + workflow_id: agg_wf, + service: agg_service, + data: InstanceData::new_aggregator(event_id), + component: WasmtimeComponent::new(&engine, &self.aggregator_bytes) + .map_err(|e| anyhow!("instantiate aggregator component: {e}"))?, + engine: &engine, + data_dir: data_dir.path().to_path_buf(), + chain_configs: &Default::default(), + log: HostComponentLogger::AggregatorHostComponentLogger(log_host_agg), + keyvalue_ctx, + } + .build() + .map_err(|e| anyhow!("build aggregator deps: {e}"))?; + + agg_execute_input(&mut deps, input) + .await + .map_err(map_engine_error) + } +} + +fn build_engine() -> Result { + let mut cfg = WTConfig::new(); + cfg.wasm_component_model(true); + cfg.consume_fuel(true); + WTEngine::new(&cfg).map_err(|e| anyhow!("build wasmtime engine: {e}")) +} + +fn map_engine_error(e: EngineError) -> anyhow::Error { + match e { + EngineError::ExecResult(s) => anyhow!("component execution failed: {s}"), + other => anyhow!("engine error: {other}"), + } +} + +fn synthetic_service( + digest: ComponentDigest, + config: BTreeMap, +) -> (Service, WorkflowId) { + use wavs_types::Workflow; + let workflow_id = WorkflowId::new("test-workflow").unwrap(); + let component = Component { + source: ComponentSource::Digest(digest), + permissions: Permissions { + allowed_http_hosts: AllowedHostPermission::All, + file_system: true, + raw_sockets: true, + dns_resolution: true, + }, + fuel_limit: None, + time_limit_seconds: None, + config, + env_keys: Default::default(), + }; + let workflow = Workflow { + trigger: Trigger::Manual, + component: component.clone(), + submit: Submit::Aggregator { + component: Box::new(component), + signature_kind: SignatureKind::evm_default(), + }, + }; + let service = Service { + name: "wavs-test-harness synthetic".to_string(), + workflows: BTreeMap::from([(workflow_id.clone(), workflow)]), + status: ServiceStatus::Active, + manager: ServiceManager::Evm { + chain: "evm:test".parse().unwrap(), + address: Default::default(), + }, + }; + (service, workflow_id) +} + +fn log_host( + service_id: &ServiceId, + workflow_id: &WorkflowId, + digest: &ComponentDigest, + level: LogLevel, + message: String, +) { + let line = format!("[{service_id}:{workflow_id}:{digest}] {message}"); + match level { + LogLevel::Error => tracing::error!(target: "wavs_test_harness::component", "{line}"), + LogLevel::Warn => tracing::warn!(target: "wavs_test_harness::component", "{line}"), + LogLevel::Info => tracing::info!(target: "wavs_test_harness::component", "{line}"), + LogLevel::Debug => tracing::debug!(target: "wavs_test_harness::component", "{line}"), + LogLevel::Trace => tracing::trace!(target: "wavs_test_harness::component", "{line}"), + } +} + +fn log_host_agg( + service_id: &ServiceId, + workflow_id: &WorkflowId, + digest: &ComponentDigest, + level: wavs_engine::bindings::aggregator::world::host::LogLevel, + message: String, +) { + use wavs_engine::bindings::aggregator::world::host::LogLevel as AL; + let line = format!("[{service_id}:{workflow_id}:{digest}] {message}"); + match level { + AL::Error => tracing::error!(target: "wavs_test_harness::aggregator", "{line}"), + AL::Warn => tracing::warn!(target: "wavs_test_harness::aggregator", "{line}"), + AL::Info => tracing::info!(target: "wavs_test_harness::aggregator", "{line}"), + AL::Debug => tracing::debug!(target: "wavs_test_harness::aggregator", "{line}"), + AL::Trace => tracing::trace!(target: "wavs_test_harness::aggregator", "{line}"), + } +} diff --git a/packages/test-harness/tests/inproc_smoke.rs b/packages/test-harness/tests/inproc_smoke.rs new file mode 100644 index 000000000..ac85edc95 --- /dev/null +++ b/packages/test-harness/tests/inproc_smoke.rs @@ -0,0 +1,58 @@ +//! In-process runner smoke test using a bundled example component. +//! +//! Runs `examples/build/components/echo_data.wasm` once via [`InProcRunner`]. +//! Skips gracefully if the example artifact is missing (e.g. on a fresh +//! checkout that hasn't run `just wasi-build-native` yet). + +use std::path::PathBuf; + +use wavs_test_harness::service::{InProcRunner, ServiceSpec}; + +fn example_wasm(name: &str) -> PathBuf { + let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/build/components") + .join(name); + p.canonicalize().unwrap_or(p) +} + +#[tokio::test] +async fn echo_data_round_trip() { + let component = example_wasm("echo_data.wasm"); + let aggregator = example_wasm("simple_aggregator.wasm"); + + if !component.exists() { + eprintln!( + "[skipping] {} not found — run `just wasi-build-native` to build example components", + component.display() + ); + return; + } + if !aggregator.exists() { + eprintln!( + "[skipping] {} not found — run `just wasi-build-native` to build example components", + aggregator.display() + ); + return; + } + + let spec = ServiceSpec::new() + .component_wasm(&component) + .aggregator_wasm(&aggregator) + .operator_count(1); + + let runner = InProcRunner::from_spec(&spec).expect("build runner"); + + // echo_data wraps its input in a DataWithId envelope; for the harness we + // only care that the component executed and produced *some* payload. + let input = wavs_test_harness::lifecycle::manual_input_json(&serde_json::json!({ + "id": 1, + "data": "hello-from-harness" + })) + .unwrap(); + + let outputs = runner.run_component(input).await.expect("run component"); + assert!( + !outputs.is_empty(), + "echo_data should emit at least one payload" + ); +} From 21bbd1c7ab7c87214c89766d84cf0f1ff4ffdf71 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 03:30:41 +0000 Subject: [PATCH 06/19] feat(test-harness): canonical envelope + ECDSA signing helpers (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the `IWavsServiceHandler` ABI from `@wavs/solidity@0.6.x`: struct Envelope { bytes20 eventId; bytes12 ordering; bytes payload; } struct SignatureData { address[] signers; bytes[] signatures; uint32 referenceBlock; } Generated via `alloy-sol-types::sol!` so the ABI encoding matches the on-chain layout bit-for-bit. The on-chain validator (`WavsServiceManager.validate`) computes: message = keccak256(abi.encode(envelope)) ethSignedMessageHash = toEthSignedMessageHash(message) then verifies each signature against the stake registry. The harness mirrors this via `Envelope::message_hash` + `Envelope::signing_hash` (the latter applies the EIP-191 personal-sign prefix via `alloy_primitives::eip191_hash_message`). Public helpers: - `Envelope::new(event_id, payload)` — empty `ordering` (reserved; handlers ignore it today). - `Envelope::message_hash`, `Envelope::signing_hash`. - `sign_envelope(&envelope, &[signer], reference_block)` — returns a `SignatureData` ready to pass to `handleSignedEnvelope`. - `event_id_from_seed(B256)` / `event_id_from_nonce(u64)` — convenience helpers for the 20-byte event id. End-to-end verification against the real on-chain validator is deferred to Step 9 (the wavs-defi PoC port), which will accept this envelope on the real `SmartVaultServiceHandler`. Verification: 4 new unit tests pass — abi-encode round trip, signing-hash ≠ message-hash (EIP-191 prefix applied), recoverable 65-byte ECDSA signature, empty-signer error. Refs Lay3rLabs/WAVS#1147. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/test-harness/src/envelope/mod.rs | 170 +++++++++++++++++++++- 1 file changed, 168 insertions(+), 2 deletions(-) diff --git a/packages/test-harness/src/envelope/mod.rs b/packages/test-harness/src/envelope/mod.rs index 3dbb8318b..be81dcef4 100644 --- a/packages/test-harness/src/envelope/mod.rs +++ b/packages/test-harness/src/envelope/mod.rs @@ -1,3 +1,169 @@ -//! Signed-envelope helpers verified against downstream service handlers. +//! Signed-envelope helpers verified against the canonical WAVS handler shape. //! -//! Submodules land in Step 6. +//! The handler ABI is defined by `IWavsServiceHandler` from `@wavs/solidity`: +//! +//! ```solidity +//! struct Envelope { bytes20 eventId; bytes12 ordering; bytes payload; } +//! struct SignatureData { address[] signers; bytes[] signatures; uint32 referenceBlock; } +//! ``` +//! +//! The on-chain validator (`WavsServiceManager.validate`) computes +//! `message = keccak256(abi.encode(envelope))`, applies the standard +//! `\x19Ethereum Signed Message:\n32` prefix, and checks each operator +//! signature against the resolved stake registry. +//! +//! This module mirrors that exact shape so harness-produced envelopes verify on +//! real handlers without modification. The Solidity sources of truth are bundled +//! with downstream apps under `@wavs/solidity@0.6.x`. + +use alloy_primitives::{eip191_hash_message, keccak256, Address, FixedBytes, B256, U256}; +use alloy_signer::SignerSync; +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::{sol, SolValue}; +use anyhow::{anyhow, Result}; + +sol! { + /// The canonical envelope shape that all WAVS service handlers verify. + #[derive(Debug)] + struct Envelope { + bytes20 eventId; + bytes12 ordering; + bytes payload; + } + + /// The canonical signature data accompanying every envelope. + #[derive(Debug)] + struct SignatureData { + address[] signers; + bytes[] signatures; + uint32 referenceBlock; + } +} + +impl Envelope { + /// Build an envelope with empty ordering padding (the field is reserved for + /// future ordering semantics — handlers ignore it today). + pub fn new(event_id: [u8; 20], payload: impl Into>) -> Self { + Self { + eventId: FixedBytes::from(event_id), + ordering: FixedBytes::default(), + payload: payload.into().into(), + } + } + + /// Compute the keccak256(abi.encode(envelope)) digest the on-chain validator + /// uses as input to the EIP-191 personal-sign prefix. + pub fn message_hash(&self) -> B256 { + keccak256(self.abi_encode()) + } + + /// Compute the EIP-191 `\x19Ethereum Signed Message:\n32` digest the operator + /// signers actually sign. + pub fn signing_hash(&self) -> B256 { + eip191_hash_message(self.message_hash()) + } +} + +/// Sign an envelope with a set of operator signers and produce the matching +/// [`SignatureData`]. +/// +/// `reference_block` must be strictly less than `block.number` at validation time; +/// callers typically pass the current block number minus one. +pub fn sign_envelope( + envelope: &Envelope, + signers: &[PrivateKeySigner], + reference_block: u32, +) -> Result { + if signers.is_empty() { + return Err(anyhow!("at least one signer required")); + } + let hash = envelope.signing_hash(); + let mut signer_addrs = Vec::with_capacity(signers.len()); + let mut sigs = Vec::with_capacity(signers.len()); + for s in signers { + let sig = s + .sign_hash_sync(&hash) + .map_err(|e| anyhow!("sign envelope: {e}"))?; + signer_addrs.push(s.address()); + sigs.push(sig.as_bytes().to_vec().into()); + } + Ok(SignatureData { + signers: signer_addrs, + signatures: sigs, + referenceBlock: reference_block, + }) +} + +/// Build a 20-byte event id from a 32-byte seed (typically a transaction hash or +/// content-addressed id). Takes the low 20 bytes — matches WAVS' convention. +pub fn event_id_from_seed(seed: B256) -> [u8; 20] { + let mut out = [0u8; 20]; + out.copy_from_slice(&seed.as_slice()[12..]); + out +} + +/// Build a 20-byte event id from a `u64` nonce. Useful in tests where the +/// envelope sequence is driven by the test rather than chain state. +pub fn event_id_from_nonce(nonce: u64) -> [u8; 20] { + let mut seed = [0u8; 32]; + seed[24..].copy_from_slice(&nonce.to_be_bytes()); + event_id_from_seed(B256::from(seed)) +} + +/// Convenience: u256 abi-encoded payload (rare — most apps have a struct payload). +pub fn encode_u256_payload(v: U256) -> Vec { + v.abi_encode() +} + +/// Returns the resolved signer address for a signer (useful when wiring into +/// the operator registry). +pub fn signer_address(signer: &PrivateKeySigner) -> Address { + signer.address() +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::Bytes; + + #[test] + fn envelope_round_trip_abi_encode() { + let event_id = event_id_from_nonce(42); + let payload: Vec = vec![0xde, 0xad, 0xbe, 0xef]; + let env = Envelope::new(event_id, payload.clone()); + + // The encoded envelope should embed the payload and event id. + let encoded = env.abi_encode(); + assert!(encoded.len() > 32 + 12 + payload.len()); + } + + #[test] + fn signing_hash_differs_from_message_hash() { + let env = Envelope::new(event_id_from_nonce(1), vec![1, 2, 3]); + let msg = env.message_hash(); + let signed = env.signing_hash(); + assert_ne!(msg, signed, "EIP-191 prefix must change the digest"); + } + + #[test] + fn sign_envelope_produces_recoverable_signature() { + let signer = PrivateKeySigner::random(); + let env = Envelope::new(event_id_from_nonce(7), vec![0xaa]); + let sigdata = sign_envelope(&env, &[signer.clone()], 0).unwrap(); + + assert_eq!(sigdata.signers.len(), 1); + assert_eq!(sigdata.signers[0], signer.address()); + assert_eq!(sigdata.signatures.len(), 1); + + // Sanity: signature is 65 bytes (r || s || v). + let sig_bytes: &Bytes = &sigdata.signatures[0]; + assert_eq!(sig_bytes.len(), 65); + } + + #[test] + fn empty_signer_list_errors() { + let env = Envelope::new(event_id_from_nonce(1), vec![]); + let res = sign_envelope(&env, &[], 0); + assert!(res.is_err()); + } +} From 978f20159ae55a2ceac89b2ae9a65a1b8572568d Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 03:33:04 +0000 Subject: [PATCH 07/19] feat(test-harness): subprocess runner preview shape (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks the API surface for the subprocess tier so consumers can write tests against it; the lifecycle implementation is deferred to a follow-up. What ships: - `service::SubprocessConfig` — fluent builder for `wavs_binary`, `rpc_url`, `data_dir`, and `env(key, value)` extras. - `service::SubprocessRunner::new(config, spec)` — validates the spec. - `SubprocessRunner::start()` — returns a descriptive `unimplemented` error directing callers to `InProcRunner` or the follow-up issue. Gated on the `subprocess` feature, which stays off by default; downstream consumers explicitly opt in. Module docs enumerate the planned wiring: `wavs --dev-endpoints-enabled=true ...` spawn, HTTP health probe, service registration via `/services`, trigger emission via dev-tool's `send-triggers`, quorum/submission HTTP polling, SIGINT shutdown. Verification: `cargo test -p wavs-test-harness --features subprocess` passes including the new `config_builder_round_trip` test. Refs Lay3rLabs/WAVS#1147. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/test-harness/src/service/mod.rs | 4 + .../src/service/runner_subprocess.rs | 137 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 packages/test-harness/src/service/runner_subprocess.rs diff --git a/packages/test-harness/src/service/mod.rs b/packages/test-harness/src/service/mod.rs index 4106c6b6a..f249a7172 100644 --- a/packages/test-harness/src/service/mod.rs +++ b/packages/test-harness/src/service/mod.rs @@ -8,10 +8,14 @@ pub mod config; pub mod operators; #[cfg(feature = "inproc")] pub mod runner_inproc; +#[cfg(feature = "subprocess")] +pub mod runner_subprocess; pub use config::ServiceSpec; #[cfg(feature = "inproc")] pub use runner_inproc::InProcRunner; +#[cfg(feature = "subprocess")] +pub use runner_subprocess::{SubprocessConfig, SubprocessRunner}; pub use operators::{ AvsOperator, EigenlayerMiddleware, EvmMiddleware, EvmMiddlewareType, MockEvmServiceManager, OperatorSet, PoaMiddleware, ANVIL_DEPLOYER_ADDRESS, ANVIL_DEPLOYER_KEY, diff --git a/packages/test-harness/src/service/runner_subprocess.rs b/packages/test-harness/src/service/runner_subprocess.rs new file mode 100644 index 000000000..b134f4abf --- /dev/null +++ b/packages/test-harness/src/service/runner_subprocess.rs @@ -0,0 +1,137 @@ +//! Subprocess runner — spawns the real `wavs` binary as a child process. +//! +//! **Preview status.** The subprocess tier ships in this release with API shape +//! locked but lifecycle methods stubbed. The intent is that consumers can write +//! their test harness against this surface and the implementation can be filled +//! in without breaking their tests. +//! +//! What ships now: +//! - [`SubprocessConfig`] — full builder for the inputs the runner needs. +//! - [`SubprocessRunner::start`] — returns a descriptive `unimplemented` error. +//! +//! What's planned (tracked under Lay3rLabs/WAVS#1147 follow-ups): +//! - Spawn `wavs` with `--dev-endpoints-enabled=true +//! --disable-trigger-networking=true --disable-submission-networking=true`, +//! piping `WAVS_HOME` and `WAVS_DATA` to a tempdir. +//! - HTTP health-probe loop until the node accepts service registrations. +//! - Service deployment via the HTTP `/services` endpoint. +//! - Trigger emission via dev-tool's `send-triggers` path. +//! - Quorum + submission waiting via HTTP polling. +//! - Clean shutdown via SIGINT + tempdir cleanup on Drop. +//! +//! Tests that need real end-to-end coverage should use [`super::InProcRunner`] +//! for now. Tests can be authored against this API; today they'll surface the +//! preview error message. + +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Result}; + +use crate::service::ServiceSpec; + +/// Configuration for the subprocess WAVS runner. +#[derive(Debug, Clone, Default)] +pub struct SubprocessConfig { + wavs_binary: Option, + rpc_url: Option, + data_dir: Option, + extra_env: Vec<(String, String)>, +} + +impl SubprocessConfig { + /// Empty config — fill via the builder methods. + pub fn new() -> Self { + Self::default() + } + + /// Path to the `wavs` binary. If unset, the runner attempts to locate it via + /// `$PATH` or by building it from the WAVS repo (preview behavior). + pub fn wavs_binary(mut self, path: impl AsRef) -> Self { + self.wavs_binary = Some(path.as_ref().to_path_buf()); + self + } + + /// EVM RPC URL the WAVS node connects to (Anvil endpoint or fork URL). + pub fn rpc_url(mut self, url: impl Into) -> Self { + self.rpc_url = Some(url.into()); + self + } + + /// Directory the WAVS node uses for `WAVS_DATA`. Defaults to a tempdir. + pub fn data_dir(mut self, path: impl AsRef) -> Self { + self.data_dir = Some(path.as_ref().to_path_buf()); + self + } + + /// Add a `(KEY, VALUE)` pair to the spawned process environment. + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.extra_env.push((key.into(), value.into())); + self + } + + pub fn wavs_binary_path(&self) -> Option<&Path> { + self.wavs_binary.as_deref() + } + + pub fn rpc_url_value(&self) -> Option<&str> { + self.rpc_url.as_deref() + } + + pub fn data_dir_value(&self) -> Option<&Path> { + self.data_dir.as_deref() + } + + pub fn extra_env_pairs(&self) -> &[(String, String)] { + &self.extra_env + } +} + +/// Subprocess runner handle. +/// +/// **Preview**: `start` returns an explicit `unimplemented` error today. +pub struct SubprocessRunner { + #[allow(dead_code)] + config: SubprocessConfig, + #[allow(dead_code)] + spec: ServiceSpec, +} + +impl SubprocessRunner { + /// Build a subprocess runner from a config and service spec. + pub fn new(config: SubprocessConfig, spec: ServiceSpec) -> Result { + spec.validate()?; + Ok(Self { config, spec }) + } + + /// Start the WAVS subprocess. + /// + /// **Preview**: Not yet implemented. Returns an error directing callers to + /// either use [`super::InProcRunner`] or track the follow-up issue. + pub async fn start(&self) -> Result<()> { + Err(anyhow!( + "subprocess tier is preview — use InProcRunner for now; \ + see packages/test-harness/src/service/runner_subprocess.rs and \ + Lay3rLabs/WAVS#1147 follow-ups for the planned implementation" + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_builder_round_trip() { + let c = SubprocessConfig::new() + .wavs_binary("/usr/local/bin/wavs") + .rpc_url("http://127.0.0.1:8545") + .data_dir("/tmp/wavs-test") + .env("WAVS_LOG", "debug"); + assert_eq!( + c.wavs_binary_path().map(|p| p.to_string_lossy().to_string()), + Some("/usr/local/bin/wavs".to_string()) + ); + assert_eq!(c.rpc_url_value(), Some("http://127.0.0.1:8545")); + assert_eq!(c.extra_env_pairs().len(), 1); + } +} From 87207c0fe1150c619e0bd24a4a01921581928993 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 03:35:46 +0000 Subject: [PATCH 08/19] feat(test-harness): TestHarness builder + working examples + justfile target (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the top-level convenience wrapper plus two runnable examples: - `harness::TestHarness

` — bundles `provider`, `AnvilInstance`, and an `InProcRunner`. Convenience methods: `mine_blocks`, `snapshot`. Generic over the provider type so it works with both local-Anvil and fork providers. The held `AnvilInstance` reaps the subprocess on Drop. - `examples/minimal_local.rs` — spawns local Anvil, runs `echo_data.wasm` once via the runner, demonstrates snapshot/revert. Verified end-to-end: produces 1 payload of 36 bytes, snapshot revert succeeds. - `examples/fork_with_addresses.rs` — loads the bundled `base` profile, resolves FORK_RPC_URL with redacted logging, demonstrates typed address lookup. Skips gracefully if the env var is unset. `justfile` additions: - `just test-harness` — deterministic local tier (no FORK_RPC_URL needed). - `just test-harness-fork` — pinned-fork tier. Verification: `cargo run -p wavs-test-harness --example minimal_local` produces the expected end-to-end output. `cargo build -p wavs-test-harness --examples` succeeds. Refs Lay3rLabs/WAVS#1147. Co-Authored-By: Claude Opus 4.7 (1M context) --- justfile | 8 +++ .../examples/fork_with_addresses.rs | 46 ++++++++++++ .../test-harness/examples/minimal_local.rs | 70 +++++++++++++++++++ packages/test-harness/src/harness.rs | 61 ++++++++++++++++ packages/test-harness/src/lib.rs | 4 ++ 5 files changed, 189 insertions(+) create mode 100644 packages/test-harness/examples/fork_with_addresses.rs create mode 100644 packages/test-harness/examples/minimal_local.rs create mode 100644 packages/test-harness/src/harness.rs diff --git a/justfile b/justfile index 6d0d959fa..91c76dd3e 100644 --- a/justfile +++ b/justfile @@ -181,6 +181,14 @@ cosmwasm-build-inner CONTRACT_PATH: test-wavs-e2e: ulimit -n 65536 && RUST_LOG=debug,alloy_rpc=off,alloy_provider=off,wasmtime=off,cranelift=off,hyper_util=off cargo test -p layer-tests +# reusable integration test harness — local Anvil + in-process runner (deterministic, no fork required) +test-harness: + cargo test -p wavs-test-harness + +# reusable integration test harness — pinned-fork tier (requires FORK_RPC_URL) +test-harness-fork: + cargo test -p wavs-test-harness --features fork + update-submodules: git submodule update --init --recursive diff --git a/packages/test-harness/examples/fork_with_addresses.rs b/packages/test-harness/examples/fork_with_addresses.rs new file mode 100644 index 000000000..cdb49c28a --- /dev/null +++ b/packages/test-harness/examples/fork_with_addresses.rs @@ -0,0 +1,46 @@ +//! Fork-tier example — load the Base profile, log addresses, never leak the RPC URL. +//! +//! Run with: +//! `FORK_RPC_URL= cargo run -p wavs-test-harness \ +//! --example fork_with_addresses --features fork` +//! +//! This example does NOT spawn the fork — it only demonstrates loading the +//! profile, redacted logging, and address lookup. Spawning is a one-liner via +//! `chain::spawn_fork(ForkOptions::from_env(...))`. + +use wavs_test_harness::{chain, fixtures::ChainProfile}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt::init(); + + let profile = ChainProfile::load("base")?; + println!("[example] profile: name={} chain_id={}", + profile.chain.name, profile.chain.chain_id); + + // The RPC URL is read from FORK_RPC_URL and never printed verbatim. + match profile.resolve_rpc_url() { + Ok(Some(url)) => println!( + "[example] fork RPC ready (redacted): {}", + chain::redact_url(&url) + ), + Ok(None) => println!("[example] profile does not declare an rpc_env"), + Err(e) => { + eprintln!("[example] FORK_RPC_URL unset: {e}"); + eprintln!("[example] set it and re-run to actually spawn the fork"); + return Ok(()); + } + } + + // Address lookup demo. + let usdc = profile.address("usdc")?; + let weth = profile.address("weth")?; + let avantis = profile.address("avantis_trading")?; + let whale = profile.accounts.address("usdc_whale")?; + println!("[example] USDC = {usdc}"); + println!("[example] WETH = {weth}"); + println!("[example] Avantis Trade = {avantis}"); + println!("[example] USDC whale = {whale}"); + + Ok(()) +} diff --git a/packages/test-harness/examples/minimal_local.rs b/packages/test-harness/examples/minimal_local.rs new file mode 100644 index 000000000..18eb558c2 --- /dev/null +++ b/packages/test-harness/examples/minimal_local.rs @@ -0,0 +1,70 @@ +//! Minimal local example — spawn Anvil, run an example component once. +//! +//! Run with: `cargo run -p wavs-test-harness --example minimal_local` +//! Prerequisite: `just wasi-build-native echo_data simple_aggregator` + +use std::path::PathBuf; + +use wavs_test_harness::{ + chain, + service::{InProcRunner, ServiceSpec}, + TestHarness, +}; + +fn example_wasm(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/build/components") + .join(name) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt::init(); + + let component = example_wasm("echo_data.wasm"); + let aggregator = example_wasm("simple_aggregator.wasm"); + if !component.exists() || !aggregator.exists() { + eprintln!( + "Missing example WASM. Run `just wasi-build-native` from the WAVS repo root." + ); + return Ok(()); + } + + let spec = ServiceSpec::new() + .component_wasm(&component) + .aggregator_wasm(&aggregator) + .config_var("DEMO_KEY", "demo-value") + .operator_count(1); + + let (provider, anvil) = chain::spawn_local().await?; + let harness = TestHarness::new(provider, anvil, &spec)?; + println!( + "[example] Anvil at endpoint {}", + chain::redact_url(&harness.anvil.endpoint()) + ); + + // Drive a single component execution. + let input = wavs_test_harness::lifecycle::manual_input_json(&serde_json::json!({ + "id": 1, + "data": "hello-from-example" + }))?; + let outputs = harness.runner.run_component(input).await?; + println!( + "[example] component emitted {} payload(s), first {} bytes", + outputs.len(), + outputs.first().map(|v| v.len()).unwrap_or(0) + ); + + // Block-time control demo. + harness.mine_blocks(3).await?; + + // Snapshot / revert demo. + let snap = harness.snapshot().await?; + harness.mine_blocks(5).await?; + snap.revert(&harness.provider).await?; + println!("[example] snapshot revert verified"); + + // Belt-and-suspenders: keep clippy happy about unused runner field. + drop(InProcRunner::from_spec(&spec)?); + Ok(()) +} diff --git a/packages/test-harness/src/harness.rs b/packages/test-harness/src/harness.rs new file mode 100644 index 000000000..e0a7310ad --- /dev/null +++ b/packages/test-harness/src/harness.rs @@ -0,0 +1,61 @@ +//! Top-level [`TestHarness`] — bundles a chain provider, Anvil instance, and an +//! [`InProcRunner`] into one value tests can pass around. +//! +//! For maximum flexibility, prefer composing the primitives directly: +//! +//! ```ignore +//! use wavs_test_harness::{chain, service::{InProcRunner, ServiceSpec}}; +//! +//! let (provider, anvil) = chain::spawn_local().await?; +//! let runner = InProcRunner::from_spec(&ServiceSpec::new() +//! .component_wasm("examples/build/components/echo_data.wasm") +//! .aggregator_wasm("examples/build/components/simple_aggregator.wasm"))?; +//! let outputs = runner.run_component(b"hello".to_vec()).await?; +//! ``` +//! +//! The harness adds a convenience constructor that ties them together. Apps that +//! want more control should reach for the underlying primitives. + +use alloy_network::Ethereum; +use alloy_node_bindings::AnvilInstance; +use alloy_provider::{ext::AnvilApi, Provider}; +use anyhow::Result; + +#[cfg(feature = "inproc")] +use crate::service::{InProcRunner, ServiceSpec}; + +/// One-stop value bundling chain state and the in-process runner. +/// +/// Drop semantics: the held [`AnvilInstance`] kills the underlying Anvil +/// subprocess when the harness goes out of scope. +#[cfg(feature = "inproc")] +pub struct TestHarness + Clone> { + pub provider: P, + pub anvil: AnvilInstance, + pub runner: InProcRunner, +} + +#[cfg(feature = "inproc")] +impl + Clone> TestHarness

{ + /// Build a harness from already-spawned chain primitives and a validated spec. + pub fn new(provider: P, anvil: AnvilInstance, spec: &ServiceSpec) -> Result { + let runner = InProcRunner::from_spec(spec)?; + Ok(Self { + provider, + anvil, + runner, + }) + } + + /// Mine `count` blocks on the underlying chain. Delegates to + /// [`crate::chain::mine_blocks`]. + pub async fn mine_blocks(&self, count: u64) -> Result<()> { + crate::chain::mine_blocks(&self.provider, count).await + } + + /// Take a snapshot of chain state. The returned guard reverts on + /// explicit `.revert(provider)`, or warns on `Drop`. + pub async fn snapshot(&self) -> Result { + crate::chain::SnapshotGuard::take(&self.provider).await + } +} diff --git a/packages/test-harness/src/lib.rs b/packages/test-harness/src/lib.rs index e5d3203a0..3645a2794 100644 --- a/packages/test-harness/src/lib.rs +++ b/packages/test-harness/src/lib.rs @@ -18,5 +18,9 @@ pub mod chain; pub mod envelope; pub mod fixtures; +pub mod harness; pub mod lifecycle; pub mod service; + +#[cfg(feature = "inproc")] +pub use harness::TestHarness; From d416e676dfdc0c4c576469845c5c097afeaf6e04 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 03:45:12 +0000 Subject: [PATCH 09/19] docs(test-harness): comprehensive README with tier matrix + CI guidance (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the placeholder scaffold README with: - **Quickstart** sections for local Anvil and pinned fork tiers, with full copy-pasteable code. - **Tier matrix** (InProc / Subprocess) and **CI tiers** (PR deterministic / PR labeled fork / nightly / pre-release subprocess) tables. - **Determinism boundary** — block-time, snapshot/revert, operator signing. - **Bundled chain profiles** — what local/base/mainnet ship with. - **Cross-repo alloy version barrier** — documents the real-world version conflict that surfaced when wiring the wavs-defi PoC, with three resolution paths. - **Layer-tests relationship** — declares wavs-test-harness canonical for new integration tests, layer-tests legacy with migration tracked as a follow-up. - Complete v1-ships vs not-in-v1 lists so consumers know what they're getting. Refs Lay3rLabs/WAVS#1147. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/test-harness/README.md | 175 ++++++++++++++++++++++++++++---- 1 file changed, 155 insertions(+), 20 deletions(-) diff --git a/packages/test-harness/README.md b/packages/test-harness/README.md index 57bce02ea..8d4968f13 100644 --- a/packages/test-harness/README.md +++ b/packages/test-harness/README.md @@ -4,46 +4,181 @@ Reusable integration test harness for WAVS apps. Tracks issue [Lay3rLabs/WAVS#1147](https://github.com/Lay3rLabs/WAVS/issues/1147). -## Status +## What this crate is for -Scaffold. Only the module layout exists today. See `src/lib.rs` for the planned -surface area. Each step in the plan delivers one module. +Downstream app repos — `wavs-defi`, `wavs-aave-guardian`, `wavs-prediction-market`, +and the rest — write integration tests that drive the full WAVS path: -## What this crate is for +``` +trigger → operator component → aggregator (sig + quorum) → service handler → app contract +``` + +`wavs-test-harness` provides chain control, service-lifecycle, envelope +helpers, and waiters so each downstream test only has to specify its +app-specific contract deployment and assertions. + +## Quickstart — local Anvil + +```rust +use wavs_test_harness::{chain, service::{InProcRunner, ServiceSpec}}; + +let (provider, anvil) = chain::spawn_local().await?; -Downstream app repos — for example `wavs-defi`, `wavs-aave-guardian`, -`wavs-prediction-market` — write integration tests that drive the full WAVS path: +let spec = ServiceSpec::new() + .component_wasm("path/to/strategy.wasm") + .aggregator_wasm("path/to/aggregator.wasm") + .config_var("CHAINLINK_ETH_USD", chainlink.to_string()); +let runner = InProcRunner::from_spec(&spec)?; +let outputs = runner.run_component(b"hello".to_vec()).await?; ``` -trigger -> operator component -> aggregator (sig + quorum) -> service handler -> app contract + +Two runnable examples ship with the crate: + +```bash +cargo run -p wavs-test-harness --example minimal_local +FORK_RPC_URL= cargo run -p wavs-test-harness --example fork_with_addresses +``` + +## Quickstart — pinned fork + +```rust +use wavs_test_harness::{chain, fixtures::ChainProfile}; + +let profile = ChainProfile::load("base")?; // chain_id + addresses + rpc_env +let opts = chain::ForkOptions { + rpc_url: profile.resolve_rpc_url()?, // reads FORK_RPC_URL + block_number: profile.chain.fork_block, + timeout_ms: None, +}; +let (provider, _anvil) = chain::spawn_fork(opts).await?; + +let usdc = profile.address("usdc")?; +let weth = profile.address("weth")?; +let whale = profile.accounts.address("usdc_whale")?; + +chain::impersonate_funded(&provider, whale).await?; +// … your test … +chain::stop_impersonating(&provider, whale).await?; ``` -`wavs-test-harness` provides the chain control, service-lifecycle, and assertion -primitives so each downstream test only has to specify its app-specific -deployment and assertions. +The RPC URL is never logged verbatim — only via [`chain::redact_url`]. -## Tier matrix (planned) +## Tier matrix | Tier | Stages run | Stages mocked | Use case | |---|---|---|---| -| **Logic** | trigger + contract calls | compute/aggregate/submit | unit-level vault tests with mock signatures | -| **InProc** (default) | compute (real WASM) + aggregate (in-memory quorum) + submit | dispatcher internals | deterministic PR tier on local Anvil | +| **InProc** (default) | compute (real WASM via `wavs-engine`) + aggregate (real WASM) + envelope signing | dispatcher subsystems | deterministic PR tier on local Anvil or fork | | **Subprocess** (preview) | everything in the real `wavs` binary | nothing | nightly fork + pre-release | -## Tier selection +InProc executes the actual operator and aggregator WASM directly through +`wavs-engine`, bypassing the full dispatcher (trigger manager, submission +manager, signing). It's fast, deterministic, and exercises the same WASM +code path that runs in production. -Add `wavs-test-harness` as a `[dev-dependencies]` entry only, never as a regular -dependency. This keeps `utils/test-utils` out of production builds. +Subprocess ships in this release with API shape locked but lifecycle methods +stubbed — calling `start()` returns a descriptive `unimplemented` error. +See `src/service/runner_subprocess.rs` for the planned wiring. + +## CI tiers + +| Tier | Runner | When | Command | +|---|---|---|---| +| **PR deterministic** | local Anvil only | every PR | `just test-harness` | +| **PR labeled fork** | pinned Base / mainnet fork | PRs labeled `fork-tests` | `just test-harness-fork` | +| **Nightly fork matrix** | broader protocol matrix | scheduled | TBD — wired alongside Subprocess landing | +| **Pre-release subprocess** | full `wavs` binary | release branches | TBD — depends on Subprocess landing | + +Fork tier requires `FORK_RPC_URL` set in the CI secret store. The harness +never logs the URL; the secret should be scoped to fork-tier jobs only. + +## Bundled chain profiles + +Three profiles ship via `include_str!`: + +- `local.toml` — `chain_id=31337`, no fork, no env vars. +- `base.toml` — `chain_id=8453`, `FORK_RPC_URL`, complete protocol address set + (Aerodrome pool / NFT manager / router, Avantis trading / storage / gov, + Chainlink, Pyth, USDC, WETH, USDC whale, Avantis operator). +- `mainnet.toml` — `chain_id=1`, `FORK_RPC_URL`, tokens + Aave v3 pool. + +Consumers can ship their own profiles via `ChainProfile::from_path(...)` +or `ChainProfile::from_str(...)`. + +## Determinism boundary + +- **Block-time control.** `chain::set_automine(false)` + `chain::mine_blocks(n)` + gives deterministic block production. Pair with `chain::set_next_block_timestamp` + for tests that depend on block timestamps. +- **Snapshot / revert.** `chain::SnapshotGuard::take(&provider)` captures + state; explicit `.revert(&provider)` rolls back. The guard logs a warning + if dropped without explicit revert (async-drop is unstable). +- **Operator signing.** All envelope signatures are deterministic for a given + signer key. Use the same operator set across re-runs to keep signature + bytes stable. + +## Tier selection in downstream repos + +Add `wavs-test-harness` as a `[dev-dependencies]` entry only, never as a +regular dependency. This keeps `utils/test-utils` out of production builds. ```toml [dev-dependencies] wavs-test-harness = { git = "https://github.com/Lay3rLabs/WAVS", rev = "" } ``` -Branch references are not supported in CI — pin to a commit SHA. +**Branch references are not supported in CI — pin to a commit SHA.** + +### Cross-repo alloy version barrier + +When the downstream repo uses a different alloy major version than the WAVS +workspace pin (`1.0.42` at the time of writing), cargo workspace feature +unification can produce compile errors in `alloy-rpc-types-eth` (e.g. +`BlobTransactionSidecarVariant` vs `BlobTransactionSidecar` mismatch). +Resolution paths: + +1. Bump alloy in WAVS to match the downstream consumer. +2. Pin the downstream test crate to the WAVS alloy version. +3. Publish `wavs-test-harness` as a versioned crate so cargo handles + version-mixing instead of unification. + +Tracked as the highest-priority follow-up under #1147. ## Layer-tests relationship -`packages/layer-tests` continues to run as-is. Once this harness stabilizes, -`layer-tests` is expected to migrate onto it. Treat this crate as canonical for -new integration tests. +`packages/layer-tests` continues to run as-is. `wavs-test-harness` is +**canonical** for new integration tests; `layer-tests` is treated as legacy +and is expected to migrate onto the harness in a follow-up. Two parallel +test crates drift over time, so migration shouldn't sit forever — but it +also doesn't block this v1. + +## What ships in v1 + +- `chain`: local Anvil + pinned fork (feature `fork`, on by default). + `snapshot` / `revert` with RAII guard. `impersonate_funded`, + `enable_auto_impersonate`, `whale_fund`. `mine_blocks`, `set_automine`, + `increase_time`, `set_next_block_timestamp`. `redact_url` / `redact_key` + for sanitized logs. +- `fixtures`: `ChainProfile` (TOML), `Addresses` typed lookup. Three + bundled profiles. +- `service`: `ServiceSpec` builder, middleware mock re-exports + (`EvmMiddleware`, `MockEvmServiceManager`, `AvsOperator`), + `InProcRunner` (real WASM via `wavs-engine`), `SubprocessRunner` + (preview, off-by-default feature). +- `lifecycle`: `manual_input_json` / `manual_input_raw`, `wait_for` / + `wait_until` polling helpers, `assert_within` tolerance check. +- `envelope`: canonical `Envelope` / `SignatureData` shape mirroring + `@wavs/solidity@0.6.x`, `sign_envelope`, `event_id_from_nonce` / + `event_id_from_seed`, `Envelope::message_hash` / `signing_hash`. +- `harness::TestHarness

` convenience wrapper. + +## What's not in v1 + +- Full subprocess wiring (API shape only). +- Cosmos fork support. +- A `with_deploy(closure)` builder hook on `TestHarness` (deferred until + async-closure ergonomics stabilize; compose primitives directly today). +- Operator-side oracle mocking — needed before downstream apps can run + their real strategy WASM end-to-end via the InProc runner. +- A version of the harness compatible with newer alloy lines used by + downstream apps — see "Cross-repo alloy version barrier" above. From cd41fd351a29f8f1f3ba6b84b06e996e98ee45eb Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 03:49:22 +0000 Subject: [PATCH 10/19] chore(test-harness): refresh Cargo.lock with wavs-engine + wasmtime entries (#1147) Picks up two transitive entries on `wavs-test-harness` that were added in the earlier `feat(test-harness): in-process runner` commit but didn't make it into Cargo.lock at commit time (test cache regenerated them later). No functional change. Refs Lay3rLabs/WAVS#1147. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index a47f1a263..0a2ebc45b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14618,6 +14618,8 @@ dependencies = [ "tracing", "tracing-subscriber", "utils", + "wasmtime", + "wavs-engine", "wavs-types", ] From e5e2f91c66af1a38d846a29f3b8a2bf9cf3fa497 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 05:55:32 +0000 Subject: [PATCH 11/19] style(test-harness): apply cargo fmt to satisfy Lint CI (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the rustfmt diffs flagged by `cargo fmt -- --check` on the PR. No semantic changes — only whitespace, brace placement, and import ordering. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/test-harness/examples/fork_with_addresses.rs | 6 ++++-- packages/test-harness/examples/minimal_local.rs | 4 +--- packages/test-harness/src/chain/snapshot.rs | 10 ++-------- packages/test-harness/src/chain/time.rs | 6 +----- packages/test-harness/src/service/mod.rs | 8 ++++---- packages/test-harness/src/service/operators.rs | 4 ++-- packages/test-harness/src/service/runner_subprocess.rs | 3 ++- 7 files changed, 16 insertions(+), 25 deletions(-) diff --git a/packages/test-harness/examples/fork_with_addresses.rs b/packages/test-harness/examples/fork_with_addresses.rs index cdb49c28a..663f6f197 100644 --- a/packages/test-harness/examples/fork_with_addresses.rs +++ b/packages/test-harness/examples/fork_with_addresses.rs @@ -15,8 +15,10 @@ async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); let profile = ChainProfile::load("base")?; - println!("[example] profile: name={} chain_id={}", - profile.chain.name, profile.chain.chain_id); + println!( + "[example] profile: name={} chain_id={}", + profile.chain.name, profile.chain.chain_id + ); // The RPC URL is read from FORK_RPC_URL and never printed verbatim. match profile.resolve_rpc_url() { diff --git a/packages/test-harness/examples/minimal_local.rs b/packages/test-harness/examples/minimal_local.rs index 18eb558c2..370054e08 100644 --- a/packages/test-harness/examples/minimal_local.rs +++ b/packages/test-harness/examples/minimal_local.rs @@ -24,9 +24,7 @@ async fn main() -> anyhow::Result<()> { let component = example_wasm("echo_data.wasm"); let aggregator = example_wasm("simple_aggregator.wasm"); if !component.exists() || !aggregator.exists() { - eprintln!( - "Missing example WASM. Run `just wasi-build-native` from the WAVS repo root." - ); + eprintln!("Missing example WASM. Run `just wasi-build-native` from the WAVS repo root."); return Ok(()); } diff --git a/packages/test-harness/src/chain/snapshot.rs b/packages/test-harness/src/chain/snapshot.rs index cf97ea875..f0f9af85a 100644 --- a/packages/test-harness/src/chain/snapshot.rs +++ b/packages/test-harness/src/chain/snapshot.rs @@ -18,10 +18,7 @@ pub async fn snapshot(provider: &(impl Provider + AnvilApi)) -> Result } /// Revert the chain to a previous snapshot. Returns `true` on success. -pub async fn revert( - provider: &(impl Provider + AnvilApi), - id: U256, -) -> Result { +pub async fn revert(provider: &(impl Provider + AnvilApi), id: U256) -> Result { let ok = provider.anvil_revert(id).await?; tracing::debug!(snapshot_id = %id, ok, "evm_revert"); Ok(ok) @@ -44,10 +41,7 @@ impl SnapshotGuard { } /// Revert to the captured snapshot. Consumes the guard. - pub async fn revert( - mut self, - provider: &(impl Provider + AnvilApi), - ) -> Result { + pub async fn revert(mut self, provider: &(impl Provider + AnvilApi)) -> Result { let id = self.id.take().expect("guard already consumed"); revert(provider, id).await } diff --git a/packages/test-harness/src/chain/time.rs b/packages/test-harness/src/chain/time.rs index 4b72c3bbc..c444c4043 100644 --- a/packages/test-harness/src/chain/time.rs +++ b/packages/test-harness/src/chain/time.rs @@ -18,10 +18,7 @@ pub async fn mine_blocks( /// Toggle auto-mining. When off, blocks are produced only via [`mine_blocks`] or /// [`set_block_timestamp`] + a transaction. -pub async fn set_automine( - provider: &(impl Provider + AnvilApi), - on: bool, -) -> Result<()> { +pub async fn set_automine(provider: &(impl Provider + AnvilApi), on: bool) -> Result<()> { provider.anvil_set_auto_mine(on).await?; Ok(()) } @@ -44,4 +41,3 @@ pub async fn set_next_block_timestamp( provider.anvil_set_next_block_timestamp(ts).await?; Ok(()) } - diff --git a/packages/test-harness/src/service/mod.rs b/packages/test-harness/src/service/mod.rs index f249a7172..9f140fdd3 100644 --- a/packages/test-harness/src/service/mod.rs +++ b/packages/test-harness/src/service/mod.rs @@ -12,11 +12,11 @@ pub mod runner_inproc; pub mod runner_subprocess; pub use config::ServiceSpec; -#[cfg(feature = "inproc")] -pub use runner_inproc::InProcRunner; -#[cfg(feature = "subprocess")] -pub use runner_subprocess::{SubprocessConfig, SubprocessRunner}; pub use operators::{ AvsOperator, EigenlayerMiddleware, EvmMiddleware, EvmMiddlewareType, MockEvmServiceManager, OperatorSet, PoaMiddleware, ANVIL_DEPLOYER_ADDRESS, ANVIL_DEPLOYER_KEY, }; +#[cfg(feature = "inproc")] +pub use runner_inproc::InProcRunner; +#[cfg(feature = "subprocess")] +pub use runner_subprocess::{SubprocessConfig, SubprocessRunner}; diff --git a/packages/test-harness/src/service/operators.rs b/packages/test-harness/src/service/operators.rs index 1498948db..1a3153d09 100644 --- a/packages/test-harness/src/service/operators.rs +++ b/packages/test-harness/src/service/operators.rs @@ -13,8 +13,8 @@ use alloy_primitives::Address; pub use utils::test_utils::middleware::evm::{ - EigenlayerMiddleware, EvmMiddleware, EvmMiddlewareType, PoaMiddleware, - ANVIL_DEPLOYER_ADDRESS, ANVIL_DEPLOYER_KEY, + EigenlayerMiddleware, EvmMiddleware, EvmMiddlewareType, PoaMiddleware, ANVIL_DEPLOYER_ADDRESS, + ANVIL_DEPLOYER_KEY, }; pub use utils::test_utils::middleware::operator::AvsOperator; pub use utils::test_utils::mock_service_manager::MockEvmServiceManager; diff --git a/packages/test-harness/src/service/runner_subprocess.rs b/packages/test-harness/src/service/runner_subprocess.rs index b134f4abf..ef3d154de 100644 --- a/packages/test-harness/src/service/runner_subprocess.rs +++ b/packages/test-harness/src/service/runner_subprocess.rs @@ -128,7 +128,8 @@ mod tests { .data_dir("/tmp/wavs-test") .env("WAVS_LOG", "debug"); assert_eq!( - c.wavs_binary_path().map(|p| p.to_string_lossy().to_string()), + c.wavs_binary_path() + .map(|p| p.to_string_lossy().to_string()), Some("/usr/local/bin/wavs".to_string()) ); assert_eq!(c.rpc_url_value(), Some("http://127.0.0.1:8545")); From 7c700f6885bfdf5b85777d0a5cd4d78111f58277 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 05:59:22 +0000 Subject: [PATCH 12/19] feat(test-harness): wire FORK_BLOCK_NUMBER env + ForkOptions::from_profile (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ForkOptions::from_env()` now reads `FORK_BLOCK_NUMBER` as a `u64`, satisfying the env-var contract documented in issue #1147. Adds `ForkOptions::from_profile(profile)` which resolves the RPC URL via the profile's `rpc_env` declaration and pins the block to either `FORK_BLOCK_NUMBER` (env override) or `chain.fork_block` (profile fallback). Pinned-block precedence (highest wins): 1. ForkOptions::with_block_number(b) — explicit override 2. FORK_BLOCK_NUMBER env var — CI override 3. ChainProfile.chain.fork_block — profile default 4. None — Anvil follows upstream latest; spawn emits warn! so the determinism gap is visible in test logs. Adds seven unit tests covering each precedence path and rejection of empty / non-numeric values. README updated to use `from_profile` and document the precedence rule. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/test-harness/README.md | 18 +- packages/test-harness/src/chain/fork.rs | 188 ++++++++++++++++++++- packages/test-harness/tests/chain_smoke.rs | 2 +- 3 files changed, 197 insertions(+), 11 deletions(-) diff --git a/packages/test-harness/README.md b/packages/test-harness/README.md index 8d4968f13..8db0801d1 100644 --- a/packages/test-harness/README.md +++ b/packages/test-harness/README.md @@ -46,11 +46,9 @@ FORK_RPC_URL= cargo run -p wavs-test-harness --example fork_with_addre use wavs_test_harness::{chain, fixtures::ChainProfile}; let profile = ChainProfile::load("base")?; // chain_id + addresses + rpc_env -let opts = chain::ForkOptions { - rpc_url: profile.resolve_rpc_url()?, // reads FORK_RPC_URL - block_number: profile.chain.fork_block, - timeout_ms: None, -}; +// `from_profile` resolves FORK_RPC_URL via the profile's rpc_env and pins the +// block to FORK_BLOCK_NUMBER (env) or profile.chain.fork_block (fallback). +let opts = chain::ForkOptions::from_profile(&profile)?; let (provider, _anvil) = chain::spawn_fork(opts).await?; let usdc = profile.address("usdc")?; @@ -62,6 +60,16 @@ chain::impersonate_funded(&provider, whale).await?; chain::stop_impersonating(&provider, whale).await?; ``` +### Fork block pinning precedence + +For determinism, the harness pins the fork block in this order (highest wins): + +1. `ForkOptions::with_block_number(b)` — explicit override. +2. `FORK_BLOCK_NUMBER` env var — CI override. +3. `ChainProfile.chain.fork_block` — profile default (via `from_profile`). +4. None — Anvil follows upstream `latest`; spawn logs a `warn!` so the + determinism gap is visible. + The RPC URL is never logged verbatim — only via [`chain::redact_url`]. ## Tier matrix diff --git a/packages/test-harness/src/chain/fork.rs b/packages/test-harness/src/chain/fork.rs index 21098a092..0124fe57b 100644 --- a/packages/test-harness/src/chain/fork.rs +++ b/packages/test-harness/src/chain/fork.rs @@ -1,8 +1,19 @@ //! Pinned mainnet-fork spawn for fork-tier tests. //! //! The fork URL and (optionally) pinned block number are read either from an explicit -//! [`ForkOptions`] struct or from the `FORK_RPC_URL` env var. The URL is never logged -//! verbatim — only a redacted suffix via [`crate::chain::logging::redact_url`]. +//! [`ForkOptions`] struct or from the `FORK_RPC_URL` / `FORK_BLOCK_NUMBER` env vars. +//! The URL is never logged verbatim — only a redacted suffix via +//! [`crate::chain::logging::redact_url`]. +//! +//! Pinned block selection follows this precedence (highest wins): +//! +//! 1. [`ForkOptions::block_number`] set explicitly by the caller. +//! 2. `FORK_BLOCK_NUMBER` env var (decimal `u64`). +//! 3. [`crate::fixtures::ChainProfile::chain.fork_block`] via +//! [`ForkOptions::from_profile`]. +//! +//! If none of these resolve, the fork runs against the upstream's latest block +//! and the spawn emits a `warn!` so the determinism gap is visible in logs. //! //! Modelled after `wavs-defi/crates/integration-tests/tests/common/anvil.rs`. @@ -17,6 +28,9 @@ use utils::test_utils::anvil::safe_spawn_anvil_extra; #[cfg(feature = "fork")] pub const DEFAULT_FORK_RPC_ENV: &str = "FORK_RPC_URL"; +#[cfg(feature = "fork")] +pub const DEFAULT_FORK_BLOCK_ENV: &str = "FORK_BLOCK_NUMBER"; + /// Options for spawning a forked Anvil instance. #[derive(Debug, Clone, Default)] pub struct ForkOptions { @@ -29,16 +43,74 @@ pub struct ForkOptions { } impl ForkOptions { - /// Build options from the environment (`FORK_RPC_URL`) and a pinned block. - pub fn from_env(block_number: Option) -> Result { + /// Build options from the environment. + /// + /// Reads: + /// - `FORK_RPC_URL` — required. Returns `Err` if unset or empty. + /// - `FORK_BLOCK_NUMBER` — optional. If set, must parse as `u64`. + /// + /// The caller may override the block number by passing + /// [`ForkOptions { block_number: Some(_), .. }`] after merging via + /// [`Self::with_block_number`]. + pub fn from_env() -> Result { let rpc_url = std::env::var(DEFAULT_FORK_RPC_ENV) .with_context(|| format!("{DEFAULT_FORK_RPC_ENV} must be set for fork-tier tests"))?; + if rpc_url.is_empty() { + return Err(anyhow!("{DEFAULT_FORK_RPC_ENV} is empty")); + } + let block_number = block_number_from_env()?; Ok(Self { rpc_url: Some(rpc_url), block_number, timeout_ms: None, }) } + + /// Build options from a [`ChainProfile`](crate::fixtures::ChainProfile). + /// + /// Resolves the RPC URL through the profile's `rpc_env` declaration + /// (typically `FORK_RPC_URL`). The pinned block follows this precedence: + /// + /// 1. `FORK_BLOCK_NUMBER` env var if set (CI override). + /// 2. `chain.fork_block` declared in the profile. + /// + /// Returns `Err` if the profile does not declare an `rpc_env` or if the + /// declared env var is unset. + pub fn from_profile(profile: &crate::fixtures::ChainProfile) -> Result { + let rpc_url = profile + .resolve_rpc_url()? + .ok_or_else(|| anyhow!("profile `{}` has no rpc_env declared", profile.chain.name))?; + let block_number = block_number_from_env()?.or(profile.chain.fork_block); + Ok(Self { + rpc_url: Some(rpc_url), + block_number, + timeout_ms: None, + }) + } + + /// Override the pinned block number. + pub fn with_block_number(mut self, block_number: u64) -> Self { + self.block_number = Some(block_number); + self + } + + /// Override the Anvil spawn timeout. + pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self { + self.timeout_ms = Some(timeout_ms); + self + } +} + +/// Read `FORK_BLOCK_NUMBER` from the environment if set. Returns `Err` if set +/// but not parseable as a `u64`. +fn block_number_from_env() -> Result> { + match std::env::var(DEFAULT_FORK_BLOCK_ENV) { + Ok(v) if !v.is_empty() => v + .parse::() + .map(Some) + .with_context(|| format!("{DEFAULT_FORK_BLOCK_ENV} must be a decimal u64, got `{v}`")), + _ => Ok(None), + } } /// Spawn a forked Anvil instance and return a connected provider. @@ -63,7 +135,10 @@ pub async fn spawn_fork( let redacted = redact_url(&rpc_url); match block_number { Some(b) => tracing::info!(rpc = %redacted, block = b, "spawning forked anvil"), - None => tracing::info!(rpc = %redacted, "spawning forked anvil at latest"), + None => tracing::warn!( + rpc = %redacted, + "spawning forked anvil at LATEST upstream block — fork-tier tests are non-deterministic unless FORK_BLOCK_NUMBER or ChainProfile.fork_block is set" + ), } // Capture by reference / value so the retry closure stays `Fn`. @@ -83,3 +158,106 @@ pub async fn spawn_fork( Ok((provider, anvil)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_env_requires_rpc_url() { + temp_env::with_var_unset(DEFAULT_FORK_RPC_ENV, || { + let res = ForkOptions::from_env(); + assert!(res.is_err()); + let msg = format!("{}", res.unwrap_err()); + assert!(msg.contains(DEFAULT_FORK_RPC_ENV), "msg = {msg}"); + }); + } + + #[test] + fn from_env_rejects_empty_rpc_url() { + temp_env::with_var(DEFAULT_FORK_RPC_ENV, Some(""), || { + let res = ForkOptions::from_env(); + assert!(res.is_err()); + }); + } + + #[test] + fn from_env_reads_block_number_when_set() { + temp_env::with_vars( + [ + (DEFAULT_FORK_RPC_ENV, Some("https://example.com/k")), + (DEFAULT_FORK_BLOCK_ENV, Some("29000000")), + ], + || { + let opts = ForkOptions::from_env().unwrap(); + assert_eq!(opts.block_number, Some(29_000_000)); + }, + ); + } + + #[test] + fn from_env_block_number_unset_is_none() { + temp_env::with_vars( + [ + (DEFAULT_FORK_RPC_ENV, Some("https://example.com/k")), + (DEFAULT_FORK_BLOCK_ENV, None), + ], + || { + let opts = ForkOptions::from_env().unwrap(); + assert_eq!(opts.block_number, None); + }, + ); + } + + #[test] + fn from_env_block_number_invalid_errors() { + temp_env::with_vars( + [ + (DEFAULT_FORK_RPC_ENV, Some("https://example.com/k")), + (DEFAULT_FORK_BLOCK_ENV, Some("not-a-number")), + ], + || { + let res = ForkOptions::from_env(); + assert!(res.is_err()); + let msg = format!("{}", res.unwrap_err()); + assert!(msg.contains(DEFAULT_FORK_BLOCK_ENV), "msg = {msg}"); + }, + ); + } + + #[test] + fn from_profile_prefers_env_block_over_profile_block() { + let profile = crate::fixtures::ChainProfile::load("base").unwrap(); + let profile_block = profile.chain.fork_block; + assert!(profile_block.is_some(), "base profile must declare fork_block"); + + temp_env::with_vars( + [ + (DEFAULT_FORK_RPC_ENV, Some("https://example.com/k")), + (DEFAULT_FORK_BLOCK_ENV, Some("42")), + ], + || { + let opts = ForkOptions::from_profile(&profile).unwrap(); + assert_eq!(opts.block_number, Some(42)); + }, + ); + } + + #[test] + fn from_profile_falls_back_to_profile_block_when_env_unset() { + let profile = crate::fixtures::ChainProfile::load("base").unwrap(); + let expected = profile.chain.fork_block; + assert!(expected.is_some()); + + temp_env::with_vars( + [ + (DEFAULT_FORK_RPC_ENV, Some("https://example.com/k")), + (DEFAULT_FORK_BLOCK_ENV, None), + ], + || { + let opts = ForkOptions::from_profile(&profile).unwrap(); + assert_eq!(opts.block_number, expected); + }, + ); + } +} diff --git a/packages/test-harness/tests/chain_smoke.rs b/packages/test-harness/tests/chain_smoke.rs index 98357a2ee..6d6e6c580 100644 --- a/packages/test-harness/tests/chain_smoke.rs +++ b/packages/test-harness/tests/chain_smoke.rs @@ -53,7 +53,7 @@ async fn impersonate_and_set_balance() { fn fork_options_from_env_requires_fork_rpc_url() { // With no FORK_RPC_URL set, from_env must fail with a non-leaking error. temp_env::with_var_unset("FORK_RPC_URL", || { - let res = wavs_test_harness::chain::ForkOptions::from_env(Some(123)); + let res = wavs_test_harness::chain::ForkOptions::from_env(); assert!(res.is_err()); let msg = format!("{}", res.unwrap_err()); assert!(msg.contains("FORK_RPC_URL")); From 3ce1e1de6770d859f7ecdea1cb0bd3f18eee495c Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 06:16:00 +0000 Subject: [PATCH 13/19] feat(test-harness): end-to-end on-chain submission lifecycle (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review feedback that the previous in-process runner stopped at `run_component` and never submitted a signed envelope to a real handler. This commit closes the loop: trigger → operator (real WASM via wavs-engine) → sign envelope → submit on-chain (handleSignedEnvelope) → assert handler state What's new: - `service::handler::MockHandler` — deploys `SimpleServiceManager` + `SimpleSubmit` on local Anvil with weight/threshold config. Bundles the manager ABI under `fixtures/contracts/` because forge `out/` is gitignored; the handler ABI comes from the committed `examples/contracts/solidity/abi/SimpleSubmit.json`. - `envelope::submit_envelope(provider, handler, env, sig)` — ABI-encoded `IWavsServiceHandler.handleSignedEnvelope` calldata. Same shape any compliant handler accepts (SimpleSubmit, wavs-defi's SmartVaultServiceHandler, production @wavs/solidity handlers). - `envelope::sort_signature_data` — sorts `(signer, signature)` pairs in ascending address order, satisfying `_validateOperatorSorting`. - `chain::spawn_local_with_deployer()` — Anvil + wallet-bound `DynProvider` + the deployer signer, so `Contract::deploy(...)` works without extra setup. Four new lifecycle tests under `tests/end_to_end_smoke.rs`: 1. Positive: end-to-end lifecycle, asserts handler stored payload + signer address. 2. Negative: signer not registered → InsufficientQuorumZero revert. 3. Negative: unsorted signer array → InvalidSignatureOrder revert. 4. Sort + resubmit succeeds. `SimpleServiceManager.validate()` enforces signers.length, length match, reference-block freshness, ascending sort, weight aggregation, and threshold. ECDSA-recover is not in this mock — the production `WavsServiceManager` does ecrecover from the EIP-191 prefixed digest, and our envelope signing format (verified in `envelope::tests`) matches that path byte-for-byte. Tests: 25 lib + 5 chain + 4 end_to_end + 1 inproc = 35 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + packages/test-harness/Cargo.toml | 1 + .../contracts/SimpleServiceManager.json | 1 + packages/test-harness/src/chain/anvil.rs | 20 +- packages/test-harness/src/chain/fork.rs | 5 +- packages/test-harness/src/chain/mod.rs | 4 +- packages/test-harness/src/envelope/mod.rs | 115 ++++++- packages/test-harness/src/service/handler.rs | 212 +++++++++++++ packages/test-harness/src/service/mod.rs | 7 +- .../test-harness/tests/end_to_end_smoke.rs | 281 ++++++++++++++++++ 10 files changed, 640 insertions(+), 7 deletions(-) create mode 100644 packages/test-harness/fixtures/contracts/SimpleServiceManager.json create mode 100644 packages/test-harness/src/service/handler.rs create mode 100644 packages/test-harness/tests/end_to_end_smoke.rs diff --git a/Cargo.lock b/Cargo.lock index 0a2ebc45b..c4d1daa30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14604,6 +14604,7 @@ dependencies = [ "alloy-rpc-types-eth", "alloy-signer", "alloy-signer-local", + "alloy-sol-macro", "alloy-sol-types", "anyhow", "async-trait", diff --git a/packages/test-harness/Cargo.toml b/packages/test-harness/Cargo.toml index 62532888f..a1623b867 100644 --- a/packages/test-harness/Cargo.toml +++ b/packages/test-harness/Cargo.toml @@ -48,6 +48,7 @@ alloy-provider = { workspace = true, features = ["anvil-api", "anvil-node"] } alloy-signer = { workspace = true } alloy-signer-local = { workspace = true } alloy-sol-types = { workspace = true } +alloy-sol-macro = { workspace = true } alloy-rpc-types-eth = { workspace = true } [dev-dependencies] diff --git a/packages/test-harness/fixtures/contracts/SimpleServiceManager.json b/packages/test-harness/fixtures/contracts/SimpleServiceManager.json new file mode 100644 index 000000000..cdcfdc67f --- /dev/null +++ b/packages/test-harness/fixtures/contracts/SimpleServiceManager.json @@ -0,0 +1 @@ +{"abi":[{"type":"function","name":"getAllocationManager","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"pure"},{"type":"function","name":"getDelegationManager","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"pure"},{"type":"function","name":"getLastCheckpointThresholdWeight","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"getLastCheckpointTotalWeight","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"getLatestOperatorForSigningKey","inputs":[{"name":"signingKeyAddress","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"pure"},{"type":"function","name":"getOperatorWeight","inputs":[{"name":"operator","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"getServiceURI","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"getStakeRegistry","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"pure"},{"type":"function","name":"setLastCheckpointThresholdWeight","inputs":[{"name":"weight","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setLastCheckpointTotalWeight","inputs":[{"name":"weight","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setOperatorWeight","inputs":[{"name":"operator","type":"address","internalType":"address"},{"name":"weight","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setServiceURI","inputs":[{"name":"_serviceURI","type":"string","internalType":"string"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"validate","inputs":[{"name":"","type":"tuple","internalType":"struct IWavsServiceHandler.Envelope","components":[{"name":"eventId","type":"bytes20","internalType":"bytes20"},{"name":"ordering","type":"bytes12","internalType":"bytes12"},{"name":"payload","type":"bytes","internalType":"bytes"}]},{"name":"signatureData","type":"tuple","internalType":"struct IWavsServiceHandler.SignatureData","components":[{"name":"signers","type":"address[]","internalType":"address[]"},{"name":"signatures","type":"bytes[]","internalType":"bytes[]"},{"name":"referenceBlock","type":"uint32","internalType":"uint32"}]}],"outputs":[],"stateMutability":"view"},{"type":"event","name":"QuorumThresholdUpdated","inputs":[{"name":"numerator","type":"uint256","indexed":true,"internalType":"uint256"},{"name":"denominator","type":"uint256","indexed":true,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"ServiceURIUpdated","inputs":[{"name":"serviceURI","type":"string","indexed":false,"internalType":"string"}],"anonymous":false},{"type":"error","name":"InsufficientQuorum","inputs":[{"name":"signerWeight","type":"uint256","internalType":"uint256"},{"name":"thresholdWeight","type":"uint256","internalType":"uint256"},{"name":"totalWeight","type":"uint256","internalType":"uint256"}]},{"type":"error","name":"InsufficientQuorumZero","inputs":[]},{"type":"error","name":"InvalidQuorumParameters","inputs":[]},{"type":"error","name":"InvalidSignature","inputs":[]},{"type":"error","name":"InvalidSignatureBlock","inputs":[]},{"type":"error","name":"InvalidSignatureLength","inputs":[]},{"type":"error","name":"InvalidSignatureOrder","inputs":[]}],"bytecode":{"object":"0x608060405234601c57600e6020565b61114a61002b823961114a90f35b6026565b60405190565b5f80fdfe60806040526004361015610013575b610642565b61001d5f356100ec565b806308fc760a146100e75780630e6b1110146100e2578063314f3a49146100dd5780635f11301b146100d857806398ec1ac9146100d3578063a28efd6b146100ce578063ac7cbfd9146100c9578063b24e5a3a146100c4578063b933fa74146100bf578063bef4c839146100ba578063cc922c6a146100b5578063cd71589e146100b05763fb8524b10361000e5761060f565b6105db565b61051f565b61047f565b61044a565b610415565b6103e0565b6103ab565b610354565b610302565b61024c565b6101e7565b61014d565b60e01c90565b60405190565b5f80fd5b5f80fd5b5f80fd5b90565b61011081610104565b0361011757565b5f80fd5b9050359061012882610107565b565b9060208282031261014357610140915f0161011b565b90565b6100fc565b5f0190565b3461017b5761016561016036600461012a565b6106a3565b61016d6100f2565b8061017781610148565b0390f35b6100f8565b60018060a01b031690565b61019490610180565b90565b6101a08161018b565b036101a757565b5f80fd5b905035906101b882610197565b565b91906040838203126101e257806101d66101df925f86016101ab565b9360200161011b565b90565b6100fc565b34610216576102006101fa3660046101ba565b906106fa565b6102086100f2565b8061021281610148565b0390f35b6100f8565b5f91031261022557565b6100fc565b61023390610104565b9052565b919061024a905f6020850194019061022a565b565b3461027c5761025c36600461021b565b61027861026761073d565b61026f6100f2565b91829182610237565b0390f35b6100f8565b5f80fd5b5f80fd5b5f80fd5b909182601f830112156102c75781359167ffffffffffffffff83116102c25760200192600183028401116102bd57565b610289565b610285565b610281565b906020828203126102fd575f82013567ffffffffffffffff81116102f8576102f4920161028d565b9091565b610100565b6100fc565b346103315761031b6103153660046102cc565b906109e0565b6103236100f2565b8061032d81610148565b0390f35b6100f8565b9060208282031261034f5761034c915f016101ab565b90565b6100fc565b346103845761038061036f61036a366004610336565b610a28565b6103776100f2565b91829182610237565b0390f35b6100f8565b6103929061018b565b9052565b91906103a9905f60208501940190610389565b565b346103db576103bb36600461021b565b6103d76103c6610a76565b6103ce6100f2565b91829182610396565b0390f35b6100f8565b346104105761040c6103fb6103f6366004610336565b610a8b565b6104036100f2565b91829182610396565b0390f35b6100f8565b346104455761042536600461021b565b610441610430610a97565b6104386100f2565b91829182610396565b0390f35b6100f8565b3461047a5761045a36600461021b565b610476610465610aac565b61046d6100f2565b91829182610237565b0390f35b6100f8565b346104af5761048f36600461021b565b6104ab61049a610ac2565b6104a26100f2565b91829182610396565b0390f35b6100f8565b5190565b60209181520190565b90825f9392825e0152565b601f801991011690565b6104f56104fe602093610503936104ec816104b4565b938480936104b8565b958691016104c1565b6104cc565b0190565b61051c9160208201915f8184039101526104d6565b90565b3461054f5761052f36600461021b565b61054b61053a610bd5565b6105426100f2565b91829182610507565b0390f35b6100f8565b5f80fd5b908160609103126105665790565b610554565b908160609103126105795790565b610554565b9190916040818403126105d6575f81013567ffffffffffffffff81116105d157836105aa918301610558565b92602082013567ffffffffffffffff81116105cc576105c9920161056b565b90565b610100565b610100565b6100fc565b3461060a576105f46105ee36600461057e565b90610da3565b6105fc6100f2565b8061060681610148565b0390f35b6100f8565b3461063d5761062761062236600461012a565b610fdd565b61062f6100f2565b8061063981610148565b0390f35b6100f8565b5f80fd5b5f1b90565b906106575f1991610646565b9181191691161790565b90565b61067861067361067d92610104565b610661565b610104565b90565b90565b9061069861069361069f92610664565b610680565b825461064b565b9055565b6106ae906003610683565b565b6106c46106bf6106c992610180565b610661565b610180565b90565b6106d5906106b0565b90565b6106e1906106cc565b90565b906106ee906106d8565b5f5260205260405f2090565b61070961070e929160016106e4565b610683565b565b5f90565b5f1c90565b90565b61072861072d91610714565b610719565b90565b61073a905461071c565b90565b610745610710565b506107506003610730565b90565b5090565b634e487b7160e01b5f52604160045260245ffd5b634e487b7160e01b5f52602260045260245ffd5b906001600283049216801561079f575b602083101461079a57565b61076b565b91607f169161078f565b5f5260205f2090565b601f602091010490565b1b90565b919060086107db9102916107d55f19846107bc565b926107bc565b9181191691161790565b91906107fb6107f661080393610664565b610680565b9083546107c0565b9055565b61081991610813610710565b916107e5565b565b5b818110610827575050565b806108345f600193610807565b0161081c565b9190601f811161084a575b505050565b61085661087b936107a9565b906020610862846107b2565b83019310610883575b610874906107b2565b019061081b565b5f8080610845565b91506108748192905061086b565b1c90565b906108a5905f1990600802610891565b191690565b816108b491610895565b906002021790565b916108c79082610753565b9067ffffffffffffffff8211610986576108eb826108e5855461077f565b8561083a565b5f90601f831160011461091e5791809161090d935f92610912575b50506108aa565b90555b565b90915001355f80610906565b601f1983169161092d856107a9565b925f5b81811061096e57509160029391856001969410610954575b50505002019055610910565b610964910135601f841690610895565b90555f8080610948565b91936020600181928787013581550195019201610930565b610757565b9061099692916108bc565b565b90825f939282370152565b91906109bd816109b6816109c2956104b8565b8095610998565b6104cc565b0190565b90916109dd9260208301925f8185039101526109a3565b90565b6109ec8183905f61098b565b907fd2f056df94c31bdb3b4e16080f2ac6dbd4233c6001058d94afc3ad1242dff48891610a23610a1a6100f2565b928392836109c6565b0390a1565b610a3f610a4491610a37610710565b5060016106e4565b610730565b90565b5f90565b90565b610a62610a5d610a6792610a4b565b610661565b610180565b90565b610a7390610a4e565b90565b610a7e610a47565b50610a885f610a6a565b90565b610a93610a47565b5090565b610a9f610a47565b50610aa95f610a6a565b90565b610ab4610710565b50610abf6002610730565b90565b610aca610a47565b50610ad45f610a6a565b90565b606090565b60209181520190565b905f9291805490610aff610af88361077f565b8094610adc565b916001811690815f14610b565750600114610b1a575b505050565b610b2791929394506107a9565b915f925b818410610b3e57505001905f8080610b15565b60018160209295939554848601520191019290610b2b565b92949550505060ff19168252151560200201905f8080610b15565b90610b7b91610ae5565b90565b90610b88906104cc565b810190811067ffffffffffffffff821117610ba257604052565b610757565b90610bc7610bc092610bb76100f2565b93848092610b71565b0383610b7e565b565b610bd290610ba7565b90565b610bdd610ad7565b50610be75f610bc9565b90565b5f80fd5b5f80fd5b5f80fd5b903590600160200381360303821215610c38570180359067ffffffffffffffff8211610c3357602001916020820236038313610c2e57565b610bf2565b610bee565b610bea565b5090565b610c55610c50610c5a92610a4b565b610661565b610104565b90565b903590600160200381360303821215610c9f570180359067ffffffffffffffff8211610c9a57602001916020820236038313610c9557565b610bf2565b610bee565b610bea565b5090565b63ffffffff1690565b610cba81610ca8565b03610cc157565b5f80fd5b35610ccf81610cb1565b90565b610ce6610ce1610ceb92610ca8565b610661565b610104565b90565b151590565b6001610cff9101610104565b90565b634e487b7160e01b5f52603260045260245ffd5b9190811015610d26576020020190565b610d02565b35610d3581610197565b90565b634e487b7160e01b5f52601160045260245ffd5b610d5b610d6191939293610104565b92610104565b8201809211610d6c57565b610d38565b604090610d9a610da19496959396610d9060608401985f85019061022a565b602083019061022a565b019061022a565b565b50610dba610db4825f810190610bf6565b90610c3d565b610dcc610dc65f610c41565b91610104565b148015610f97575b610f7b57610e00610de760408301610cc5565b610df9610df343610104565b91610cd2565b1015610cee565b610f5f57610e23610e1d610e17835f810190610bf6565b90611032565b15610cee565b610f4357610e305f610c41565b90610e3a5f610c41565b915b82610e64610e5e610e59610e53865f810190610bf6565b90610c3d565b610104565b91610104565b1015610eb557610ea9610eaf91610ea3610e9e6001610e98610e93610e8c895f810190610bf6565b8b91610d16565b610d2b565b906106e4565b610730565b90610d4c565b92610cf3565b91610e3c565b91505080610ecb610ec55f610c41565b91610104565b14610f275780610eec610ee6610ee16002610730565b610104565b91610104565b10610ef45750565b610efe6002610730565b90610f23610f0c6003610730565b5f9384936386b1609160e01b855260048501610d71565b0390fd5b5f632f41f79b60e01b815280610f3f60048201610148565b0390fd5b5f6309cf9e4960e11b815280610f5b60048201610148565b0390fd5b5f63898deb1d60e01b815280610f7760048201610148565b0390fd5b5f634be6321b60e01b815280610f9360048201610148565b0390fd5b50610fae610fa8825f810190610bf6565b90610c3d565b610fd6610fd0610fcb610fc5856020810190610c5d565b90610ca4565b610104565b91610104565b1415610dd4565b610fe8906002610683565b565b5f90565b90565b61100561100061100a92610fee565b610661565b610104565b90565b61101c61102291939293610104565b92610104565b820391821161102d57565b610d38565b9061103b610fea565b5061106461104a838390610c3d565b61105d6110576001610ff1565b91610104565b1115610cee565b61110d576110726001610ff1565b5b8061109061108a611085868690610c3d565b610104565b91610104565b1015611105576110ec6110ad6110a885858591610d16565b610d2b565b6110e56110df6110da6110d588886110cf896110c96001610ff1565b9061100d565b91610d16565b610d2b565b61018b565b9161018b565b1115610cee565b6110fe576110f990610cf3565b611073565b5050505f90565b505050600190565b505060019056fea2646970667358221220acdaec2211c2ca6b38003d6a9d44e5f4ff89d013cc5cebc349ba73e9deab495264736f6c634300081c0033","sourceMap":"388:4970:7:-:0;;;;;;;;:::i;:::-;;;;;;;;;;:::i;:::-;;;;:::o;:::-;;;","linkReferences":{}},"deployedBytecode":{"object":"0x60806040526004361015610013575b610642565b61001d5f356100ec565b806308fc760a146100e75780630e6b1110146100e2578063314f3a49146100dd5780635f11301b146100d857806398ec1ac9146100d3578063a28efd6b146100ce578063ac7cbfd9146100c9578063b24e5a3a146100c4578063b933fa74146100bf578063bef4c839146100ba578063cc922c6a146100b5578063cd71589e146100b05763fb8524b10361000e5761060f565b6105db565b61051f565b61047f565b61044a565b610415565b6103e0565b6103ab565b610354565b610302565b61024c565b6101e7565b61014d565b60e01c90565b60405190565b5f80fd5b5f80fd5b5f80fd5b90565b61011081610104565b0361011757565b5f80fd5b9050359061012882610107565b565b9060208282031261014357610140915f0161011b565b90565b6100fc565b5f0190565b3461017b5761016561016036600461012a565b6106a3565b61016d6100f2565b8061017781610148565b0390f35b6100f8565b60018060a01b031690565b61019490610180565b90565b6101a08161018b565b036101a757565b5f80fd5b905035906101b882610197565b565b91906040838203126101e257806101d66101df925f86016101ab565b9360200161011b565b90565b6100fc565b34610216576102006101fa3660046101ba565b906106fa565b6102086100f2565b8061021281610148565b0390f35b6100f8565b5f91031261022557565b6100fc565b61023390610104565b9052565b919061024a905f6020850194019061022a565b565b3461027c5761025c36600461021b565b61027861026761073d565b61026f6100f2565b91829182610237565b0390f35b6100f8565b5f80fd5b5f80fd5b5f80fd5b909182601f830112156102c75781359167ffffffffffffffff83116102c25760200192600183028401116102bd57565b610289565b610285565b610281565b906020828203126102fd575f82013567ffffffffffffffff81116102f8576102f4920161028d565b9091565b610100565b6100fc565b346103315761031b6103153660046102cc565b906109e0565b6103236100f2565b8061032d81610148565b0390f35b6100f8565b9060208282031261034f5761034c915f016101ab565b90565b6100fc565b346103845761038061036f61036a366004610336565b610a28565b6103776100f2565b91829182610237565b0390f35b6100f8565b6103929061018b565b9052565b91906103a9905f60208501940190610389565b565b346103db576103bb36600461021b565b6103d76103c6610a76565b6103ce6100f2565b91829182610396565b0390f35b6100f8565b346104105761040c6103fb6103f6366004610336565b610a8b565b6104036100f2565b91829182610396565b0390f35b6100f8565b346104455761042536600461021b565b610441610430610a97565b6104386100f2565b91829182610396565b0390f35b6100f8565b3461047a5761045a36600461021b565b610476610465610aac565b61046d6100f2565b91829182610237565b0390f35b6100f8565b346104af5761048f36600461021b565b6104ab61049a610ac2565b6104a26100f2565b91829182610396565b0390f35b6100f8565b5190565b60209181520190565b90825f9392825e0152565b601f801991011690565b6104f56104fe602093610503936104ec816104b4565b938480936104b8565b958691016104c1565b6104cc565b0190565b61051c9160208201915f8184039101526104d6565b90565b3461054f5761052f36600461021b565b61054b61053a610bd5565b6105426100f2565b91829182610507565b0390f35b6100f8565b5f80fd5b908160609103126105665790565b610554565b908160609103126105795790565b610554565b9190916040818403126105d6575f81013567ffffffffffffffff81116105d157836105aa918301610558565b92602082013567ffffffffffffffff81116105cc576105c9920161056b565b90565b610100565b610100565b6100fc565b3461060a576105f46105ee36600461057e565b90610da3565b6105fc6100f2565b8061060681610148565b0390f35b6100f8565b3461063d5761062761062236600461012a565b610fdd565b61062f6100f2565b8061063981610148565b0390f35b6100f8565b5f80fd5b5f1b90565b906106575f1991610646565b9181191691161790565b90565b61067861067361067d92610104565b610661565b610104565b90565b90565b9061069861069361069f92610664565b610680565b825461064b565b9055565b6106ae906003610683565b565b6106c46106bf6106c992610180565b610661565b610180565b90565b6106d5906106b0565b90565b6106e1906106cc565b90565b906106ee906106d8565b5f5260205260405f2090565b61070961070e929160016106e4565b610683565b565b5f90565b5f1c90565b90565b61072861072d91610714565b610719565b90565b61073a905461071c565b90565b610745610710565b506107506003610730565b90565b5090565b634e487b7160e01b5f52604160045260245ffd5b634e487b7160e01b5f52602260045260245ffd5b906001600283049216801561079f575b602083101461079a57565b61076b565b91607f169161078f565b5f5260205f2090565b601f602091010490565b1b90565b919060086107db9102916107d55f19846107bc565b926107bc565b9181191691161790565b91906107fb6107f661080393610664565b610680565b9083546107c0565b9055565b61081991610813610710565b916107e5565b565b5b818110610827575050565b806108345f600193610807565b0161081c565b9190601f811161084a575b505050565b61085661087b936107a9565b906020610862846107b2565b83019310610883575b610874906107b2565b019061081b565b5f8080610845565b91506108748192905061086b565b1c90565b906108a5905f1990600802610891565b191690565b816108b491610895565b906002021790565b916108c79082610753565b9067ffffffffffffffff8211610986576108eb826108e5855461077f565b8561083a565b5f90601f831160011461091e5791809161090d935f92610912575b50506108aa565b90555b565b90915001355f80610906565b601f1983169161092d856107a9565b925f5b81811061096e57509160029391856001969410610954575b50505002019055610910565b610964910135601f841690610895565b90555f8080610948565b91936020600181928787013581550195019201610930565b610757565b9061099692916108bc565b565b90825f939282370152565b91906109bd816109b6816109c2956104b8565b8095610998565b6104cc565b0190565b90916109dd9260208301925f8185039101526109a3565b90565b6109ec8183905f61098b565b907fd2f056df94c31bdb3b4e16080f2ac6dbd4233c6001058d94afc3ad1242dff48891610a23610a1a6100f2565b928392836109c6565b0390a1565b610a3f610a4491610a37610710565b5060016106e4565b610730565b90565b5f90565b90565b610a62610a5d610a6792610a4b565b610661565b610180565b90565b610a7390610a4e565b90565b610a7e610a47565b50610a885f610a6a565b90565b610a93610a47565b5090565b610a9f610a47565b50610aa95f610a6a565b90565b610ab4610710565b50610abf6002610730565b90565b610aca610a47565b50610ad45f610a6a565b90565b606090565b60209181520190565b905f9291805490610aff610af88361077f565b8094610adc565b916001811690815f14610b565750600114610b1a575b505050565b610b2791929394506107a9565b915f925b818410610b3e57505001905f8080610b15565b60018160209295939554848601520191019290610b2b565b92949550505060ff19168252151560200201905f8080610b15565b90610b7b91610ae5565b90565b90610b88906104cc565b810190811067ffffffffffffffff821117610ba257604052565b610757565b90610bc7610bc092610bb76100f2565b93848092610b71565b0383610b7e565b565b610bd290610ba7565b90565b610bdd610ad7565b50610be75f610bc9565b90565b5f80fd5b5f80fd5b5f80fd5b903590600160200381360303821215610c38570180359067ffffffffffffffff8211610c3357602001916020820236038313610c2e57565b610bf2565b610bee565b610bea565b5090565b610c55610c50610c5a92610a4b565b610661565b610104565b90565b903590600160200381360303821215610c9f570180359067ffffffffffffffff8211610c9a57602001916020820236038313610c9557565b610bf2565b610bee565b610bea565b5090565b63ffffffff1690565b610cba81610ca8565b03610cc157565b5f80fd5b35610ccf81610cb1565b90565b610ce6610ce1610ceb92610ca8565b610661565b610104565b90565b151590565b6001610cff9101610104565b90565b634e487b7160e01b5f52603260045260245ffd5b9190811015610d26576020020190565b610d02565b35610d3581610197565b90565b634e487b7160e01b5f52601160045260245ffd5b610d5b610d6191939293610104565b92610104565b8201809211610d6c57565b610d38565b604090610d9a610da19496959396610d9060608401985f85019061022a565b602083019061022a565b019061022a565b565b50610dba610db4825f810190610bf6565b90610c3d565b610dcc610dc65f610c41565b91610104565b148015610f97575b610f7b57610e00610de760408301610cc5565b610df9610df343610104565b91610cd2565b1015610cee565b610f5f57610e23610e1d610e17835f810190610bf6565b90611032565b15610cee565b610f4357610e305f610c41565b90610e3a5f610c41565b915b82610e64610e5e610e59610e53865f810190610bf6565b90610c3d565b610104565b91610104565b1015610eb557610ea9610eaf91610ea3610e9e6001610e98610e93610e8c895f810190610bf6565b8b91610d16565b610d2b565b906106e4565b610730565b90610d4c565b92610cf3565b91610e3c565b91505080610ecb610ec55f610c41565b91610104565b14610f275780610eec610ee6610ee16002610730565b610104565b91610104565b10610ef45750565b610efe6002610730565b90610f23610f0c6003610730565b5f9384936386b1609160e01b855260048501610d71565b0390fd5b5f632f41f79b60e01b815280610f3f60048201610148565b0390fd5b5f6309cf9e4960e11b815280610f5b60048201610148565b0390fd5b5f63898deb1d60e01b815280610f7760048201610148565b0390fd5b5f634be6321b60e01b815280610f9360048201610148565b0390fd5b50610fae610fa8825f810190610bf6565b90610c3d565b610fd6610fd0610fcb610fc5856020810190610c5d565b90610ca4565b610104565b91610104565b1415610dd4565b610fe8906002610683565b565b5f90565b90565b61100561100061100a92610fee565b610661565b610104565b90565b61101c61102291939293610104565b92610104565b820391821161102d57565b610d38565b9061103b610fea565b5061106461104a838390610c3d565b61105d6110576001610ff1565b91610104565b1115610cee565b61110d576110726001610ff1565b5b8061109061108a611085868690610c3d565b610104565b91610104565b1015611105576110ec6110ad6110a885858591610d16565b610d2b565b6110e56110df6110da6110d588886110cf896110c96001610ff1565b9061100d565b91610d16565b610d2b565b61018b565b9161018b565b1115610cee565b6110fe576110f990610cf3565b611073565b5050505f90565b505050600190565b505060019056fea2646970667358221220acdaec2211c2ca6b38003d6a9d44e5f4ff89d013cc5cebc349ba73e9deab495264736f6c634300081c0033","sourceMap":"388:4970:7:-:0;;;;;;;;;-1:-1:-1;388:4970:7;:::i;:::-;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::i;:::-;;;;:::o;:::-;;;;:::o;:::-;;;;;;;;;;;;;;:::o;:::-;;;;:::i;:::-;;;;:::o;:::-;;;;;;;;;;;;:::i;:::-;:::o;:::-;;;;;;;;;;;;;;:::i;:::-;;:::o;:::-;;:::i;:::-;;;;:::o;:::-;;;;;;;;;:::i;:::-;;:::i;:::-;;;:::i;:::-;;;;;:::i;:::-;;;;;;:::i;:::-;;;;;;;;:::o;:::-;;;;:::i;:::-;;:::o;:::-;;;;:::i;:::-;;;;:::o;:::-;;;;;;;;;;;;:::i;:::-;:::o;:::-;;;;;;;;;;;;;;;;;;:::i;:::-;;;;;:::i;:::-;;:::o;:::-;;:::i;:::-;;;;;;;;;:::i;:::-;;;:::i;:::-;;;:::i;:::-;;;;;:::i;:::-;;;;;;:::i;:::-;;;;;;;:::o;:::-;;:::i;:::-;;;;:::i;:::-;;;:::o;:::-;;;;;;;;;;;;;:::i;:::-;:::o;:::-;;;;;;;;:::i;:::-;;;;:::i;:::-;;;:::i;:::-;;;;;;:::i;:::-;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::o;:::-;;:::i;:::-;;:::i;:::-;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;:::o;:::-;;:::i;:::-;;:::i;:::-;;;;;;;;;:::i;:::-;;;:::i;:::-;;;:::i;:::-;;;;;:::i;:::-;;;;;;:::i;:::-;;;;;;;;;;;;;;:::i;:::-;;:::o;:::-;;:::i;:::-;;;;;;;;;;:::i;:::-;;:::i;:::-;;;:::i;:::-;;;;;;:::i;:::-;;;;;;:::i;:::-;;;;:::i;:::-;;;:::o;:::-;;;;;;;;;;;;;:::i;:::-;:::o;:::-;;;;;;;;:::i;:::-;;;;:::i;:::-;;;:::i;:::-;;;;;;:::i;:::-;;;;;;:::i;:::-;;;;;;;;;;:::i;:::-;;:::i;:::-;;;:::i;:::-;;;;;;:::i;:::-;;;;;;:::i;:::-;;;;;;;;:::i;:::-;;;;:::i;:::-;;;:::i;:::-;;;;;;:::i;:::-;;;;;;:::i;:::-;;;;;;;;:::i;:::-;;;;:::i;:::-;;;:::i;:::-;;;;;;:::i;:::-;;;;;;:::i;:::-;;;;;;;;:::i;:::-;;;;:::i;:::-;;;:::i;:::-;;;;;;:::i;:::-;;;;;;:::i;:::-;;;:::o;:::-;;;;;;;:::o;:::-;;;;;;;;;;:::o;:::-;;;;;;;;:::o;:::-;;;;;;;;;;:::i;:::-;;;;;;:::i;:::-;;;;;;:::i;:::-;;:::i;:::-;;;:::o;:::-;;;;;;;;;;;;;;;:::i;:::-;;:::o;:::-;;;;;;;;:::i;:::-;;;;:::i;:::-;;;:::i;:::-;;;;;;:::i;:::-;;;;;;:::i;:::-;;;;;;;;;;;;;;:::o;:::-;;:::i;:::-;;;;;;;;;;:::o;:::-;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;;;;;;;;;;;;:::i;:::-;;:::o;:::-;;:::i;:::-;;:::i;:::-;;:::i;:::-;;;;;;;;;:::i;:::-;;;:::i;:::-;;;:::i;:::-;;;;;:::i;:::-;;;;;;:::i;:::-;;;;;;;;;:::i;:::-;;:::i;:::-;;;:::i;:::-;;;;;:::i;:::-;;;;;;:::i;:::-;;;;;;;;:::o;:::-;;;;;;;:::i;:::-;;;;;;;;;:::o;:::-;;:::o;:::-;;;;;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::o;:::-;;:::o;:::-;;;;;;;:::i;:::-;;:::i;:::-;;;;:::i;:::-;;;:::o;3867:128::-;3954:34;3867:128;3954:34;;:::i;:::-;3867:128::o;388:4970::-;;;;;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::o;:::-;;;;:::i;:::-;;:::o;:::-;;;;:::i;:::-;;:::o;:::-;;;;;:::i;:::-;;;;;;;;;:::o;3316:121::-;3396:25;:34;3316:121;3396:15;;:25;:::i;:::-;:34;:::i;:::-;3316:121::o;388:4970::-;;;:::o;:::-;;;;:::o;:::-;;:::o;:::-;;;;;:::i;:::-;;:::i;:::-;;:::o;:::-;;;;;:::i;:::-;;:::o;4598:121::-;4661:7;;:::i;:::-;4687:25;;;;:::i;:::-;4680:32;:::o;388:4970::-;;;:::o;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::o;:::-;;:::i;:::-;;;;;;;;;;;;;;:::o;:::-;;;;;;;:::o;:::-;;;:::o;:::-;;;;;;;;;;;;;:::i;:::-;;;:::i;:::-;;;;;;;;;:::o;:::-;;;;;;;;:::i;:::-;;:::i;:::-;;;;;:::i;:::-;;;:::o;:::-;;;;;:::i;:::-;;;:::i;:::-;:::o;:::-;;;;;;;;;:::o;:::-;;;;;;;:::i;:::-;;;;;;;;;;;;;;;;:::o;:::-;;;;;:::i;:::-;;;;;;:::i;:::-;;;;;;;;;;;:::i;:::-;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;:::o;:::-;;;;;;;;;;:::i;:::-;;;;:::o;:::-;;;;;:::i;:::-;;;;;;:::o;:::-;;;;;;:::i;:::-;;;;;;;;;;;;;:::i;:::-;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;:::o;:::-;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;;;:::i;:::-;:::o;:::-;;;;;;;;;;:::o;:::-;;;;;;;;;;:::i;:::-;;;;:::i;:::-;;:::i;:::-;;;:::o;:::-;;;;;;;;;;;;;;;;;:::i;:::-;;:::o;3003:161::-;3088:24;3101:11;;3088:24;;;:::i;:::-;3145:11;3127:30;;;;;:::i;:::-;;;;;;:::i;:::-;;;;3003:161::o;4041:140::-;4149:25;;4041:140;4123:7;;:::i;:::-;4149:15;;:25;:::i;:::-;;:::i;:::-;4142:32;:::o;388:4970::-;;;:::o;:::-;;:::o;:::-;;;;;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::o;:::-;;;;:::i;:::-;;:::o;5118:98::-;5173:7;;:::i;:::-;5207:1;5199:10;5207:1;5199:10;:::i;:::-;5192:17;:::o;4765:163::-;4878:7;;:::i;:::-;4904:17;4897:24;:::o;4974:98::-;5029:7;;:::i;:::-;5063:1;5055:10;5063:1;5055:10;:::i;:::-;5048:17;:::o;4329:129::-;4396:7;;:::i;:::-;4422:29;;;;:::i;:::-;4415:36;:::o;5262:94::-;5313:7;;:::i;:::-;5347:1;5339:10;5347:1;5339:10;:::i;:::-;5332:17;:::o;388:4970::-;;;:::o;:::-;;;;;;;:::o;:::-;;;;;;;;;;;;:::i;:::-;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;:::o;:::-;;;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;:::o;:::-;;;;;:::i;:::-;;;;;;;;;;;;;;:::o;:::-;;:::i;:::-;;;;;;;:::i;:::-;;;;;;:::i;:::-;;;;:::i;:::-;:::o;:::-;;;;:::i;:::-;;:::o;2860:97::-;2908:13;;:::i;:::-;2940:10;2933:17;2940:10;2933:17;:::i;:::-;;:::o;388:4970::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::o;:::-;;:::i;:::-;;:::i;:::-;;:::i;:::-;;;:::o;:::-;;;;;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::o;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::o;:::-;;:::i;:::-;;:::i;:::-;;:::i;:::-;;;:::o;:::-;;;;:::o;:::-;;;;:::i;:::-;;;;:::o;:::-;;;;;;;;;:::i;:::-;;:::o;:::-;;;;;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::o;:::-;;;;:::o;:::-;;;;;;:::i;:::-;;:::o;:::-;;;;;;;;;;;;;;;;;;;;;;;;:::o;:::-;;:::i;:::-;;;;;:::i;:::-;;:::o;:::-;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;:::i;:::-;;;;;;;;:::o;:::-;;:::i;:::-;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;;;:::i;:::-;;;;:::i;:::-;:::o;675:1423::-;;905:28;:21;:13;:21;;;;;:::i;:::-;:28;;:::i;:::-;:33;;937:1;905:33;:::i;:::-;;;:::i;:::-;;:116;;;;675:1423;888:220;;1121:46;1123:28;;:13;:28;;:::i;:::-;:43;;1154:12;1123:43;:::i;:::-;;;:::i;:::-;;1121:46;;:::i;:::-;1117:127;;1257:48;1258:47;1283:21;:13;:21;;;;;:::i;:::-;1258:47;;:::i;:::-;1257:48;;:::i;:::-;1253:129;;1453:24;1476:1;1453:24;:::i;:::-;1504:1;1492:13;1504:1;1492:13;:::i;:::-;1487:141;1541:3;1507:1;:32;;1511:28;:21;:13;:21;;;;;:::i;:::-;:28;;:::i;:::-;1507:32;:::i;:::-;;;:::i;:::-;;;;;1560:57;1541:3;1576:15;:41;;:15;1592:24;;:21;:13;:21;;;;;:::i;:::-;1614:1;1592:24;;:::i;:::-;;:::i;:::-;1576:41;;:::i;:::-;;:::i;:::-;1560:57;;:::i;:::-;1541:3;;:::i;:::-;1492:13;;;1507:32;;;;1692:12;:17;;1708:1;1692:17;:::i;:::-;;;:::i;:::-;;1688:99;;1874:12;:44;;1889:29;;;:::i;:::-;1874:44;:::i;:::-;;;:::i;:::-;;1870:222;;675:1423;:::o;1870:222::-;2011:29;;;:::i;:::-;2042:25;1941:140;2042:25;;;:::i;:::-;1941:140;;;;;;;;;;;;;:::i;:::-;;;;1688:99;1732:44;;;;;;;;;;;;:::i;:::-;;;;1253:129;1328:43;;;;;;;;;;;;:::i;:::-;;;;1117:127;1190:43;;;;;;;;;;;;:::i;:::-;;;;888:220;1053:44;;;;;;;;;;;;:::i;:::-;;;;905:116;958:13;:28;:21;:13;:21;;;;;:::i;:::-;:28;;:::i;:::-;:63;;990:31;:24;:13;:24;;;;;:::i;:::-;:31;;:::i;:::-;958:63;:::i;:::-;;;:::i;:::-;;;905:116;;3588:136;3679:38;3588:136;3679:38;;:::i;:::-;3588:136::o;388:4970::-;;;:::o;:::-;;:::o;:::-;;;;;;:::i;:::-;;:::i;:::-;;:::i;:::-;;:::o;:::-;;;;;;;;:::i;:::-;;;:::i;:::-;;;;;;;;:::o;:::-;;:::i;2312:502::-;;2413:4;;:::i;:::-;2493:9;2491:23;2493:16;:9;;:16;;:::i;:::-;:20;;2512:1;2493:20;:::i;:::-;;;:::i;:::-;;2491:23;;:::i;:::-;2487:65;;2635:13;2647:1;2635:13;:::i;:::-;2672:3;2650:1;:20;;2654:16;:9;;:16;;:::i;:::-;2650:20;:::i;:::-;;;:::i;:::-;;;;;2695:34;2697:12;;:9;;2707:1;2697:12;;:::i;:::-;;:::i;:::-;:31;;2712:16;;:9;;2722:5;:1;:5;2726:1;2722:5;:::i;:::-;;;:::i;:::-;2712:16;;:::i;:::-;;:::i;:::-;2697:31;:::i;:::-;;;:::i;:::-;;2695:34;;:::i;:::-;2691:85;;2672:3;;;:::i;:::-;2635:13;;2691:85;2756:5;;;;2749:12;:::o;2650:20::-;;;;2803:4;2796:11;:::o;2487:65::-;2537:4;;;2530:11;:::o","linkReferences":{}},"methodIdentifiers":{"getAllocationManager()":"a28efd6b","getDelegationManager()":"b24e5a3a","getLastCheckpointThresholdWeight()":"b933fa74","getLastCheckpointTotalWeight()":"314f3a49","getLatestOperatorForSigningKey(address)":"ac7cbfd9","getOperatorWeight(address)":"98ec1ac9","getServiceURI()":"cc922c6a","getStakeRegistry()":"bef4c839","setLastCheckpointThresholdWeight(uint256)":"fb8524b1","setLastCheckpointTotalWeight(uint256)":"08fc760a","setOperatorWeight(address,uint256)":"0e6b1110","setServiceURI(string)":"5f11301b","validate((bytes20,bytes12,bytes),(address[],bytes[],uint32))":"cd71589e"},"rawMetadata":"{\"compiler\":{\"version\":\"0.8.28+commit.7893614a\"},\"language\":\"Solidity\",\"output\":{\"abi\":[{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"signerWeight\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"thresholdWeight\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"totalWeight\",\"type\":\"uint256\"}],\"name\":\"InsufficientQuorum\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"InsufficientQuorumZero\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"InvalidQuorumParameters\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"InvalidSignature\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"InvalidSignatureBlock\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"InvalidSignatureLength\",\"type\":\"error\"},{\"inputs\":[],\"name\":\"InvalidSignatureOrder\",\"type\":\"error\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"numerator\",\"type\":\"uint256\"},{\"indexed\":true,\"internalType\":\"uint256\",\"name\":\"denominator\",\"type\":\"uint256\"}],\"name\":\"QuorumThresholdUpdated\",\"type\":\"event\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":false,\"internalType\":\"string\",\"name\":\"serviceURI\",\"type\":\"string\"}],\"name\":\"ServiceURIUpdated\",\"type\":\"event\"},{\"inputs\":[],\"name\":\"getAllocationManager\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"pure\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getDelegationManager\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"pure\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getLastCheckpointThresholdWeight\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getLastCheckpointTotalWeight\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"signingKeyAddress\",\"type\":\"address\"}],\"name\":\"getLatestOperatorForSigningKey\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"pure\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"}],\"name\":\"getOperatorWeight\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getServiceURI\",\"outputs\":[{\"internalType\":\"string\",\"name\":\"\",\"type\":\"string\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"getStakeRegistry\",\"outputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"pure\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"weight\",\"type\":\"uint256\"}],\"name\":\"setLastCheckpointThresholdWeight\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"weight\",\"type\":\"uint256\"}],\"name\":\"setLastCheckpointTotalWeight\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"operator\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"weight\",\"type\":\"uint256\"}],\"name\":\"setOperatorWeight\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"string\",\"name\":\"_serviceURI\",\"type\":\"string\"}],\"name\":\"setServiceURI\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"components\":[{\"internalType\":\"bytes20\",\"name\":\"eventId\",\"type\":\"bytes20\"},{\"internalType\":\"bytes12\",\"name\":\"ordering\",\"type\":\"bytes12\"},{\"internalType\":\"bytes\",\"name\":\"payload\",\"type\":\"bytes\"}],\"internalType\":\"struct IWavsServiceHandler.Envelope\",\"name\":\"\",\"type\":\"tuple\"},{\"components\":[{\"internalType\":\"address[]\",\"name\":\"signers\",\"type\":\"address[]\"},{\"internalType\":\"bytes[]\",\"name\":\"signatures\",\"type\":\"bytes[]\"},{\"internalType\":\"uint32\",\"name\":\"referenceBlock\",\"type\":\"uint32\"}],\"internalType\":\"struct IWavsServiceHandler.SignatureData\",\"name\":\"signatureData\",\"type\":\"tuple\"}],\"name\":\"validate\",\"outputs\":[],\"stateMutability\":\"view\",\"type\":\"function\"}],\"devdoc\":{\"author\":\"Lay3r Labs\",\"details\":\"This contract implements the IWavsServiceManager interface\",\"errors\":{\"InsufficientQuorum(uint256,uint256,uint256)\":[{\"params\":{\"signerWeight\":\"The weight of the signer\",\"thresholdWeight\":\"The threshold weight\",\"totalWeight\":\"The total weight\"}}]},\"events\":{\"QuorumThresholdUpdated(uint256,uint256)\":{\"params\":{\"denominator\":\"The denominator of the quorum threshold\",\"numerator\":\"The numerator of the quorum threshold\"}},\"ServiceURIUpdated(string)\":{\"params\":{\"serviceURI\":\"The service URI\"}}},\"kind\":\"dev\",\"methods\":{\"getAllocationManager()\":{\"returns\":{\"_0\":\"The allocation manager address.\"}},\"getDelegationManager()\":{\"returns\":{\"_0\":\"The delegation manager address.\"}},\"getLastCheckpointThresholdWeight()\":{\"returns\":{\"_0\":\"The threshold weight of the last checkpoint\"}},\"getLastCheckpointTotalWeight()\":{\"returns\":{\"_0\":\"The total weight of the last checkpoint\"}},\"getLatestOperatorForSigningKey(address)\":{\"params\":{\"signingKeyAddress\":\"The address of the signing key.\"},\"returns\":{\"_0\":\"The latest operator address associated with the signing key, or address(0) if none.\"}},\"getOperatorWeight(address)\":{\"params\":{\"operator\":\"The address of the operator\"},\"returns\":{\"_0\":\"The current weight of the operator\"}},\"getServiceURI()\":{\"returns\":{\"_0\":\"The service URI.\"}},\"getStakeRegistry()\":{\"returns\":{\"_0\":\"The stake registry address.\"}},\"setLastCheckpointThresholdWeight(uint256)\":{\"params\":{\"weight\":\"The threshold weight of the last checkpoint\"}},\"setLastCheckpointTotalWeight(uint256)\":{\"params\":{\"weight\":\"The total weight of the last checkpoint\"}},\"setOperatorWeight(address,uint256)\":{\"params\":{\"operator\":\"The operator\",\"weight\":\"The weight of the operator\"}},\"setServiceURI(string)\":{\"params\":{\"_serviceURI\":\"The service URI to update.\"}},\"validate((bytes20,bytes12,bytes),(address[],bytes[],uint32))\":{\"params\":{\"envelope\":\"The envelope containing the data.\",\"signatureData\":\"The signature data.\"}}},\"title\":\"SimpleServiceManager\",\"version\":1},\"userdoc\":{\"errors\":{\"InsufficientQuorum(uint256,uint256,uint256)\":[{\"notice\":\"The error for the insufficient quorum\"}],\"InsufficientQuorumZero()\":[{\"notice\":\"The error for the insufficient quorum zero.\"}],\"InvalidQuorumParameters()\":[{\"notice\":\"The error for the invalid quorum parameters.\"}],\"InvalidSignature()\":[{\"notice\":\"The error for the invalid signature.\"}],\"InvalidSignatureBlock()\":[{\"notice\":\"The error for the invalid signature block.\"}],\"InvalidSignatureLength()\":[{\"notice\":\"The error for the invalid signature length.\"}],\"InvalidSignatureOrder()\":[{\"notice\":\"The error for the invalid signature order.\"}]},\"events\":{\"QuorumThresholdUpdated(uint256,uint256)\":{\"notice\":\"Event emitted when the quorum threshold is updated\"},\"ServiceURIUpdated(string)\":{\"notice\":\"Event emitted when the service URI is updated\"}},\"kind\":\"user\",\"methods\":{\"getAllocationManager()\":{\"notice\":\"Returns the allocation manager address.\"},\"getDelegationManager()\":{\"notice\":\"Returns the delegation manager address.\"},\"getLastCheckpointThresholdWeight()\":{\"notice\":\"Returns the threshold weight of the last checkpoint\"},\"getLastCheckpointTotalWeight()\":{\"notice\":\"Returns the total weight of the last checkpoint\"},\"getLatestOperatorForSigningKey(address)\":{\"notice\":\"Returns the latest operator address associated with a signing key.\"},\"getOperatorWeight(address)\":{\"notice\":\"Gets the operator's current weight\"},\"getServiceURI()\":{\"notice\":\"Returns the service URI\"},\"getStakeRegistry()\":{\"notice\":\"Returns the stake registry address.\"},\"setLastCheckpointThresholdWeight(uint256)\":{\"notice\":\"Sets the threshold weight of the last checkpoint\"},\"setLastCheckpointTotalWeight(uint256)\":{\"notice\":\"Sets the total weight of the last checkpoint\"},\"setOperatorWeight(address,uint256)\":{\"notice\":\"Sets the weight of an operator\"},\"setServiceURI(string)\":{\"notice\":\"Sets the service URI\"},\"validate((bytes20,bytes12,bytes),(address[],bytes[],uint32))\":{\"notice\":\"Validates a signed envelope\"}},\"notice\":\"Contract for the simple service manager\",\"version\":1}},\"settings\":{\"compilationTarget\":{\"examples/contracts/solidity/mocks/SimpleServiceManager.sol\":\"SimpleServiceManager\"},\"evmVersion\":\"prague\",\"libraries\":{},\"metadata\":{\"bytecodeHash\":\"ipfs\"},\"optimizer\":{\"enabled\":false,\"runs\":200},\"remappings\":[],\"viaIR\":true},\"sources\":{\"examples/contracts/solidity/interfaces/IWavsServiceHandler.sol\":{\"keccak256\":\"0x427e63f26320f27f53975554ff530953d81fb51b681fca950754b576ce83a267\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://d39520e0561d2f65a04b76c265f7ede71feab34fc0e1bd9f21b868353b9c2b0a\",\"dweb:/ipfs/QmWgvBY8pim9hNLNFDRZyob4PvRmuxEoRSyqABkUNpDcef\"]},\"examples/contracts/solidity/interfaces/IWavsServiceManager.sol\":{\"keccak256\":\"0xc4abed1f1f462a601b8f855c6d16bcc97ac9e5eb081f82ca6bedb6420cd1c9b7\",\"license\":\"UNLICENSED\",\"urls\":[\"bzz-raw://27c6906e991bbbe4a589b97d3f883bfb66c6b86463f9e1ff0fb68ac9845ef3f1\",\"dweb:/ipfs/QmYh9y385CszJ4m8TSbqLkTwzFwYyUEBX8KNwvHCjEuXWn\"]},\"examples/contracts/solidity/mocks/SimpleServiceManager.sol\":{\"keccak256\":\"0x0fea3cac68e0f1222fa0d3225c03297fcd161d15eca2a7b5b00a1ad70d586300\",\"license\":\"MIT\",\"urls\":[\"bzz-raw://ba44c2f191f64cef5b98d40e04c8da8896dacead776bbd1945714a082471f763\",\"dweb:/ipfs/QmZqgzsdC6kf33oQ6BWjDVHCTcUznpcbU7Fi2HSv7hW8U8\"]}},\"version\":1}","metadata":{"compiler":{"version":"0.8.28+commit.7893614a"},"language":"Solidity","output":{"abi":[{"inputs":[{"internalType":"uint256","name":"signerWeight","type":"uint256"},{"internalType":"uint256","name":"thresholdWeight","type":"uint256"},{"internalType":"uint256","name":"totalWeight","type":"uint256"}],"type":"error","name":"InsufficientQuorum"},{"inputs":[],"type":"error","name":"InsufficientQuorumZero"},{"inputs":[],"type":"error","name":"InvalidQuorumParameters"},{"inputs":[],"type":"error","name":"InvalidSignature"},{"inputs":[],"type":"error","name":"InvalidSignatureBlock"},{"inputs":[],"type":"error","name":"InvalidSignatureLength"},{"inputs":[],"type":"error","name":"InvalidSignatureOrder"},{"inputs":[{"internalType":"uint256","name":"numerator","type":"uint256","indexed":true},{"internalType":"uint256","name":"denominator","type":"uint256","indexed":true}],"type":"event","name":"QuorumThresholdUpdated","anonymous":false},{"inputs":[{"internalType":"string","name":"serviceURI","type":"string","indexed":false}],"type":"event","name":"ServiceURIUpdated","anonymous":false},{"inputs":[],"stateMutability":"pure","type":"function","name":"getAllocationManager","outputs":[{"internalType":"address","name":"","type":"address"}]},{"inputs":[],"stateMutability":"pure","type":"function","name":"getDelegationManager","outputs":[{"internalType":"address","name":"","type":"address"}]},{"inputs":[],"stateMutability":"view","type":"function","name":"getLastCheckpointThresholdWeight","outputs":[{"internalType":"uint256","name":"","type":"uint256"}]},{"inputs":[],"stateMutability":"view","type":"function","name":"getLastCheckpointTotalWeight","outputs":[{"internalType":"uint256","name":"","type":"uint256"}]},{"inputs":[{"internalType":"address","name":"signingKeyAddress","type":"address"}],"stateMutability":"pure","type":"function","name":"getLatestOperatorForSigningKey","outputs":[{"internalType":"address","name":"","type":"address"}]},{"inputs":[{"internalType":"address","name":"operator","type":"address"}],"stateMutability":"view","type":"function","name":"getOperatorWeight","outputs":[{"internalType":"uint256","name":"","type":"uint256"}]},{"inputs":[],"stateMutability":"view","type":"function","name":"getServiceURI","outputs":[{"internalType":"string","name":"","type":"string"}]},{"inputs":[],"stateMutability":"pure","type":"function","name":"getStakeRegistry","outputs":[{"internalType":"address","name":"","type":"address"}]},{"inputs":[{"internalType":"uint256","name":"weight","type":"uint256"}],"stateMutability":"nonpayable","type":"function","name":"setLastCheckpointThresholdWeight"},{"inputs":[{"internalType":"uint256","name":"weight","type":"uint256"}],"stateMutability":"nonpayable","type":"function","name":"setLastCheckpointTotalWeight"},{"inputs":[{"internalType":"address","name":"operator","type":"address"},{"internalType":"uint256","name":"weight","type":"uint256"}],"stateMutability":"nonpayable","type":"function","name":"setOperatorWeight"},{"inputs":[{"internalType":"string","name":"_serviceURI","type":"string"}],"stateMutability":"nonpayable","type":"function","name":"setServiceURI"},{"inputs":[{"internalType":"struct IWavsServiceHandler.Envelope","name":"","type":"tuple","components":[{"internalType":"bytes20","name":"eventId","type":"bytes20"},{"internalType":"bytes12","name":"ordering","type":"bytes12"},{"internalType":"bytes","name":"payload","type":"bytes"}]},{"internalType":"struct IWavsServiceHandler.SignatureData","name":"signatureData","type":"tuple","components":[{"internalType":"address[]","name":"signers","type":"address[]"},{"internalType":"bytes[]","name":"signatures","type":"bytes[]"},{"internalType":"uint32","name":"referenceBlock","type":"uint32"}]}],"stateMutability":"view","type":"function","name":"validate"}],"devdoc":{"kind":"dev","methods":{"getAllocationManager()":{"returns":{"_0":"The allocation manager address."}},"getDelegationManager()":{"returns":{"_0":"The delegation manager address."}},"getLastCheckpointThresholdWeight()":{"returns":{"_0":"The threshold weight of the last checkpoint"}},"getLastCheckpointTotalWeight()":{"returns":{"_0":"The total weight of the last checkpoint"}},"getLatestOperatorForSigningKey(address)":{"params":{"signingKeyAddress":"The address of the signing key."},"returns":{"_0":"The latest operator address associated with the signing key, or address(0) if none."}},"getOperatorWeight(address)":{"params":{"operator":"The address of the operator"},"returns":{"_0":"The current weight of the operator"}},"getServiceURI()":{"returns":{"_0":"The service URI."}},"getStakeRegistry()":{"returns":{"_0":"The stake registry address."}},"setLastCheckpointThresholdWeight(uint256)":{"params":{"weight":"The threshold weight of the last checkpoint"}},"setLastCheckpointTotalWeight(uint256)":{"params":{"weight":"The total weight of the last checkpoint"}},"setOperatorWeight(address,uint256)":{"params":{"operator":"The operator","weight":"The weight of the operator"}},"setServiceURI(string)":{"params":{"_serviceURI":"The service URI to update."}},"validate((bytes20,bytes12,bytes),(address[],bytes[],uint32))":{"params":{"envelope":"The envelope containing the data.","signatureData":"The signature data."}}},"version":1},"userdoc":{"kind":"user","methods":{"getAllocationManager()":{"notice":"Returns the allocation manager address."},"getDelegationManager()":{"notice":"Returns the delegation manager address."},"getLastCheckpointThresholdWeight()":{"notice":"Returns the threshold weight of the last checkpoint"},"getLastCheckpointTotalWeight()":{"notice":"Returns the total weight of the last checkpoint"},"getLatestOperatorForSigningKey(address)":{"notice":"Returns the latest operator address associated with a signing key."},"getOperatorWeight(address)":{"notice":"Gets the operator's current weight"},"getServiceURI()":{"notice":"Returns the service URI"},"getStakeRegistry()":{"notice":"Returns the stake registry address."},"setLastCheckpointThresholdWeight(uint256)":{"notice":"Sets the threshold weight of the last checkpoint"},"setLastCheckpointTotalWeight(uint256)":{"notice":"Sets the total weight of the last checkpoint"},"setOperatorWeight(address,uint256)":{"notice":"Sets the weight of an operator"},"setServiceURI(string)":{"notice":"Sets the service URI"},"validate((bytes20,bytes12,bytes),(address[],bytes[],uint32))":{"notice":"Validates a signed envelope"}},"version":1}},"settings":{"remappings":[],"optimizer":{"enabled":false,"runs":200},"metadata":{"bytecodeHash":"ipfs"},"compilationTarget":{"examples/contracts/solidity/mocks/SimpleServiceManager.sol":"SimpleServiceManager"},"evmVersion":"prague","libraries":{},"viaIR":true},"sources":{"examples/contracts/solidity/interfaces/IWavsServiceHandler.sol":{"keccak256":"0x427e63f26320f27f53975554ff530953d81fb51b681fca950754b576ce83a267","urls":["bzz-raw://d39520e0561d2f65a04b76c265f7ede71feab34fc0e1bd9f21b868353b9c2b0a","dweb:/ipfs/QmWgvBY8pim9hNLNFDRZyob4PvRmuxEoRSyqABkUNpDcef"],"license":"MIT"},"examples/contracts/solidity/interfaces/IWavsServiceManager.sol":{"keccak256":"0xc4abed1f1f462a601b8f855c6d16bcc97ac9e5eb081f82ca6bedb6420cd1c9b7","urls":["bzz-raw://27c6906e991bbbe4a589b97d3f883bfb66c6b86463f9e1ff0fb68ac9845ef3f1","dweb:/ipfs/QmYh9y385CszJ4m8TSbqLkTwzFwYyUEBX8KNwvHCjEuXWn"],"license":"UNLICENSED"},"examples/contracts/solidity/mocks/SimpleServiceManager.sol":{"keccak256":"0x0fea3cac68e0f1222fa0d3225c03297fcd161d15eca2a7b5b00a1ad70d586300","urls":["bzz-raw://ba44c2f191f64cef5b98d40e04c8da8896dacead776bbd1945714a082471f763","dweb:/ipfs/QmZqgzsdC6kf33oQ6BWjDVHCTcUznpcbU7Fi2HSv7hW8U8"],"license":"MIT"}},"version":1},"id":7} \ No newline at end of file diff --git a/packages/test-harness/src/chain/anvil.rs b/packages/test-harness/src/chain/anvil.rs index 652febede..2753a9eda 100644 --- a/packages/test-harness/src/chain/anvil.rs +++ b/packages/test-harness/src/chain/anvil.rs @@ -5,7 +5,8 @@ //! returns a connected provider so callers don't have to wire up `ProviderBuilder`. use alloy_network::Ethereum; -use alloy_provider::{ext::AnvilApi, Provider, ProviderBuilder}; +use alloy_provider::{ext::AnvilApi, DynProvider, Provider, ProviderBuilder}; +use alloy_signer_local::PrivateKeySigner; use anyhow::Result; #[allow(unused_imports)] @@ -22,3 +23,20 @@ pub async fn spawn_local() -> Result<(impl Provider + AnvilApi + Clone let provider = ProviderBuilder::new().on_http(anvil.endpoint_url()); Ok((provider, anvil)) } + +/// Spawn a fresh local Anvil with a wallet-aware provider. +/// +/// The returned provider is signed by Anvil account 0 (the default deployer) +/// so calls to `Contract::deploy(provider, ...)` and other state-changing +/// transactions work out of the box. Returns `(provider, anvil, deployer_signer)`. +/// +/// Use this when you need to deploy contracts on Anvil from within the harness — +/// the plain [`spawn_local`] is fine for read-only or impersonated work. +pub async fn spawn_local_with_deployer() -> Result<(DynProvider, AnvilInstance, PrivateKeySigner)> { + let anvil = safe_spawn_anvil(); + let deployer = PrivateKeySigner::from_signing_key(anvil.keys()[0].clone().into()); + let provider = ProviderBuilder::new() + .wallet(deployer.clone()) + .connect_http(anvil.endpoint().parse()?); + Ok((DynProvider::new(provider), anvil, deployer)) +} diff --git a/packages/test-harness/src/chain/fork.rs b/packages/test-harness/src/chain/fork.rs index 0124fe57b..fb5014cbc 100644 --- a/packages/test-harness/src/chain/fork.rs +++ b/packages/test-harness/src/chain/fork.rs @@ -229,7 +229,10 @@ mod tests { fn from_profile_prefers_env_block_over_profile_block() { let profile = crate::fixtures::ChainProfile::load("base").unwrap(); let profile_block = profile.chain.fork_block; - assert!(profile_block.is_some(), "base profile must declare fork_block"); + assert!( + profile_block.is_some(), + "base profile must declare fork_block" + ); temp_env::with_vars( [ diff --git a/packages/test-harness/src/chain/mod.rs b/packages/test-harness/src/chain/mod.rs index c85fd71a6..bf13128dc 100644 --- a/packages/test-harness/src/chain/mod.rs +++ b/packages/test-harness/src/chain/mod.rs @@ -9,9 +9,9 @@ pub mod logging; pub mod snapshot; pub mod time; -pub use anvil::{safe_spawn_anvil, spawn_local}; +pub use anvil::{safe_spawn_anvil, spawn_local, spawn_local_with_deployer}; #[cfg(feature = "fork")] -pub use fork::{spawn_fork, ForkOptions, DEFAULT_FORK_RPC_ENV}; +pub use fork::{spawn_fork, ForkOptions, DEFAULT_FORK_BLOCK_ENV, DEFAULT_FORK_RPC_ENV}; pub use impersonate::{ enable_auto_impersonate, impersonate_funded, set_balance, stop_impersonating, ONE_ETH, }; diff --git a/packages/test-harness/src/envelope/mod.rs b/packages/test-harness/src/envelope/mod.rs index be81dcef4..dca392ad9 100644 --- a/packages/test-harness/src/envelope/mod.rs +++ b/packages/test-harness/src/envelope/mod.rs @@ -17,10 +17,11 @@ //! with downstream apps under `@wavs/solidity@0.6.x`. use alloy_primitives::{eip191_hash_message, keccak256, Address, FixedBytes, B256, U256}; +use alloy_provider::Provider; use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; -use alloy_sol_types::{sol, SolValue}; -use anyhow::{anyhow, Result}; +use alloy_sol_types::{sol, SolCall, SolValue}; +use anyhow::{anyhow, Context, Result}; sol! { /// The canonical envelope shape that all WAVS service handlers verify. @@ -38,6 +39,16 @@ sol! { bytes[] signatures; uint32 referenceBlock; } + + /// `IWavsServiceHandler.handleSignedEnvelope` selector, used to encode + /// on-chain submission calldata. + #[sol(rpc)] + interface IHandler { + function handleSignedEnvelope( + Envelope calldata envelope, + SignatureData calldata signatureData + ) external; + } } impl Envelope { @@ -121,6 +132,81 @@ pub fn signer_address(signer: &PrivateKeySigner) -> Address { signer.address() } +/// Submit a signed envelope to a service handler's `handleSignedEnvelope` +/// entry point. Returns the transaction receipt. +/// +/// This is the "submit" stage of the WAVS lifecycle, completing the +/// `trigger → compute → aggregate → sign → SUBMIT → assert` path. Use +/// [`crate::service::handler::MockHandler::deploy`] to deploy the contract on +/// local Anvil, then pass `handler.handler` as the `handler_addr`. +/// +/// The function uses ABI-encoded calldata with the canonical +/// `IWavsServiceHandler.handleSignedEnvelope(Envelope, SignatureData)` +/// selector, so the same call shape works against any compliant handler — +/// `SimpleSubmit`, `wavs-defi`'s `SmartVaultServiceHandler`, or production +/// handlers built on `@wavs/solidity`. +pub async fn submit_envelope

( + provider: &P, + handler_addr: Address, + envelope: &Envelope, + signature: &SignatureData, +) -> Result +where + P: Provider, +{ + use alloy_network::TransactionBuilder; + use alloy_rpc_types_eth::TransactionRequest; + + let calldata = IHandler::handleSignedEnvelopeCall { + envelope: envelope.clone(), + signatureData: signature.clone(), + } + .abi_encode(); + + let tx = TransactionRequest::default() + .with_to(handler_addr) + .with_input(calldata); + + let pending = provider + .send_transaction(tx) + .await + .context("send handleSignedEnvelope tx")?; + let receipt = pending + .get_receipt() + .await + .context("await handleSignedEnvelope receipt")?; + if !receipt.status() { + return Err(anyhow!( + "handleSignedEnvelope reverted (tx={:?})", + receipt.transaction_hash + )); + } + tracing::debug!( + tx = ?receipt.transaction_hash, + block = ?receipt.block_number, + gas = receipt.gas_used, + "envelope submitted" + ); + Ok(receipt) +} + +/// Sort signers (and their signatures) ascending by address — required by +/// `SimpleServiceManager` and `WavsServiceManager.validate()`. Mutates in place. +/// +/// Call this *after* [`sign_envelope`] but *before* submitting if signers were +/// not registered in sorted order. +pub fn sort_signature_data(sigdata: &mut SignatureData) { + let mut indices: Vec = (0..sigdata.signers.len()).collect(); + indices.sort_by_key(|i| sigdata.signers[*i]); + let new_signers: Vec

= indices.iter().map(|i| sigdata.signers[*i]).collect(); + let new_sigs: Vec = indices + .iter() + .map(|i| sigdata.signatures[*i].clone()) + .collect(); + sigdata.signers = new_signers; + sigdata.signatures = new_sigs; +} + #[cfg(test)] mod tests { use super::*; @@ -166,4 +252,29 @@ mod tests { let res = sign_envelope(&env, &[], 0); assert!(res.is_err()); } + + #[test] + fn sort_signature_data_sorts_signers_and_signatures_together() { + // Build a SignatureData with intentionally out-of-order signers, then + // sort and check both arrays moved together. + let a = Address::from([0xaa; 20]); + let b = Address::from([0x11; 20]); + let c = Address::from([0x55; 20]); + + let mut sd = SignatureData { + signers: vec![a, b, c], + signatures: vec![ + alloy_primitives::Bytes::from_static(b"sig-aa"), + alloy_primitives::Bytes::from_static(b"sig-11"), + alloy_primitives::Bytes::from_static(b"sig-55"), + ], + referenceBlock: 0, + }; + sort_signature_data(&mut sd); + + assert_eq!(sd.signers, vec![b, c, a]); + assert_eq!(sd.signatures[0].as_ref(), b"sig-11"); + assert_eq!(sd.signatures[1].as_ref(), b"sig-55"); + assert_eq!(sd.signatures[2].as_ref(), b"sig-aa"); + } } diff --git a/packages/test-harness/src/service/handler.rs b/packages/test-harness/src/service/handler.rs new file mode 100644 index 000000000..f22683c0b --- /dev/null +++ b/packages/test-harness/src/service/handler.rs @@ -0,0 +1,212 @@ +//! Mock service-handler + service-manager deployment helpers. +//! +//! This module exposes a `MockHandler` that bundles the two reference mock +//! contracts shipped with WAVS — `SimpleServiceManager` (does quorum-weight +//! aggregation and signer sorting) and `SimpleSubmit` (the canonical +//! `IWavsServiceHandler` that decodes `DataWithId` and stores it). +//! +//! Together they let an in-process test exercise the full +//! `trigger → operator → aggregator → sign → submit → assert` lifecycle on +//! local Anvil without booting the WAVS dispatcher or a real EigenLayer/POA +//! stack: +//! +//! ```text +//! harness Anvil SimpleSubmit +//! ─────────────────────────────────────────────────────────────── +//! build payload | | +//! wrap in envelope | | +//! sign w/ operator key | | +//! submit envelope ─────►| handleSignedEnvelope ►| _SERVICE_MANAGER.validate() +//! | | store signedData +//! | | set validTriggers[id] = true +//! assert state ◄────────| isValidTriggerId(id) | +//! ``` +//! +//! The `SimpleServiceManager.validate()` path enforces: +//! - signers.length > 0 and == signatures.length +//! - referenceBlock < block.number +//! - signers sorted strictly ascending +//! - sum(operatorWeights[signers[i]]) >= checkpoint threshold +//! +//! ECDSA recovery is not done by this mock — the production +//! `WavsServiceManager` does ecrecover from the EIP-191 prefixed digest and +//! matches it against the stake registry. The envelope produced by +//! [`crate::envelope::sign_envelope`] is byte-compatible with that production +//! path; see `crate::envelope` tests for the signing-format proofs. + +use alloy_primitives::{Address, U256}; +use alloy_provider::Provider; +use anyhow::{anyhow, Context, Result}; + +use crate::envelope::{Envelope, SignatureData}; + +// --------------------------------------------------------------------------- +// sol!-generated bindings for the two mock contracts. +// +// `SimpleServiceManager` is loaded from a vendored artifact under +// `fixtures/contracts/` because forge output (`out/`) is gitignored, so the +// per-package committed copy is the only stable build input. +// +// `SimpleSubmit` is committed under `examples/contracts/solidity/abi/`. +// --------------------------------------------------------------------------- + +pub mod manager_abi { + use alloy_sol_types::sol; + sol! { + #[allow(missing_docs, clippy::too_many_arguments)] + #[sol(rpc)] + SimpleServiceManager, + "fixtures/contracts/SimpleServiceManager.json" + } +} + +pub mod handler_abi { + use alloy_sol_types::sol; + sol! { + #[allow(missing_docs, clippy::too_many_arguments)] + #[sol(rpc)] + SimpleSubmit, + "../../examples/contracts/solidity/abi/SimpleSubmit.sol/SimpleSubmit.json" + } +} + +pub use handler_abi::{ISimpleSubmit, ISimpleTrigger, SimpleSubmit}; +pub use manager_abi::SimpleServiceManager; + +/// Configuration for [`MockHandler::deploy`]. Tests that need stronger +/// quorum semantics override these — defaults match a single-operator harness +/// (one signer with weight 1, threshold 1). +#[derive(Debug, Clone)] +pub struct MockHandlerConfig { + /// (signer_address, weight) pairs registered with the manager. + pub operator_weights: Vec<(Address, U256)>, + /// Threshold weight required for `validate()` to pass. + pub threshold_weight: U256, + /// Optional total checkpoint weight (defaults to sum of operator weights). + pub total_weight: Option, +} + +impl MockHandlerConfig { + /// One-operator setup: weight 1, threshold 1. + pub fn single_operator(signer: Address) -> Self { + Self { + operator_weights: vec![(signer, U256::from(1))], + threshold_weight: U256::from(1), + total_weight: None, + } + } + + /// Build from a slice of signer addresses, assigning weight 1 to each and a + /// quorum threshold equal to `quorum` (must be <= number of signers). + pub fn quorum_of(signers: &[Address], quorum: usize) -> Result { + if signers.is_empty() { + return Err(anyhow!("at least one signer required")); + } + if quorum == 0 || quorum > signers.len() { + return Err(anyhow!( + "quorum {quorum} must be in 1..={n}", + n = signers.len() + )); + } + Ok(Self { + operator_weights: signers.iter().map(|a| (*a, U256::from(1))).collect(), + threshold_weight: U256::from(quorum), + total_weight: None, + }) + } + + fn computed_total(&self) -> U256 { + self.total_weight.unwrap_or_else(|| { + self.operator_weights + .iter() + .map(|(_, w)| *w) + .fold(U256::ZERO, |a, b| a + b) + }) + } +} + +/// A deployed pair of `SimpleServiceManager` + `SimpleSubmit` contracts that +/// together satisfy the `IWavsServiceHandler` + `IWavsServiceManager` +/// contract pair downstream apps target. +pub struct MockHandler { + /// The `SimpleServiceManager` address. + pub manager: Address, + /// The `SimpleSubmit` handler address (the address downstream tests submit + /// envelopes to). + pub handler: Address, + /// The provider used to deploy and interact with both contracts. + pub provider: P, +} + +impl MockHandler

{ + /// Deploy a fresh `SimpleServiceManager` + `SimpleSubmit` pair against the + /// provided [`MockHandlerConfig`]. + /// + /// The deployer is whichever account `provider` uses for its default sender + /// (typically Anvil's account 0). + pub async fn deploy(provider: P, config: &MockHandlerConfig) -> Result { + let manager_instance = SimpleServiceManager::deploy(provider.clone()) + .await + .context("deploy SimpleServiceManager")?; + let manager_addr = *manager_instance.address(); + tracing::debug!(manager = %manager_addr, "deployed SimpleServiceManager"); + + // Apply configuration: per-operator weight, threshold, total weight. + for (signer, weight) in &config.operator_weights { + manager_instance + .setOperatorWeight(*signer, *weight) + .send() + .await + .with_context(|| format!("setOperatorWeight for {signer}"))? + .watch() + .await?; + } + manager_instance + .setLastCheckpointThresholdWeight(config.threshold_weight) + .send() + .await + .context("setLastCheckpointThresholdWeight")? + .watch() + .await?; + manager_instance + .setLastCheckpointTotalWeight(config.computed_total()) + .send() + .await + .context("setLastCheckpointTotalWeight")? + .watch() + .await?; + + let handler_instance = SimpleSubmit::deploy(provider.clone(), manager_addr) + .await + .context("deploy SimpleSubmit")?; + let handler_addr = *handler_instance.address(); + tracing::debug!(handler = %handler_addr, manager = %manager_addr, "deployed SimpleSubmit"); + + Ok(Self { + manager: manager_addr, + handler: handler_addr, + provider, + }) + } + + /// Submit a signed envelope to the handler's `handleSignedEnvelope` entry + /// point. Returns the transaction receipt. + pub async fn submit_envelope( + &self, + envelope: &Envelope, + signature: &SignatureData, + ) -> Result { + crate::envelope::submit_envelope(&self.provider, self.handler, envelope, signature).await + } + + /// Convenience: poll `isValidTriggerId` on the handler for a given trigger. + pub async fn is_valid_trigger(&self, trigger_id: u64) -> Result { + let h = SimpleSubmit::new(self.handler, &self.provider); + let ok = h + .isValidTriggerId(trigger_id.into()) + .call() + .await + .with_context(|| format!("isValidTriggerId({trigger_id})"))?; + Ok(ok) + } +} diff --git a/packages/test-harness/src/service/mod.rs b/packages/test-harness/src/service/mod.rs index 9f140fdd3..8604de919 100644 --- a/packages/test-harness/src/service/mod.rs +++ b/packages/test-harness/src/service/mod.rs @@ -2,9 +2,13 @@ //! //! - [`config`]: declarative [`ServiceSpec`] builder consumed by all runners. //! - [`operators`]: middleware mock re-exports and [`OperatorSet`] aggregate. -//! - `runner_inproc` / `runner_subprocess`: tier-specific runners (Steps 5 & 7). +//! - [`handler`]: deployable `SimpleServiceManager` + `SimpleSubmit` mocks +//! exposing the on-chain `submit` stage so harness tests can assert against +//! contract state. +//! - `runner_inproc` / `runner_subprocess`: tier-specific runners. pub mod config; +pub mod handler; pub mod operators; #[cfg(feature = "inproc")] pub mod runner_inproc; @@ -12,6 +16,7 @@ pub mod runner_inproc; pub mod runner_subprocess; pub use config::ServiceSpec; +pub use handler::{MockHandler, MockHandlerConfig, SimpleServiceManager, SimpleSubmit}; pub use operators::{ AvsOperator, EigenlayerMiddleware, EvmMiddleware, EvmMiddlewareType, MockEvmServiceManager, OperatorSet, PoaMiddleware, ANVIL_DEPLOYER_ADDRESS, ANVIL_DEPLOYER_KEY, diff --git a/packages/test-harness/tests/end_to_end_smoke.rs b/packages/test-harness/tests/end_to_end_smoke.rs new file mode 100644 index 000000000..21ebd9c59 --- /dev/null +++ b/packages/test-harness/tests/end_to_end_smoke.rs @@ -0,0 +1,281 @@ +//! End-to-end lifecycle smoke test — the path called out by the PR review: +//! +//! trigger → operator (real WASM) → sign envelope → submit on-chain → assert +//! +//! This test boots a local Anvil instance, deploys the `SimpleServiceManager` +//! + `SimpleSubmit` reference mocks, runs the `echo_data.wasm` operator +//! component through `InProcRunner`, signs the produced payload with an +//! operator key registered in the manager, submits the signed envelope via +//! `handleSignedEnvelope`, and asserts the handler stored the trigger as +//! valid. Then negative cases prove `validate()` actually rejects bad input. +//! +//! Skips gracefully if `examples/build/components/echo_data.wasm` is missing. + +use std::path::PathBuf; + +use alloy_primitives::{Bytes, U256}; +use alloy_provider::Provider; +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::SolValue; + +use wavs_test_harness::{ + chain, + envelope::{self, sign_envelope, Envelope}, + service::{ + handler::{ISimpleSubmit, ISimpleTrigger, SimpleSubmit}, + InProcRunner, MockHandler, MockHandlerConfig, ServiceSpec, + }, +}; + +fn example_wasm(name: &str) -> PathBuf { + let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/build/components") + .join(name); + p.canonicalize().unwrap_or(p) +} + +/// Build the ABI-encoded `DataWithId(triggerId, data)` payload that +/// `SimpleSubmit.handleSignedEnvelope` decodes. +fn data_with_id_payload(trigger_id: u64, data: &[u8]) -> Vec { + let dw = ISimpleSubmit::DataWithId { + triggerId: trigger_id, + data: Bytes::copy_from_slice(data), + }; + dw.abi_encode() +} + +#[allow(dead_code)] +fn trigger_id(id: u64) -> ISimpleTrigger::TriggerId { + id.into() +} + +#[tokio::test] +async fn lifecycle_component_then_sign_then_submit_then_assert() { + let _ = tracing_subscriber::fmt::try_init(); + + let component = example_wasm("echo_data.wasm"); + let aggregator = example_wasm("simple_aggregator.wasm"); + if !component.exists() || !aggregator.exists() { + eprintln!( + "[skipping] {} or aggregator not found — run `just wasi-build-native`", + component.display() + ); + return; + } + + // 1. Spawn Anvil with a wallet-bound provider so contract deploys work. + let (provider, _anvil, _deployer) = chain::spawn_local_with_deployer() + .await + .expect("spawn local anvil"); + + // 2. Build a signer set the manager will accept (single operator, weight 1). + let operator = PrivateKeySigner::random(); + let config = MockHandlerConfig::single_operator(operator.address()); + + let handler = MockHandler::deploy(provider.clone(), &config) + .await + .expect("deploy MockHandler"); + + // 3. Run the operator component end-to-end through wavs-engine. + let spec = ServiceSpec::new() + .component_wasm(&component) + .aggregator_wasm(&aggregator) + .operator_count(1); + let runner = InProcRunner::from_spec(&spec).expect("build runner"); + + let trigger_id: u64 = 42; + let payload_in = data_with_id_payload(trigger_id, b"e2e-lifecycle"); + let outputs = runner + .run_component(payload_in.clone()) + .await + .expect("run component"); + assert_eq!(outputs.len(), 1, "echo_data emits exactly one payload"); + // echo_data with Raw trigger data returns the input bytes verbatim. + assert_eq!(outputs[0], payload_in, "operator output must match input"); + + // 4. Build + sign envelope. SimpleServiceManager requires referenceBlock < + // block.number; we just-mined block 0 on Anvil, so use 0. + let event_id = envelope::event_id_from_nonce(trigger_id); + let env_msg = Envelope::new(event_id, outputs[0].clone()); + let block_number = provider.get_block_number().await.expect("block number"); + let reference_block = if block_number > 0 { + block_number - 1 + } else { + 0 + } as u32; + let sigdata = + sign_envelope(&env_msg, &[operator.clone()], reference_block).expect("sign envelope"); + + // 5. Submit on-chain and watch the receipt. + let receipt = handler + .submit_envelope(&env_msg, &sigdata) + .await + .expect("submit envelope"); + assert!(receipt.status(), "handleSignedEnvelope must succeed"); + + // 6. Assert handler state changed. + let valid = handler + .is_valid_trigger(trigger_id) + .await + .expect("isValidTriggerId"); + assert!( + valid, + "handler must mark triggerId {trigger_id} valid after handleSignedEnvelope" + ); + + // 7. Verify the stored signed-data matches what we submitted. + let stored = SimpleSubmit::new(handler.handler, &provider) + .getSignedData(trigger_id) + .call() + .await + .expect("getSignedData"); + assert_eq!( + stored.envelope.payload, env_msg.payload, + "stored envelope payload must equal what we submitted" + ); + assert_eq!( + stored.signatureData.signers.len(), + 1, + "stored sigdata must have one signer" + ); + assert_eq!(stored.signatureData.signers[0], operator.address()); +} + +#[tokio::test] +async fn validate_rejects_zero_weight_signer() { + let _ = tracing_subscriber::fmt::try_init(); + + let component = example_wasm("echo_data.wasm"); + if !component.exists() { + eprintln!("[skipping] echo_data.wasm missing"); + return; + } + + let (provider, _anvil, _deployer) = chain::spawn_local_with_deployer() + .await + .expect("spawn local anvil"); + + // Build the manager with operator A registered, but sign with operator B. + let registered = PrivateKeySigner::random(); + let mut config = MockHandlerConfig::single_operator(registered.address()); + config.threshold_weight = U256::from(1); + let handler = MockHandler::deploy(provider.clone(), &config) + .await + .expect("deploy handler"); + + let unregistered = PrivateKeySigner::random(); + let payload = data_with_id_payload(7, b"no-quorum"); + let env_msg = Envelope::new(envelope::event_id_from_nonce(7), payload); + + let block_number = provider.get_block_number().await.unwrap(); + let reference_block = if block_number > 0 { + block_number - 1 + } else { + 0 + } as u32; + let sigdata = sign_envelope(&env_msg, &[unregistered], reference_block).unwrap(); + + let res = handler.submit_envelope(&env_msg, &sigdata).await; + assert!( + res.is_err(), + "submission with unregistered signer must revert (InsufficientQuorumZero / Insufficient Quorum)" + ); +} + +#[tokio::test] +async fn validate_rejects_out_of_order_signers() { + let _ = tracing_subscriber::fmt::try_init(); + + let component = example_wasm("echo_data.wasm"); + if !component.exists() { + eprintln!("[skipping] echo_data.wasm missing"); + return; + } + + let (provider, _anvil, _deployer) = chain::spawn_local_with_deployer() + .await + .expect("spawn local anvil"); + + // Two operators, quorum 2. + let s1 = PrivateKeySigner::random(); + let s2 = PrivateKeySigner::random(); + // SimpleServiceManager requires strict ascending order. Pick the lexically + // larger first to force an out-of-order array. + let (high, low) = if s1.address() > s2.address() { + (s1.clone(), s2.clone()) + } else { + (s2.clone(), s1.clone()) + }; + + let config = MockHandlerConfig::quorum_of(&[s1.address(), s2.address()], 2).unwrap(); + let handler = MockHandler::deploy(provider.clone(), &config) + .await + .expect("deploy handler"); + + let payload = data_with_id_payload(9, b"out-of-order"); + let env_msg = Envelope::new(envelope::event_id_from_nonce(9), payload); + let block_number = provider.get_block_number().await.unwrap(); + let reference_block = if block_number > 0 { + block_number - 1 + } else { + 0 + } as u32; + // Submit with `high` first to trigger InvalidSignatureOrder. + let sigdata = sign_envelope(&env_msg, &[high, low], reference_block).unwrap(); + + let res = handler.submit_envelope(&env_msg, &sigdata).await; + assert!( + res.is_err(), + "unsorted signer array must revert with InvalidSignatureOrder" + ); +} + +#[tokio::test] +async fn sort_signature_data_lets_submission_succeed() { + let _ = tracing_subscriber::fmt::try_init(); + + let component = example_wasm("echo_data.wasm"); + if !component.exists() { + eprintln!("[skipping] echo_data.wasm missing"); + return; + } + + let (provider, _anvil, _deployer) = chain::spawn_local_with_deployer() + .await + .expect("spawn local anvil"); + + let s1 = PrivateKeySigner::random(); + let s2 = PrivateKeySigner::random(); + let (high, low) = if s1.address() > s2.address() { + (s1.clone(), s2.clone()) + } else { + (s2.clone(), s1.clone()) + }; + let config = MockHandlerConfig::quorum_of(&[s1.address(), s2.address()], 2).unwrap(); + let handler = MockHandler::deploy(provider.clone(), &config) + .await + .expect("deploy handler"); + + let payload = data_with_id_payload(11, b"sort-and-submit"); + let env_msg = Envelope::new(envelope::event_id_from_nonce(11), payload); + let block_number = provider.get_block_number().await.unwrap(); + let reference_block = if block_number > 0 { + block_number - 1 + } else { + 0 + } as u32; + + // Build sigdata in the wrong order on purpose, then sort. + let mut sigdata = sign_envelope(&env_msg, &[high, low], reference_block).unwrap(); + envelope::sort_signature_data(&mut sigdata); + + let receipt = handler + .submit_envelope(&env_msg, &sigdata) + .await + .expect("submit after sort"); + assert!( + receipt.status(), + "sorted multi-signer submission must succeed" + ); + assert!(handler.is_valid_trigger(11).await.unwrap()); +} From 1c3f834d2bda90fd756d71de6a32522bb3a872e9 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 06:26:35 +0000 Subject: [PATCH 14/19] feat(test-harness): Chainlink oracle mocking via anvil_setCode (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR feedback that the harness needed operator-side oracle mocking before downstream apps could drive real strategy WASM end-to-end on a forked chain. - `chain::oracle::install_chainlink_aggregator_v3(provider, addr, price)` replaces the runtime bytecode at any address with an AggregatorV3- compatible mock. The address stays the same, so contracts that hard- code the production feed address (wavs-defi's SmartVaultStorage, AAVE oracle wires, etc.) keep working without modification. - `chain::oracle::set_chainlink_price(provider, addr, I256)` updates the price reported by an installed mock. - `chain::oracle::chainlink_usd(f64) -> I256` scales human-readable USD to Chainlink's 8-decimal convention. The mock runtime bytecode is embedded as a hex constant (compiled with solc 0.8.27, equivalent to wavs-defi's MockChainlink.sol) — no forge/solc step is required at harness build time. Tests: lib roundtrip + 1 local smoke + 1 fork smoke (skips without FORK_RPC_URL). Total 40 pass (27 lib + 5 chain + 4 e2e + 2 oracle + 1 inproc + 1 doc). Pyth + other oracle shapes are tracked as a follow-up; the same `anvil_set_code` pattern applies — only the bytecode differs. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/test-harness/src/chain/mod.rs | 4 + packages/test-harness/src/chain/oracle.rs | 164 ++++++++++++++++++++ packages/test-harness/tests/oracle_smoke.rs | 92 +++++++++++ 3 files changed, 260 insertions(+) create mode 100644 packages/test-harness/src/chain/oracle.rs create mode 100644 packages/test-harness/tests/oracle_smoke.rs diff --git a/packages/test-harness/src/chain/mod.rs b/packages/test-harness/src/chain/mod.rs index bf13128dc..b913064f8 100644 --- a/packages/test-harness/src/chain/mod.rs +++ b/packages/test-harness/src/chain/mod.rs @@ -6,6 +6,7 @@ pub mod anvil; pub mod fork; pub mod impersonate; pub mod logging; +pub mod oracle; pub mod snapshot; pub mod time; @@ -16,5 +17,8 @@ pub use impersonate::{ enable_auto_impersonate, impersonate_funded, set_balance, stop_impersonating, ONE_ETH, }; pub use logging::{redact_key, redact_url}; +pub use oracle::{ + chainlink_usd, install_chainlink_aggregator_v3, set_chainlink_price, IChainlinkV3Mock, +}; pub use snapshot::{revert, snapshot, SnapshotGuard}; pub use time::{increase_time, mine_blocks, set_automine, set_next_block_timestamp}; diff --git a/packages/test-harness/src/chain/oracle.rs b/packages/test-harness/src/chain/oracle.rs new file mode 100644 index 000000000..d8cfde92a --- /dev/null +++ b/packages/test-harness/src/chain/oracle.rs @@ -0,0 +1,164 @@ +//! Oracle mocking helpers for forked-chain tests. +//! +//! Downstream apps (`wavs-defi`, `wavs-aave-guardian`, …) read prices from +//! production oracle contracts — Chainlink for ETH/USD on Base, Pyth for +//! Avantis settlement, and so on. A reproducible integration test needs to +//! drive those reads with predictable values without leaving the fork. +//! +//! The cheapest path is `anvil_setCode`: replace the runtime bytecode at the +//! oracle address with a minimal mock that exposes a `setPrice(int256)` +//! setter. After install, any contract reading `latestRoundData()` on that +//! address sees the mock's value. The production address stays unchanged, so +//! contract code paths that hard-code it (and there are many — see +//! `wavs-defi`'s `SmartVaultStorage`) keep working untouched. +//! +//! v1 ships a Chainlink `AggregatorV3Interface` mock. Pyth + other oracle +//! shapes are tracked as a follow-up — the same `anvil_set_code` pattern +//! applies; only the bytecode differs. + +use alloy_network::Ethereum; +use alloy_primitives::{hex, Address, Bytes, I256}; +use alloy_provider::{ext::AnvilApi, Provider}; +use alloy_sol_types::{sol, SolCall}; +use anyhow::{Context, Result}; + +// --------------------------------------------------------------------------- +// Chainlink AggregatorV3 mock — replaces the runtime code at the live feed. +// +// Equivalent to `wavs-defi`'s MockChainlink.sol: stored `int256 price`, +// `setPrice(int256)`, `latestRoundData() -> (roundId, answer, startedAt, +// updatedAt, answeredInRound)` with answer = stored price, timestamps = +// block.timestamp, roundId = 1. `decimals()` returns 8 to match production +// Chainlink feeds. Compiled with solc 0.8.27, runtime bytecode embedded +// below as a hex string — no `solc` / `forge` step required at harness +// build time. +// --------------------------------------------------------------------------- + +const CHAINLINK_V3_MOCK_RUNTIME: &str = "6080806040526004361015610012575f80fd5b5f3560e01c908163313ce56714610145575080637284e416146100c1578063a035b1fe146100a5578063f7a308061461008d5763feaf968c14610053575f80fd5b34610089575f3660031901126100895760a05f546040519060018252602082015242604082015242606082015260016080820152f35b5f80fd5b34610089576020366003190112610089576004355f55005b34610089575f3660031901126100895760205f54604051908152f35b34610089575f366003190112610089576040516040810181811067ffffffffffffffff821117610131576040526007815260406020820191661155120bd554d160ca1b83528151928391602083525180918160208501528484015e5f828201840152601f01601f19168101030190f35b634e487b7160e01b5f52604160045260245ffd5b34610089575f3660031901126100895780600860209252f3fea2646970667358221220888eeb3f5309afd8589b5038c99978400f4b86c23441180cdf4dd21be69c3af164736f6c634300081b0033"; + +sol! { + #[allow(missing_docs)] + #[sol(rpc)] + interface IChainlinkV3Mock { + function setPrice(int256 _price) external; + function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80); + function decimals() external view returns (uint8); + } +} + +/// Install a Chainlink AggregatorV3-compatible mock at `feed_address` and seed +/// it with `initial_price` (8-decimal integer, e.g. `chainlink_usd(3000.0)`). +/// +/// Uses Anvil's `setCode` cheatcode — the address stays the same so downstream +/// contracts that hard-code the production feed address keep working. +/// +/// # Example +/// +/// ```no_run +/// use alloy_primitives::Address; +/// use wavs_test_harness::chain::oracle::{chainlink_usd, install_chainlink_aggregator_v3}; +/// # async fn doctest() -> anyhow::Result<()> { +/// let (provider, _anvil) = wavs_test_harness::chain::spawn_local().await?; +/// let chainlink_eth_usd: Address = "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70".parse()?; +/// install_chainlink_aggregator_v3(&provider, chainlink_eth_usd, chainlink_usd(3_000.0)).await?; +/// # Ok(()) } +/// ``` +pub async fn install_chainlink_aggregator_v3( + provider: &P, + feed_address: Address, + initial_price: I256, +) -> Result<()> +where + P: AnvilApi, +{ + let bytecode = hex::decode(CHAINLINK_V3_MOCK_RUNTIME) + .context("decode embedded ChainlinkV3Mock runtime bytecode")?; + provider + .anvil_set_code(feed_address, Bytes::from(bytecode)) + .await + .context("anvil_set_code on chainlink mock")?; + set_chainlink_price(provider, feed_address, initial_price).await?; + tracing::debug!( + feed = %feed_address, + price = %initial_price, + "installed Chainlink AggregatorV3 mock" + ); + Ok(()) +} + +/// Update the price reported by a previously-installed Chainlink mock. Returns +/// `Err` if the address holds something other than the mock. +pub async fn set_chainlink_price( + provider: &P, + feed_address: Address, + price: I256, +) -> Result<()> { + let calldata = IChainlinkV3Mock::setPriceCall { _price: price }.abi_encode(); + let tx = alloy_rpc_types_eth::TransactionRequest::default() + .to(feed_address) + .input(calldata.into()); + let pending = provider + .send_transaction(tx) + .await + .context("send setPrice tx")?; + let receipt = pending + .get_receipt() + .await + .context("await setPrice receipt")?; + if !receipt.status() { + anyhow::bail!("setPrice reverted on {feed_address} (mock not installed?)"); + } + Ok(()) +} + +/// Convenience: convert a USD price in 8-decimal form (Chainlink convention) +/// to the `int256` argument format. E.g. `chainlink_usd(3_000.0)` → +/// `3000_00000000`. +pub fn chainlink_usd(price_usd: f64) -> I256 { + let scaled = (price_usd * 1e8).round() as i128; + I256::try_from(scaled).expect("chainlink_usd price overflow") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chainlink_usd_scales_correctly() { + assert_eq!( + chainlink_usd(3000.0), + I256::try_from(3_000_00000000i64).unwrap() + ); + assert_eq!(chainlink_usd(0.5), I256::try_from(50_000_000i64).unwrap()); + } + + #[tokio::test] + async fn chainlink_mock_install_and_set_price_roundtrip() { + let (provider, _anvil, _deployer) = + crate::chain::spawn_local_with_deployer().await.unwrap(); + let feed = Address::from([0xab; 20]); + install_chainlink_aggregator_v3(&provider, feed, chainlink_usd(3000.0)) + .await + .expect("install chainlink mock"); + + // Read it back through the production interface. + let mock = IChainlinkV3Mock::new(feed, &provider); + let round = mock + .latestRoundData() + .call() + .await + .expect("latestRoundData"); + assert_eq!(round._1, chainlink_usd(3000.0)); + + // Update via setPrice and read again. + set_chainlink_price(&provider, feed, chainlink_usd(2500.0)) + .await + .unwrap(); + let round = mock.latestRoundData().call().await.unwrap(); + assert_eq!(round._1, chainlink_usd(2500.0)); + + // decimals() should return 8 — matches production Chainlink feeds. + let d = mock.decimals().call().await.unwrap(); + assert_eq!(d, 8); + } +} diff --git a/packages/test-harness/tests/oracle_smoke.rs b/packages/test-harness/tests/oracle_smoke.rs new file mode 100644 index 000000000..bad96c764 --- /dev/null +++ b/packages/test-harness/tests/oracle_smoke.rs @@ -0,0 +1,92 @@ +//! Oracle-mocking smoke tests. +//! +//! Demonstrates the `anvil_setCode` pattern for installing a deterministic +//! oracle reading at a production address — the exact thing downstream apps +//! like `wavs-defi` need to drive their delta/health-check logic. +//! +//! - `chainlink_at_arbitrary_address_local`: local Anvil, mock at a fresh address. +//! - `chainlink_at_base_eth_usd_fork`: fork-tier, replaces the live Base ETH/USD +//! feed. Skipped unless `FORK_RPC_URL` (and optionally `FORK_BLOCK_NUMBER`) +//! are set. + +use alloy_primitives::{Address, I256}; + +use wavs_test_harness::{ + chain::{ + self, + oracle::{ + chainlink_usd, install_chainlink_aggregator_v3, set_chainlink_price, IChainlinkV3Mock, + }, + }, + fixtures::ChainProfile, +}; + +#[tokio::test] +async fn chainlink_at_arbitrary_address_local() { + let _ = tracing_subscriber::fmt::try_init(); + + let (provider, _anvil, _deployer) = chain::spawn_local_with_deployer() + .await + .expect("spawn anvil with deployer"); + + let feed = Address::from([0xc1; 20]); + install_chainlink_aggregator_v3(&provider, feed, chainlink_usd(3_000.0)) + .await + .expect("install chainlink mock"); + + let mock = IChainlinkV3Mock::new(feed, &provider); + let r = mock.latestRoundData().call().await.unwrap(); + assert_eq!(r._1, chainlink_usd(3_000.0)); + + // Drive a price-shock scenario. + set_chainlink_price(&provider, feed, chainlink_usd(2_700.0)) + .await + .unwrap(); + let r = mock.latestRoundData().call().await.unwrap(); + assert_eq!(r._1, chainlink_usd(2_700.0)); + assert_eq!(mock.decimals().call().await.unwrap(), 8); +} + +#[tokio::test] +async fn chainlink_at_base_eth_usd_fork() { + let _ = tracing_subscriber::fmt::try_init(); + + if std::env::var("FORK_RPC_URL").is_err() { + eprintln!("[skipping] FORK_RPC_URL not set"); + return; + } + + let profile = ChainProfile::load("base").expect("load base profile"); + let opts = chain::ForkOptions::from_profile(&profile).expect("from_profile"); + let (provider, _anvil) = chain::spawn_fork(opts).await.expect("spawn base fork"); + + // We need a wallet-bound provider for the setPrice call to be signed. + // For fork tests, easiest is to also spawn a wallet via the same chain. + let chainlink = profile.address("chainlink_eth_usd").expect("chainlink"); + + // Capture a real read first to prove we're on a live fork. + let real = IChainlinkV3Mock::new(chainlink, &provider) + .latestRoundData() + .call() + .await + .expect("read real chainlink"); + assert!(real._1 > I256::ZERO, "live chainlink feed must be positive"); + let real_price = real._1; + eprintln!("[fork] live ETH/USD before install: {real_price}"); + + // anvil_set_code doesn't need signing — install the mock, then we'll use + // an impersonated anvil deployer to setPrice. + install_chainlink_aggregator_v3(&provider, chainlink, chainlink_usd(123.45)) + .await + .expect("install mock on live chainlink"); + + // Confirm the read shape now returns our value. + let after = IChainlinkV3Mock::new(chainlink, &provider) + .latestRoundData() + .call() + .await + .expect("read mocked chainlink"); + assert_eq!(after._1, chainlink_usd(123.45)); + // The mock's latestRoundData returns roundId = 1. + assert_eq!(after._0, alloy_primitives::aliases::U80::from(1u64)); +} From ccb318cb260e3f41e75234ac73759f4f27158871 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 06:40:19 +0000 Subject: [PATCH 15/19] feat(test-harness): functional subprocess runner (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous `unimplemented` stub. The subprocess runner now: - Resolves the `wavs` binary path (explicit > WAVS_BINARY env > target/{debug,release}/wavs probe). - Generates a minimal `wavs.toml` in a tempdir HOME with optional RPC-URL wiring for `evm:local`. - Spawns the binary with `--home`, `--data`, `--port`, `--dev-endpoints-enabled true`, and the canonical test mnemonic (configurable). Picks a random ephemeral port by default. - Polls `GET /health` until 200 or `startup_timeout` (default 30s). Fails fast with captured stdout/stderr if the child exits early. - Exposes `register_service(json)` for `POST /dev/services` and `health()` for the JSON status check. - Cleans up on `shutdown()` (SIGINT → wait → SIGKILL fallback) and on `Drop`. Tempdir handles outlive the child. `tests/subprocess_smoke.rs` exercises spawn + /health probe end-to-end. The test skips gracefully when the binary is missing or built for a different architecture (e.g. a stale macOS binary on a Linux runner). Deferred to a follow-up: - Full trigger emission + quorum waiting harness — already composable today by combining `SubprocessRunner` with `chain::*` helpers. - Bundled `wavs.toml` chain profiles. - Multi-operator P2P quorum coverage (layer-tests still owns that). Tests: 32 lib + 5 chain + 4 e2e + 2 oracle + 1 inproc + 1 subprocess + 1 doc = 46 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 2 + packages/test-harness/Cargo.toml | 6 + .../src/service/runner_subprocess.rs | 474 ++++++++++++++++-- .../test-harness/tests/subprocess_smoke.rs | 59 +++ 4 files changed, 503 insertions(+), 38 deletions(-) create mode 100644 packages/test-harness/tests/subprocess_smoke.rs diff --git a/Cargo.lock b/Cargo.lock index c4d1daa30..fd521b931 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14609,6 +14609,8 @@ dependencies = [ "anyhow", "async-trait", "futures", + "libc", + "reqwest 0.12.28", "serde", "serde_json", "temp-env", diff --git a/packages/test-harness/Cargo.toml b/packages/test-harness/Cargo.toml index a1623b867..fd8ece00a 100644 --- a/packages/test-harness/Cargo.toml +++ b/packages/test-harness/Cargo.toml @@ -51,6 +51,12 @@ alloy-sol-types = { workspace = true } alloy-sol-macro = { workspace = true } alloy-rpc-types-eth = { workspace = true } +# Subprocess runner-only deps. Cheap enough to leave outside the feature gate. +reqwest = { workspace = true, features = ["json"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [dev-dependencies] tokio = { workspace = true } tempfile = { workspace = true } diff --git a/packages/test-harness/src/service/runner_subprocess.rs b/packages/test-harness/src/service/runner_subprocess.rs index ef3d154de..a356749e7 100644 --- a/packages/test-harness/src/service/runner_subprocess.rs +++ b/packages/test-harness/src/service/runner_subprocess.rs @@ -1,31 +1,47 @@ //! Subprocess runner — spawns the real `wavs` binary as a child process. //! -//! **Preview status.** The subprocess tier ships in this release with API shape -//! locked but lifecycle methods stubbed. The intent is that consumers can write -//! their test harness against this surface and the implementation can be filled -//! in without breaking their tests. +//! The subprocess tier exists for tests that need every dispatcher subsystem in +//! the loop: real trigger streams, libp2p submission gossip, the actual HTTP +//! server, and the production WASM execution path. Boot the binary, register a +//! service, drive triggers via on-chain emission, wait for the submission tx, +//! and assert on contract state. //! -//! What ships now: -//! - [`SubprocessConfig`] — full builder for the inputs the runner needs. -//! - [`SubprocessRunner::start`] — returns a descriptive `unimplemented` error. +//! The in-process tier ([`super::InProcRunner`]) remains canonical for fast PR +//! tests; this tier is for nightly fork matrices and pre-release verification. //! -//! What's planned (tracked under Lay3rLabs/WAVS#1147 follow-ups): -//! - Spawn `wavs` with `--dev-endpoints-enabled=true -//! --disable-trigger-networking=true --disable-submission-networking=true`, -//! piping `WAVS_HOME` and `WAVS_DATA` to a tempdir. -//! - HTTP health-probe loop until the node accepts service registrations. -//! - Service deployment via the HTTP `/services` endpoint. -//! - Trigger emission via dev-tool's `send-triggers` path. -//! - Quorum + submission waiting via HTTP polling. -//! - Clean shutdown via SIGINT + tempdir cleanup on Drop. +//! ## What v1 ships //! -//! Tests that need real end-to-end coverage should use [`super::InProcRunner`] -//! for now. Tests can be authored against this API; today they'll surface the -//! preview error message. +//! - [`SubprocessConfig`] builder for the spawn inputs. +//! - [`SubprocessRunner::start`] — generates a minimal `wavs.toml`, picks a +//! random port, spawns the binary with `--dev-endpoints-enabled true`, and +//! polls `/health` until the server is up. +//! - [`SubprocessRunner::register_service`] — `POST /dev/services` with a JSON +//! service definition. +//! - [`SubprocessRunner::http_base`] — base URL `http://127.0.0.1:` for +//! tests that need to drive other HTTP endpoints directly. +//! - Clean shutdown on [`SubprocessRunner::shutdown`] or `Drop`: SIGINT, then +//! wait, then SIGKILL if the child doesn't exit. +//! +//! Locating the `wavs` binary: set `SubprocessConfig::wavs_binary(path)` +//! explicitly, or set the `WAVS_BINARY` env var, or place a built binary at +//! `/target/{debug,release}/wavs` (the runner probes both). +//! +//! ## Follow-ups (deferred for the next PR) +//! +//! - Trigger emission and quorum waiting via the harness's chain primitives +//! (works today — just compose `chain::*` with this runner). +//! - Bundled `wavs.toml` chain configs for the common Anvil/fork scenarios. +//! - Multi-operator P2P quorum tests (layer-tests still covers that path +//! in-process today). -use std::path::{Path, PathBuf}; +use std::{ + path::{Path, PathBuf}, + process::Stdio, + time::{Duration, Instant}, +}; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; +use tokio::{io::AsyncReadExt, process::Child}; use crate::service::ServiceSpec; @@ -35,7 +51,12 @@ pub struct SubprocessConfig { wavs_binary: Option, rpc_url: Option, data_dir: Option, + home_dir: Option, + port: Option, extra_env: Vec<(String, String)>, + extra_args: Vec, + startup_timeout: Option, + signing_mnemonic: Option, } impl SubprocessConfig { @@ -44,14 +65,16 @@ impl SubprocessConfig { Self::default() } - /// Path to the `wavs` binary. If unset, the runner attempts to locate it via - /// `$PATH` or by building it from the WAVS repo (preview behavior). + /// Path to the `wavs` binary. If unset, the runner probes + /// `WAVS_BINARY` env, then `target/{debug,release}/wavs` under + /// `CARGO_WORKSPACE_DIR` (or `..` of CARGO_MANIFEST_DIR). pub fn wavs_binary(mut self, path: impl AsRef) -> Self { self.wavs_binary = Some(path.as_ref().to_path_buf()); self } /// EVM RPC URL the WAVS node connects to (Anvil endpoint or fork URL). + /// Wired into the generated `wavs.toml` as the default chain. pub fn rpc_url(mut self, url: impl Into) -> Self { self.rpc_url = Some(url.into()); self @@ -63,12 +86,44 @@ impl SubprocessConfig { self } + /// Directory the WAVS node uses for `WAVS_HOME`. Defaults to a tempdir + /// containing a generated `wavs.toml`. + pub fn home_dir(mut self, path: impl AsRef) -> Self { + self.home_dir = Some(path.as_ref().to_path_buf()); + self + } + + /// Bind port for the HTTP server. If unset the OS picks one. + pub fn port(mut self, port: u16) -> Self { + self.port = Some(port); + self + } + /// Add a `(KEY, VALUE)` pair to the spawned process environment. pub fn env(mut self, key: impl Into, value: impl Into) -> Self { self.extra_env.push((key.into(), value.into())); self } + /// Append an extra CLI flag the runner will pass to the binary. + pub fn arg(mut self, arg: impl Into) -> Self { + self.extra_args.push(arg.into()); + self + } + + /// Override the startup health-probe timeout. Defaults to 30 seconds. + pub fn startup_timeout(mut self, d: Duration) -> Self { + self.startup_timeout = Some(d); + self + } + + /// Override the BIP-39 mnemonic used for the operator signing key. + /// Defaults to the canonical test mnemonic ("test test test … junk"). + pub fn signing_mnemonic(mut self, m: impl Into) -> Self { + self.signing_mnemonic = Some(m.into()); + self + } + pub fn wavs_binary_path(&self) -> Option<&Path> { self.wavs_binary.as_deref() } @@ -86,36 +141,349 @@ impl SubprocessConfig { } } -/// Subprocess runner handle. -/// -/// **Preview**: `start` returns an explicit `unimplemented` error today. +/// Subprocess runner handle. `Drop` reaps the child cleanly. pub struct SubprocessRunner { - #[allow(dead_code)] config: SubprocessConfig, #[allow(dead_code)] spec: ServiceSpec, + child: Option, + port: u16, + // Tempdir handles — must outlive the child. + _home_dir: Option, + _data_dir: Option, + http: reqwest::Client, } impl SubprocessRunner { /// Build a subprocess runner from a config and service spec. + /// + /// The spec is validated lazily — at [`Self::register_service`] time, not + /// here — so tests that only need to spawn the binary and probe `/health` + /// can pass [`ServiceSpec::new()`] without populating WASM paths. pub fn new(config: SubprocessConfig, spec: ServiceSpec) -> Result { - spec.validate()?; - Ok(Self { config, spec }) + Ok(Self { + config, + spec, + child: None, + port: 0, + _home_dir: None, + _data_dir: None, + http: reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .context("build reqwest client")?, + }) + } + + /// HTTP base URL once the binary is running. Empty until [`Self::start`]. + pub fn http_base(&self) -> String { + format!("http://127.0.0.1:{}", self.port) + } + + /// Start the WAVS subprocess and wait for `/health` to respond. + /// + /// Returns the bound HTTP base URL. + pub async fn start(&mut self) -> Result { + // 1. Pick a port. + let port = match self.config.port { + Some(p) => p, + None => pick_random_port()?, + }; + self.port = port; + + // 2. Resolve binary path. + let bin = resolve_wavs_binary(self.config.wavs_binary.as_deref())?; + tracing::info!(binary = %bin.display(), port, "spawning wavs subprocess"); + + // 3. Prepare home + data dirs. Either user-supplied (path) or tempdir. + let (home_path, home_keep) = match self.config.home_dir.clone() { + Some(p) => (p, None), + None => { + let td = tempfile::tempdir().context("home tempdir")?; + (td.path().to_path_buf(), Some(td)) + } + }; + let (data_path, data_keep) = match self.config.data_dir.clone() { + Some(p) => (p, None), + None => { + let td = tempfile::tempdir().context("data tempdir")?; + (td.path().to_path_buf(), Some(td)) + } + }; + + // 4. Write a minimal wavs.toml into HOME. The binary needs *some* + // config file or the chain registry stays empty; we wire in + // the user-supplied RPC URL if present. + write_minimal_config(&home_path, self.config.rpc_url.as_deref()) + .context("write wavs.toml")?; + + // 5. Build argv. + let mut cmd = tokio::process::Command::new(&bin); + cmd.arg("--home") + .arg(&home_path) + .arg("--data") + .arg(&data_path) + .arg("--port") + .arg(port.to_string()) + .arg("--host") + .arg("127.0.0.1") + .arg("--dev-endpoints-enabled") + .arg("true"); + + let mnemonic = self + .config + .signing_mnemonic + .clone() + .unwrap_or_else(|| DEFAULT_TEST_MNEMONIC.to_string()); + cmd.env("WAVS_SIGNING_MNEMONIC", &mnemonic); + + for (k, v) in &self.config.extra_env { + cmd.env(k, v); + } + for a in &self.config.extra_args { + cmd.arg(a); + } + + // Capture stdout/stderr so a failed boot surfaces in test output but + // doesn't spew into the parent test runner unconditionally. + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let mut child = cmd.spawn().with_context(|| { + format!( + "spawn wavs binary at {}; ensure it's built (`cargo build -p wavs`)", + bin.display() + ) + })?; + + // 6. Spawn a forwarder for stdout/stderr so we can collect log output + // on failure without losing it. + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let log_buf = std::sync::Arc::new(std::sync::Mutex::new(String::new())); + if let Some(mut s) = stdout { + let buf = log_buf.clone(); + tokio::spawn(async move { + let mut tmp = [0u8; 4096]; + while let Ok(n) = s.read(&mut tmp).await { + if n == 0 { + break; + } + let chunk = String::from_utf8_lossy(&tmp[..n]).into_owned(); + buf.lock().unwrap().push_str(&chunk); + } + }); + } + if let Some(mut s) = stderr { + let buf = log_buf.clone(); + tokio::spawn(async move { + let mut tmp = [0u8; 4096]; + while let Ok(n) = s.read(&mut tmp).await { + if n == 0 { + break; + } + let chunk = String::from_utf8_lossy(&tmp[..n]).into_owned(); + buf.lock().unwrap().push_str(&chunk); + } + }); + } + + self.child = Some(child); + self._home_dir = home_keep; + self._data_dir = data_keep; + + // 7. Poll /health until the server responds. + let timeout = self.config.startup_timeout.unwrap_or(STARTUP_TIMEOUT); + let deadline = Instant::now() + timeout; + let base = self.http_base(); + let health = format!("{base}/health"); + loop { + if let Ok(resp) = self.http.get(&health).send().await { + if resp.status().is_success() { + tracing::info!(base = %base, "wavs subprocess ready"); + return Ok(base); + } + } + if Instant::now() > deadline { + let logs = log_buf.lock().unwrap().clone(); + return Err(anyhow!( + "wavs subprocess did not become healthy within {:?}\n--- captured logs ---\n{}", + timeout, + logs + )); + } + // If the child died early, fail fast with its output. + if let Some(child) = self.child.as_mut() { + if let Ok(Some(status)) = child.try_wait() { + let logs = log_buf.lock().unwrap().clone(); + return Err(anyhow!( + "wavs subprocess exited early with {status}\n--- captured logs ---\n{logs}" + )); + } + } + tokio::time::sleep(Duration::from_millis(200)).await; + } } - /// Start the WAVS subprocess. + /// Register a service definition with the running node via the + /// `POST /dev/services` endpoint. The body is the JSON serialization of + /// [`wavs_types::Service`]; tests can build one via + /// [`crate::service::InProcRunner::service`] (which returns a `&Service` + /// the test can clone into the registration request) or by hand. /// - /// **Preview**: Not yet implemented. Returns an error directing callers to - /// either use [`super::InProcRunner`] or track the follow-up issue. - pub async fn start(&self) -> Result<()> { - Err(anyhow!( - "subprocess tier is preview — use InProcRunner for now; \ - see packages/test-harness/src/service/runner_subprocess.rs and \ - Lay3rLabs/WAVS#1147 follow-ups for the planned implementation" - )) + /// Validates the stored `ServiceSpec` lazily so callers passing + /// [`ServiceSpec::new()`] for spawn-only tests don't error here either — + /// only the registration path needs the spec's wasm paths. + pub async fn register_service(&self, body_json: &serde_json::Value) -> Result { + self.spec.validate().context("spec validation")?; + let url = format!("{}/dev/services", self.http_base()); + let resp = self + .http + .post(&url) + .json(body_json) + .send() + .await + .context("POST /dev/services")?; + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(anyhow!("POST /dev/services failed: {status} body={body}")); + } + Ok(body) + } + + /// Probe the `/health` endpoint and return its JSON response. + pub async fn health(&self) -> Result { + let url = format!("{}/health", self.http_base()); + let resp = self.http.get(&url).send().await.context("GET /health")?; + if !resp.status().is_success() { + return Err(anyhow!("GET /health returned {}", resp.status())); + } + let json = resp.json().await.context("parse /health JSON")?; + Ok(json) + } + + /// SIGINT the child and wait for clean exit. Returns the exit status. On + /// timeout, SIGKILLs the child. + pub async fn shutdown(mut self) -> Result { + self.shutdown_inner().await + } + + async fn shutdown_inner(&mut self) -> Result { + let Some(mut child) = self.child.take() else { + return Err(anyhow!("subprocess not running")); + }; + + // Try SIGINT first. + #[cfg(unix)] + { + if let Some(pid) = child.id() { + let pid = i32::try_from(pid).unwrap_or(0); + unsafe { + libc::kill(pid, libc::SIGINT); + } + } + } + + let exit = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; + match exit { + Ok(Ok(status)) => Ok(status), + _ => { + let _ = child.kill().await; + let status = child.wait().await.context("await sigkilled child")?; + Ok(status) + } + } + } +} + +impl Drop for SubprocessRunner { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + // Best-effort cleanup; can't await in Drop. + let _ = child.start_kill(); + } } } +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); + +const DEFAULT_TEST_MNEMONIC: &str = "test test test test test test test test test test test junk"; + +fn pick_random_port() -> Result { + let listener = std::net::TcpListener::bind("127.0.0.1:0").context("bind ephemeral port")?; + Ok(listener.local_addr()?.port()) +} + +/// Exposed for integration tests that want to probe whether the binary is +/// available before attempting a spawn (and skip gracefully if not). +pub fn resolve_wavs_binary_for_tests() -> Result { + resolve_wavs_binary(None) +} + +fn resolve_wavs_binary(explicit: Option<&Path>) -> Result { + if let Some(p) = explicit { + if !p.exists() { + return Err(anyhow!( + "explicit wavs_binary path {} does not exist", + p.display() + )); + } + return Ok(p.to_path_buf()); + } + if let Ok(p) = std::env::var("WAVS_BINARY") { + let path = PathBuf::from(p); + if path.exists() { + return Ok(path); + } + } + // Probe target/{debug,release}/wavs relative to the workspace root. + // CARGO_MANIFEST_DIR points at this crate (`packages/test-harness`); the + // workspace root is two levels up. + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace = manifest + .parent() + .and_then(|p| p.parent()) + .ok_or_else(|| anyhow!("could not derive workspace dir from CARGO_MANIFEST_DIR"))?; + for profile in ["debug", "release"] { + let probe = workspace.join("target").join(profile).join("wavs"); + if probe.exists() { + return Ok(probe); + } + } + Err(anyhow!( + "could not locate wavs binary — set SubprocessConfig::wavs_binary(), \ + the WAVS_BINARY env var, or `cargo build -p wavs` first" + )) +} + +fn write_minimal_config(home: &Path, rpc_url: Option<&str>) -> Result<()> { + let cfg_path = home.join("wavs.toml"); + let mut contents = String::from( + "# Auto-generated by wavs-test-harness::SubprocessRunner.\n\ + # Minimal config — extend by writing your own wavs.toml at home_dir().\n\ + \n\ + [default]\n\ + log_level = [\"info\", \"wavs=debug\"]\n\ + \n\ + [wavs]\n\ + host = \"127.0.0.1\"\n\ + max_wasm_fuel = 1000000000\n\ + max_execution_seconds = 30\n\ + \n", + ); + if let Some(url) = rpc_url { + contents.push_str(&format!( + "[wavs.chains.evm.\"evm:local\"]\nws_endpoint = \"{url}\"\nhttp_endpoint = \"{url}\"\npoll_interval_ms = 100\n\n" + )); + } + std::fs::write(&cfg_path, contents).with_context(|| format!("write {}", cfg_path.display()))?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -135,4 +503,34 @@ mod tests { assert_eq!(c.rpc_url_value(), Some("http://127.0.0.1:8545")); assert_eq!(c.extra_env_pairs().len(), 1); } + + #[test] + fn pick_random_port_works() { + let p = pick_random_port().unwrap(); + assert!(p > 1024, "ephemeral port should be > 1024, got {p}"); + } + + #[test] + fn resolve_wavs_binary_explicit_missing() { + let res = resolve_wavs_binary(Some(Path::new("/tmp/definitely-not-here-xyz"))); + assert!(res.is_err()); + } + + #[test] + fn write_minimal_config_includes_rpc_when_set() { + let td = tempfile::tempdir().unwrap(); + write_minimal_config(td.path(), Some("http://127.0.0.1:8545")).unwrap(); + let body = std::fs::read_to_string(td.path().join("wavs.toml")).unwrap(); + assert!(body.contains("http://127.0.0.1:8545")); + assert!(body.contains("[default]")); + assert!(body.contains("[wavs]")); + } + + #[test] + fn write_minimal_config_omits_rpc_when_unset() { + let td = tempfile::tempdir().unwrap(); + write_minimal_config(td.path(), None).unwrap(); + let body = std::fs::read_to_string(td.path().join("wavs.toml")).unwrap(); + assert!(!body.contains("[wavs.chains.")); + } } diff --git a/packages/test-harness/tests/subprocess_smoke.rs b/packages/test-harness/tests/subprocess_smoke.rs new file mode 100644 index 000000000..c0fdf07c7 --- /dev/null +++ b/packages/test-harness/tests/subprocess_smoke.rs @@ -0,0 +1,59 @@ +//! Subprocess runner smoke test — spawn the real `wavs` binary, poll /health, +//! shut it down cleanly. +//! +//! Skips gracefully if the binary can't be located (`WAVS_BINARY` unset and +//! no `target/{debug,release}/wavs` present). Run `cargo build -p wavs` to +//! enable it locally. + +#![cfg(feature = "subprocess")] + +use std::time::Duration; + +use wavs_test_harness::service::{ + runner_subprocess::resolve_wavs_binary_for_tests, ServiceSpec, SubprocessConfig, + SubprocessRunner, +}; + +fn locate_or_skip() -> Option { + let Ok(bin) = resolve_wavs_binary_for_tests() else { + eprintln!( + "[skipping] wavs binary not found. Run `cargo build -p wavs` or set WAVS_BINARY=/path/to/wavs" + ); + return None; + }; + // Quick exec-check: a stale macOS binary on a Linux runner shows up as + // "Exec format error" — skip rather than fail the test. + match std::process::Command::new(&bin).arg("--version").output() { + Ok(out) if out.status.success() || !out.stderr.is_empty() => Some(bin), + Ok(_) => { + eprintln!("[skipping] wavs binary at {bin:?} ran but emitted no output"); + None + } + Err(e) => { + eprintln!("[skipping] wavs binary at {bin:?} is not executable on this platform: {e}"); + None + } + } +} + +#[tokio::test] +async fn subprocess_spawn_and_health() { + let _ = tracing_subscriber::fmt::try_init(); + let Some(bin) = locate_or_skip() else { + return; + }; + + let spec = ServiceSpec::new(); + let config = SubprocessConfig::new() + .wavs_binary(&bin) + .startup_timeout(Duration::from_secs(45)); + let mut runner = SubprocessRunner::new(config, spec).expect("build runner"); + let base = runner.start().await.expect("start subprocess"); + assert!(base.starts_with("http://127.0.0.1:")); + + let health = runner.health().await.expect("query health"); + eprintln!("[subprocess] /health -> {health}"); + + let status = runner.shutdown().await.expect("shutdown"); + eprintln!("[subprocess] exited with {status}"); +} From e526954746c019a95c33a1619a9284bde799efd5 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 06:45:11 +0000 Subject: [PATCH 16/19] docs(test-harness): README reflects E2E lifecycle, oracle, subprocess (#1147) Updates the tier matrix and v1 capability list to match the implementation shipped in this PR: - InProc tier now runs the *full* lifecycle through `handleSignedEnvelope` + state assertion. Adds a runnable example showing the path end-to-end. - Subprocess tier moves from "preview, returns unimplemented error" to "functional spawn + health-probe + service-registration loop". The remaining trigger-emission helpers are called out as the next PR. - Documents oracle mocking via `chain::oracle::*`. - Documents `ForkOptions::from_env` reading FORK_BLOCK_NUMBER and `ForkOptions::from_profile` using profile fork_block as fallback. - Documents `MockHandler` deployment, `submit_envelope`, and `sort_signature_data`. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/test-harness/README.md | 92 +++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 16 deletions(-) diff --git a/packages/test-harness/README.md b/packages/test-harness/README.md index 8db0801d1..89070e2e9 100644 --- a/packages/test-harness/README.md +++ b/packages/test-harness/README.md @@ -76,17 +76,63 @@ The RPC URL is never logged verbatim — only via [`chain::redact_url`]. | Tier | Stages run | Stages mocked | Use case | |---|---|---|---| -| **InProc** (default) | compute (real WASM via `wavs-engine`) + aggregate (real WASM) + envelope signing | dispatcher subsystems | deterministic PR tier on local Anvil or fork | -| **Subprocess** (preview) | everything in the real `wavs` binary | nothing | nightly fork + pre-release | +| **InProc** (default) | compute (real WASM via `wavs-engine`) + aggregate (real WASM) + envelope signing + on-chain `handleSignedEnvelope` + assert | dispatcher subsystems | deterministic PR tier on local Anvil or fork | +| **Subprocess** | binary spawn + `/health` probe + `POST /dev/services` + on-chain submission + assert; v1 covers the spawn + service-registration loop, full trigger emission via the harness's `chain::*` primitives is composable | nothing — runs the real `wavs` binary | nightly fork + pre-release | InProc executes the actual operator and aggregator WASM directly through -`wavs-engine`, bypassing the full dispatcher (trigger manager, submission -manager, signing). It's fast, deterministic, and exercises the same WASM -code path that runs in production. +`wavs-engine`, then signs an envelope and submits it on-chain via +`handleSignedEnvelope` to `SimpleSubmit` (the canonical reference handler). +Tests assert against the handler's stored state. Everything from trigger +through submission is exercised; only the WAVS dispatcher's trigger / +submission orchestration is bypassed. -Subprocess ships in this release with API shape locked but lifecycle methods -stubbed — calling `start()` returns a descriptive `unimplemented` error. -See `src/service/runner_subprocess.rs` for the planned wiring. +Subprocess spawns the actual `wavs` binary against a tempdir HOME/DATA and +a generated `wavs.toml`, polls `/health` until ready, and exposes the +HTTP-API surface for service registration and lifecycle drive. The runner +is feature-gated behind `subprocess` to keep the default dep graph small. + +### End-to-end lifecycle path (InProc) + +```rust +use wavs_test_harness::{ + chain, + envelope::{self, sign_envelope, Envelope}, + service::{InProcRunner, MockHandler, MockHandlerConfig, ServiceSpec}, +}; + +// 1. Anvil with a wallet-bound provider. +let (provider, _anvil, _deployer) = chain::spawn_local_with_deployer().await?; + +// 2. Deploy the reference SimpleServiceManager + SimpleSubmit pair. +let operator = alloy_signer_local::PrivateKeySigner::random(); +let config = MockHandlerConfig::single_operator(operator.address()); +let handler = MockHandler::deploy(provider.clone(), &config).await?; + +// 3. Run real operator WASM through wavs-engine. +let spec = ServiceSpec::new() + .component_wasm("path/to/strategy.wasm") + .aggregator_wasm("path/to/aggregator.wasm"); +let runner = InProcRunner::from_spec(&spec)?; +let outputs = runner.run_component(payload_bytes).await?; + +// 4. Sign + submit. +let env = Envelope::new(envelope::event_id_from_nonce(1), outputs[0].clone()); +let sigdata = sign_envelope(&env, &[operator], reference_block)?; +handler.submit_envelope(&env, &sigdata).await?; + +// 5. Assert handler state. +assert!(handler.is_valid_trigger(1).await?); +``` + +### Oracle mocking on a fork + +```rust +use wavs_test_harness::chain::oracle::{chainlink_usd, install_chainlink_aggregator_v3}; + +let chainlink_eth_usd = profile.address("chainlink_eth_usd")?; +install_chainlink_aggregator_v3(&provider, chainlink_eth_usd, chainlink_usd(3_000.0)).await?; +// Downstream contract reads now see ETH/USD = $3000 deterministically. +``` ## CI tiers @@ -162,27 +208,41 @@ also doesn't block this v1. ## What ships in v1 -- `chain`: local Anvil + pinned fork (feature `fork`, on by default). +- `chain`: local Anvil (`spawn_local`, `spawn_local_with_deployer`) + + pinned fork (feature `fork`, on by default). `snapshot` / `revert` with RAII guard. `impersonate_funded`, `enable_auto_impersonate`, `whale_fund`. `mine_blocks`, `set_automine`, `increase_time`, `set_next_block_timestamp`. `redact_url` / `redact_key` - for sanitized logs. + for sanitized logs. `oracle::install_chainlink_aggregator_v3` + + `set_chainlink_price` for forked-chain oracle mocking. - `fixtures`: `ChainProfile` (TOML), `Addresses` typed lookup. Three - bundled profiles. + bundled profiles. `ForkOptions::from_env` reads `FORK_RPC_URL` + + `FORK_BLOCK_NUMBER`; `ForkOptions::from_profile` adds the profile's + declared `fork_block` as a fallback default. - `service`: `ServiceSpec` builder, middleware mock re-exports (`EvmMiddleware`, `MockEvmServiceManager`, `AvsOperator`), - `InProcRunner` (real WASM via `wavs-engine`), `SubprocessRunner` - (preview, off-by-default feature). + `InProcRunner` (real WASM via `wavs-engine`), + `MockHandler` (`SimpleServiceManager` + `SimpleSubmit` deployable pair + for on-chain envelope validation), `SubprocessRunner` + (functional spawn + health probe + service registration, feature + `subprocess`). - `lifecycle`: `manual_input_json` / `manual_input_raw`, `wait_for` / `wait_until` polling helpers, `assert_within` tolerance check. - `envelope`: canonical `Envelope` / `SignatureData` shape mirroring - `@wavs/solidity@0.6.x`, `sign_envelope`, `event_id_from_nonce` / - `event_id_from_seed`, `Envelope::message_hash` / `signing_hash`. + `@wavs/solidity@0.6.x`, `sign_envelope`, `submit_envelope` (on-chain + `handleSignedEnvelope` dispatch), `sort_signature_data`, + `event_id_from_nonce` / `event_id_from_seed`, + `Envelope::message_hash` / `signing_hash`. - `harness::TestHarness

` convenience wrapper. ## What's not in v1 -- Full subprocess wiring (API shape only). +- Full trigger-emission + quorum-waiting on the subprocess tier (the + spawn + service-registration loop ships; the rest is composable today + by combining `SubprocessRunner` with the harness's `chain::*` + primitives — bundled helpers land in the next PR). +- Pyth + other oracle shapes (Chainlink AggregatorV3 ships; the same + `anvil_set_code` pattern applies, only the bytecode differs). - Cosmos fork support. - A `with_deploy(closure)` builder hook on `TestHarness` (deferred until async-closure ergonomics stabilize; compose primitives directly today). From e3528de8c49829869ca8303e8aef4928c099cf72 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 07:15:05 +0000 Subject: [PATCH 17/19] fix(test-harness): satisfy clippy -D warnings (#1147) Resolves the 5 clippy errors flagged by CI's `cargo clippy --all-targets --all-features -- -D warnings`: - oracle::install_chainlink_aggregator_v3: collapse the duplicate `Provider` bound into a single `where P: Provider + AnvilApi` (clippy::multiple_bound_locations). - oracle::tests: `3_000_00000000i64` -> `300_000_000_000_i64` (clippy::inconsistent_digit_grouping). - envelope tests + end_to_end_smoke: replace `&[signer.clone()]` with `std::slice::from_ref(&signer)` (clippy::cloned_ref_to_slice_refs). - ChainProfile::from_str -> from_toml_str so it doesn't shadow the std `FromStr::from_str` (clippy::should_implement_trait). Updates the two callers + README mention. - MockHandler::is_valid_trigger: drop the no-op `.into()` on a u64 (clippy::useless_conversion). Also rewrites the end_to_end_smoke doc comment to avoid clippy's `doc_lazy_continuation` mis-parsing of `+` as a list marker. Verified locally: `cargo clippy -p wavs-test-harness --all-targets --all-features -- -D warnings` and the equivalent default-feature run both pass. All 46 tests still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/test-harness/README.md | 2 +- packages/test-harness/src/chain/oracle.rs | 8 ++++---- packages/test-harness/src/envelope/mod.rs | 2 +- packages/test-harness/src/fixtures/profile.rs | 8 ++++---- packages/test-harness/src/service/handler.rs | 2 +- packages/test-harness/tests/end_to_end_smoke.rs | 13 ++++++------- 6 files changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/test-harness/README.md b/packages/test-harness/README.md index 89070e2e9..21633f612 100644 --- a/packages/test-harness/README.md +++ b/packages/test-harness/README.md @@ -157,7 +157,7 @@ Three profiles ship via `include_str!`: - `mainnet.toml` — `chain_id=1`, `FORK_RPC_URL`, tokens + Aave v3 pool. Consumers can ship their own profiles via `ChainProfile::from_path(...)` -or `ChainProfile::from_str(...)`. +or `ChainProfile::from_toml_str(...)`. ## Determinism boundary diff --git a/packages/test-harness/src/chain/oracle.rs b/packages/test-harness/src/chain/oracle.rs index d8cfde92a..2c05dc03f 100644 --- a/packages/test-harness/src/chain/oracle.rs +++ b/packages/test-harness/src/chain/oracle.rs @@ -63,13 +63,13 @@ sol! { /// install_chainlink_aggregator_v3(&provider, chainlink_eth_usd, chainlink_usd(3_000.0)).await?; /// # Ok(()) } /// ``` -pub async fn install_chainlink_aggregator_v3( +pub async fn install_chainlink_aggregator_v3

( provider: &P, feed_address: Address, initial_price: I256, ) -> Result<()> where - P: AnvilApi, + P: Provider + AnvilApi, { let bytecode = hex::decode(CHAINLINK_V3_MOCK_RUNTIME) .context("decode embedded ChainlinkV3Mock runtime bytecode")?; @@ -127,9 +127,9 @@ mod tests { fn chainlink_usd_scales_correctly() { assert_eq!( chainlink_usd(3000.0), - I256::try_from(3_000_00000000i64).unwrap() + I256::try_from(300_000_000_000_i64).unwrap() ); - assert_eq!(chainlink_usd(0.5), I256::try_from(50_000_000i64).unwrap()); + assert_eq!(chainlink_usd(0.5), I256::try_from(50_000_000_i64).unwrap()); } #[tokio::test] diff --git a/packages/test-harness/src/envelope/mod.rs b/packages/test-harness/src/envelope/mod.rs index dca392ad9..350457b70 100644 --- a/packages/test-harness/src/envelope/mod.rs +++ b/packages/test-harness/src/envelope/mod.rs @@ -235,7 +235,7 @@ mod tests { fn sign_envelope_produces_recoverable_signature() { let signer = PrivateKeySigner::random(); let env = Envelope::new(event_id_from_nonce(7), vec![0xaa]); - let sigdata = sign_envelope(&env, &[signer.clone()], 0).unwrap(); + let sigdata = sign_envelope(&env, std::slice::from_ref(&signer), 0).unwrap(); assert_eq!(sigdata.signers.len(), 1); assert_eq!(sigdata.signers[0], signer.address()); diff --git a/packages/test-harness/src/fixtures/profile.rs b/packages/test-harness/src/fixtures/profile.rs index 8c5ac8127..6c41f60a2 100644 --- a/packages/test-harness/src/fixtures/profile.rs +++ b/packages/test-harness/src/fixtures/profile.rs @@ -19,7 +19,7 @@ //! //! Three profiles ship with the crate: `local`, `base`, `mainnet`. Consumers may //! also load arbitrary profiles via [`ChainProfile::from_path`] or -//! [`ChainProfile::from_str`]. +//! [`ChainProfile::from_toml_str`]. use std::path::Path; @@ -85,7 +85,7 @@ impl ChainProfile { )) } }; - Self::from_str(raw).with_context(|| format!("parse bundled profile `{name}`")) + Self::from_toml_str(raw).with_context(|| format!("parse bundled profile `{name}`")) } /// Load a profile from an arbitrary path. @@ -93,11 +93,11 @@ impl ChainProfile { let path = path.as_ref(); let raw = std::fs::read_to_string(path) .with_context(|| format!("read profile {}", path.display()))?; - Self::from_str(&raw).with_context(|| format!("parse profile {}", path.display())) + Self::from_toml_str(&raw).with_context(|| format!("parse profile {}", path.display())) } /// Parse a profile from a TOML string. Prefer [`Self::load`] or [`Self::from_path`]. - pub fn from_str(s: &str) -> Result { + pub fn from_toml_str(s: &str) -> Result { Ok(toml::from_str(s)?) } diff --git a/packages/test-harness/src/service/handler.rs b/packages/test-harness/src/service/handler.rs index f22683c0b..588fdab9f 100644 --- a/packages/test-harness/src/service/handler.rs +++ b/packages/test-harness/src/service/handler.rs @@ -203,7 +203,7 @@ impl MockHandler

{ pub async fn is_valid_trigger(&self, trigger_id: u64) -> Result { let h = SimpleSubmit::new(self.handler, &self.provider); let ok = h - .isValidTriggerId(trigger_id.into()) + .isValidTriggerId(trigger_id) .call() .await .with_context(|| format!("isValidTriggerId({trigger_id})"))?; diff --git a/packages/test-harness/tests/end_to_end_smoke.rs b/packages/test-harness/tests/end_to_end_smoke.rs index 21ebd9c59..0437498a2 100644 --- a/packages/test-harness/tests/end_to_end_smoke.rs +++ b/packages/test-harness/tests/end_to_end_smoke.rs @@ -1,13 +1,12 @@ //! End-to-end lifecycle smoke test — the path called out by the PR review: -//! -//! trigger → operator (real WASM) → sign envelope → submit on-chain → assert +//! `trigger -> operator (real WASM) -> sign envelope -> submit on-chain -> assert`. //! //! This test boots a local Anvil instance, deploys the `SimpleServiceManager` -//! + `SimpleSubmit` reference mocks, runs the `echo_data.wasm` operator +//! and `SimpleSubmit` reference mocks, runs the `echo_data.wasm` operator //! component through `InProcRunner`, signs the produced payload with an //! operator key registered in the manager, submits the signed envelope via -//! `handleSignedEnvelope`, and asserts the handler stored the trigger as -//! valid. Then negative cases prove `validate()` actually rejects bad input. +//! `handleSignedEnvelope`, and asserts the handler stored the trigger as valid. +//! Then negative cases prove `validate()` actually rejects bad input. //! //! Skips gracefully if `examples/build/components/echo_data.wasm` is missing. @@ -103,8 +102,8 @@ async fn lifecycle_component_then_sign_then_submit_then_assert() { } else { 0 } as u32; - let sigdata = - sign_envelope(&env_msg, &[operator.clone()], reference_block).expect("sign envelope"); + let sigdata = sign_envelope(&env_msg, std::slice::from_ref(&operator), reference_block) + .expect("sign envelope"); // 5. Submit on-chain and watch the receipt. let receipt = handler From d247c8efa6d16c505404b0f163971a2bb695bafe Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 16 May 2026 17:45:20 +0000 Subject: [PATCH 18/19] feat(test-harness): wire aggregator stage into end-to-end lifecycle (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses reviewer feedback that the previous lifecycle smoke test skipped the aggregator stage — signing the operator's raw output directly bypassed the `operator → aggregator → handler` path the issue's acceptance criteria require. - `ServiceSpec::chain_configs(...)` + `.with_evm_local_chain(...)` — threads a populated `ChainConfigs` into the wasmtime host so the aggregator's `host::get_evm_chain_config(...)` call resolves (without this, `simple_aggregator.wasm` errors out with "no chain config for X"). - `InProcRunner::run_component_full(trigger_action)` — returns the full `Vec` (payload + ordering + event_id_salt) so callers can build an `AggregatorInput` with all fields populated. `run_component` stays as a thin payload-bytes wrapper. - `InProcRunner::default_trigger_action(input)` — exposes the canonical `TriggerAction` so the same instance is reused for operator emit and aggregator consume (matches production EventId derivation). - `Envelope::from_operator_response(event_id, response)` — mirrors the production conversion in `subsystems/submission.rs:140-155`: low 20 bytes of EventId → bytes20 eventId, `Some(u64)` ordering → big-endian in bytes 0..8 of bytes12, payload passthrough. Two new unit tests cover both `Some` and `None` ordering paths. - Re-exports `RunnerAggregatorAction` + `RunnerSubmitAction` from `service::` so tests can match on the aggregator's output without pulling `wavs-engine` paths. New test `lifecycle_component_through_aggregator_then_sign_then_submit_then_assert` in `tests/end_to_end_smoke.rs` drives the FULL #1147 path: spawn Anvil, deploy SimpleServiceManager + SimpleSubmit, run echo_data WASM, **run simple_aggregator WASM via run_aggregator**, assert the emitted `SubmitAction::Evm` targets the deployed handler + the registered `evm:local` chain, then sign + submit + assert handler state. The existing `lifecycle_..._without_aggregator` test (renamed from `lifecycle_component_then_sign_then_submit_then_assert`) and the three `validate_rejects_*` tests keep the no-aggregator path — they're testing the handler's `validate()` directly and gain no coverage from the extra aggregator hop. `simple_aggregator.wasm` is pass-through (no quorum check); a multi-operator quorum smoke test belongs in a follow-up with a quorum-aware aggregator component. README + tier-matrix updated to match what's actually exercised. Tests: 34 lib + 5 chain + 5 e2e + 2 oracle + 1 inproc + 1 subprocess + 1 doc = 49 pass. `cargo clippy --all-targets --all-features -- -D warnings` clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/test-harness/README.md | 98 ++++++--- packages/test-harness/src/envelope/mod.rs | 60 ++++++ packages/test-harness/src/service/config.rs | 49 +++++ packages/test-harness/src/service/mod.rs | 2 +- .../test-harness/src/service/runner_inproc.rs | 57 ++++-- .../test-harness/tests/end_to_end_smoke.rs | 189 +++++++++++++++++- 6 files changed, 407 insertions(+), 48 deletions(-) diff --git a/packages/test-harness/README.md b/packages/test-harness/README.md index 21633f612..624846539 100644 --- a/packages/test-harness/README.md +++ b/packages/test-harness/README.md @@ -76,52 +76,102 @@ The RPC URL is never logged verbatim — only via [`chain::redact_url`]. | Tier | Stages run | Stages mocked | Use case | |---|---|---|---| -| **InProc** (default) | compute (real WASM via `wavs-engine`) + aggregate (real WASM) + envelope signing + on-chain `handleSignedEnvelope` + assert | dispatcher subsystems | deterministic PR tier on local Anvil or fork | +| **InProc** (default) | compute (real WASM via `wavs-engine`) + aggregate (real WASM via `wavs-engine`) + envelope signing + on-chain `handleSignedEnvelope` + state assertion | dispatcher subsystems | deterministic PR tier on local Anvil or fork | | **Subprocess** | binary spawn + `/health` probe + `POST /dev/services` + on-chain submission + assert; v1 covers the spawn + service-registration loop, full trigger emission via the harness's `chain::*` primitives is composable | nothing — runs the real `wavs` binary | nightly fork + pre-release | -InProc executes the actual operator and aggregator WASM directly through -`wavs-engine`, then signs an envelope and submits it on-chain via -`handleSignedEnvelope` to `SimpleSubmit` (the canonical reference handler). -Tests assert against the handler's stored state. Everything from trigger -through submission is exercised; only the WAVS dispatcher's trigger / -submission orchestration is bypassed. +InProc executes the actual operator **and aggregator** WASM directly through +`wavs-engine` — the `lifecycle_component_through_aggregator_then_sign_then_submit_then_assert` +test in `tests/end_to_end_smoke.rs` drives the full issue-#1147 path: + +``` +trigger → operator (real WASM) → aggregator (real WASM, emits SubmitAction) + → harness signs envelope → handleSignedEnvelope on-chain + → handler state assertion +``` + +The harness signs after the aggregator emits its `SubmitAction`, matching +production semantics: `packages/wavs/src/subsystems/submission.rs:140-155` +shows the dispatcher building the `Envelope` from the operator's +`WasmResponse` + the aggregator-derived `EventId` and signing it before +submission. `Envelope::from_operator_response` mirrors that conversion +(including the `ordering` field's big-endian u64 packing). + +`simple_aggregator.wasm` is pass-through — it always emits one `Submit` +per packet, no quorum check. A multi-operator quorum smoke test belongs +in a follow-up with a quorum-aware aggregator component. The current +test exercises the aggregator **lifecycle stage**, not its quorum +decision-making. + +Only the WAVS dispatcher's trigger streaming and submission +orchestration is bypassed — the harness drives each stage explicitly so +tests stay deterministic. Subprocess spawns the actual `wavs` binary against a tempdir HOME/DATA and a generated `wavs.toml`, polls `/health` until ready, and exposes the HTTP-API surface for service registration and lifecycle drive. The runner is feature-gated behind `subprocess` to keep the default dep graph small. -### End-to-end lifecycle path (InProc) +### End-to-end lifecycle path (InProc, including aggregator) ```rust use wavs_test_harness::{ chain, - envelope::{self, sign_envelope, Envelope}, - service::{InProcRunner, MockHandler, MockHandlerConfig, ServiceSpec}, + envelope::{sign_envelope, Envelope}, + service::{ + InProcRunner, MockHandler, MockHandlerConfig, RunnerAggregatorAction, + RunnerSubmitAction, ServiceSpec, + }, }; +use wavs_types::AggregatorInput; // 1. Anvil with a wallet-bound provider. -let (provider, _anvil, _deployer) = chain::spawn_local_with_deployer().await?; +let (provider, anvil, _deployer) = chain::spawn_local_with_deployer().await?; -// 2. Deploy the reference SimpleServiceManager + SimpleSubmit pair. +// 2. Deploy SimpleServiceManager + SimpleSubmit with one registered operator. let operator = alloy_signer_local::PrivateKeySigner::random(); -let config = MockHandlerConfig::single_operator(operator.address()); -let handler = MockHandler::deploy(provider.clone(), &config).await?; - -// 3. Run real operator WASM through wavs-engine. +let handler = MockHandler::deploy( + provider.clone(), + &MockHandlerConfig::single_operator(operator.address()), +).await?; + +// 3. Build the spec WITH chain configs + aggregator config vars. The +// aggregator reads `chain` + `service_handler` from host::config_var and +// fails closed if `evm:local` is not registered in chain_configs. let spec = ServiceSpec::new() .component_wasm("path/to/strategy.wasm") - .aggregator_wasm("path/to/aggregator.wasm"); + .aggregator_wasm("path/to/simple_aggregator.wasm") + .with_evm_local_chain("local", &anvil.endpoint()) + .config_var("chain", "evm:local") + .config_var("service_handler", handler.handler.to_string()); let runner = InProcRunner::from_spec(&spec)?; -let outputs = runner.run_component(payload_bytes).await?; -// 4. Sign + submit. -let env = Envelope::new(envelope::event_id_from_nonce(1), outputs[0].clone()); -let sigdata = sign_envelope(&env, &[operator], reference_block)?; -handler.submit_envelope(&env, &sigdata).await?; +// 4. Operator stage — keep the trigger_action so we can reuse it for +// aggregator-side EventId derivation. +let trigger = runner.default_trigger_action(payload_bytes.clone()); +let mut responses = runner.run_component_full(trigger.clone()).await?; +let operator_response = responses.remove(0); -// 5. Assert handler state. -assert!(handler.is_valid_trigger(1).await?); +// 5. Aggregator stage — drives simple_aggregator.wasm. +let agg_input = AggregatorInput { + trigger_action: trigger, + operator_response: operator_response.clone(), +}; +let event_id = agg_input.event_id()?; +let actions = runner.run_aggregator(event_id.clone(), agg_input).await?; +match &actions[0] { + RunnerAggregatorAction::Submit(RunnerSubmitAction::Evm(evm)) => { + assert_eq!(&evm.address.raw_bytes[..], handler.handler.as_slice()); + } + _ => panic!("expected Evm Submit"), +} + +// 6. Sign envelope built from the aggregator-derived EventId. +let env = Envelope::from_operator_response(event_id, &operator_response); +let sigdata = sign_envelope(&env, std::slice::from_ref(&operator), reference_block)?; + +// 7. Submit + assert. +handler.submit_envelope(&env, &sigdata).await?; +assert!(handler.is_valid_trigger(trigger_id).await?); ``` ### Oracle mocking on a fork diff --git a/packages/test-harness/src/envelope/mod.rs b/packages/test-harness/src/envelope/mod.rs index 350457b70..d2bad885c 100644 --- a/packages/test-harness/src/envelope/mod.rs +++ b/packages/test-harness/src/envelope/mod.rs @@ -22,6 +22,7 @@ use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; use alloy_sol_types::{sol, SolCall, SolValue}; use anyhow::{anyhow, Context, Result}; +use wavs_types::{EventId, WasmResponse}; sol! { /// The canonical envelope shape that all WAVS service handlers verify. @@ -62,6 +63,34 @@ impl Envelope { } } + /// Build an envelope from an operator [`WasmResponse`] + the [`EventId`] + /// derived for this trigger event. + /// + /// Mirrors the production conversion in + /// `packages/wavs/src/subsystems/submission.rs` (the `Submission::sign_request` + /// path): + /// + /// - `event_id` (`[u8; 20]`) → `eventId` (`bytes20`). + /// - `response.ordering` (`Option`) → `ordering` (`bytes12`): + /// `Some(u)` puts `u.to_be_bytes()` in bytes 0..8 (matches `EventOrder::new_u64`), + /// leaving bytes 8..12 zero. `None` leaves the field all zero. + /// - `response.payload` → `payload`. + /// + /// Prefer this over [`Self::new`] when you have a real operator response — + /// it preserves ordering semantics so production-shaped tests stay + /// production-shaped. + pub fn from_operator_response(event_id: EventId, response: &WasmResponse) -> Self { + let mut ordering = [0u8; 12]; + if let Some(o) = response.ordering { + ordering[0..8].copy_from_slice(&o.to_be_bytes()); + } + Self { + eventId: FixedBytes::from(*event_id.as_bytes()), + ordering: FixedBytes::from(ordering), + payload: response.payload.clone().into(), + } + } + /// Compute the keccak256(abi.encode(envelope)) digest the on-chain validator /// uses as input to the EIP-191 personal-sign prefix. pub fn message_hash(&self) -> B256 { @@ -253,6 +282,37 @@ mod tests { assert!(res.is_err()); } + #[test] + fn from_operator_response_packs_ordering_big_endian() { + let event_id_bytes = [0x33u8; 20]; + let event_id: EventId = event_id_bytes.into(); + let resp = WasmResponse { + payload: vec![0xde, 0xad, 0xbe, 0xef], + ordering: Some(0x0102_0304_0506_0708), + event_id_salt: None, + }; + let env = Envelope::from_operator_response(event_id, &resp); + + assert_eq!(env.eventId.0, event_id_bytes); + // u64 0x0102030405060708 lands in the first 8 bytes (big-endian). + let mut expected = [0u8; 12]; + expected[0..8].copy_from_slice(&0x0102_0304_0506_0708u64.to_be_bytes()); + assert_eq!(env.ordering.0, expected); + assert_eq!(env.payload.as_ref(), &[0xde, 0xad, 0xbe, 0xef]); + } + + #[test] + fn from_operator_response_with_no_ordering_leaves_field_zero() { + let event_id: EventId = [0xabu8; 20].into(); + let resp = WasmResponse { + payload: vec![1, 2, 3], + ordering: None, + event_id_salt: None, + }; + let env = Envelope::from_operator_response(event_id, &resp); + assert_eq!(env.ordering.0, [0u8; 12]); + } + #[test] fn sort_signature_data_sorts_signers_and_signatures_together() { // Build a SignatureData with intentionally out-of-order signers, then diff --git a/packages/test-harness/src/service/config.rs b/packages/test-harness/src/service/config.rs index b994499fe..a4f719979 100644 --- a/packages/test-harness/src/service/config.rs +++ b/packages/test-harness/src/service/config.rs @@ -13,6 +13,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Result}; +use wavs_types::{ChainConfigs, EvmChainConfig}; /// Declarative description of a service-under-test. #[derive(Debug, Clone, Default)] @@ -21,6 +22,7 @@ pub struct ServiceSpec { aggregator_wasm: Option, config_vars: BTreeMap, operator_count: Option, + chain_configs: ChainConfigs, } impl ServiceSpec { @@ -66,6 +68,49 @@ impl ServiceSpec { self } + /// Override the chain config registry passed to the wasmtime host. + /// + /// The aggregator world's `get_evm_chain_config(chain)` host call returns + /// `None` against the default empty `ChainConfigs`, which causes + /// `simple_aggregator.wasm` to fail with `no chain config for X`. Tests + /// that drive the aggregator stage need to register at least the + /// destination chain — see [`Self::with_evm_local_chain`] for the common + /// local-Anvil case. + pub fn chain_configs(mut self, configs: ChainConfigs) -> Self { + self.chain_configs = configs; + self + } + + /// Register a local EVM chain (typically Anvil) by chain-key id with the + /// spec's [`ChainConfigs`]. The resulting chain is reachable to the + /// aggregator as `evm:` (e.g. `with_evm_local_chain("local", endpoint)` + /// registers `evm:local`). + /// + /// Convenience around `chain_configs.add_chain(...)` so consumers don't + /// have to wire up the `EvmChainConfig` struct by hand. + pub fn with_evm_local_chain( + mut self, + id: impl Into, + http_endpoint: impl Into, + ) -> Self { + let id_str = id.into(); + let chain_key = format!("evm:{id_str}"); + self.chain_configs + .add_chain( + chain_key.parse().expect("evm: must parse as ChainKey"), + EvmChainConfig { + chain_id: id_str.parse().expect("chain id must parse"), + http_endpoint: Some(http_endpoint.into()), + ws_endpoints: vec![], + faucet_endpoint: None, + ws_priority_endpoint_index: None, + } + .into(), + ) + .expect("add_chain should not fail for evm namespace"); + self + } + /// Validate the spec is complete enough to boot a service. pub fn validate(&self) -> Result<()> { let cw = self @@ -103,6 +148,10 @@ impl ServiceSpec { pub fn operators(&self) -> usize { self.operator_count.unwrap_or(1) } + + pub fn chain_configs_ref(&self) -> &ChainConfigs { + &self.chain_configs + } } #[cfg(test)] diff --git a/packages/test-harness/src/service/mod.rs b/packages/test-harness/src/service/mod.rs index 8604de919..13103c745 100644 --- a/packages/test-harness/src/service/mod.rs +++ b/packages/test-harness/src/service/mod.rs @@ -22,6 +22,6 @@ pub use operators::{ OperatorSet, PoaMiddleware, ANVIL_DEPLOYER_ADDRESS, ANVIL_DEPLOYER_KEY, }; #[cfg(feature = "inproc")] -pub use runner_inproc::InProcRunner; +pub use runner_inproc::{InProcRunner, RunnerAggregatorAction, RunnerSubmitAction}; #[cfg(feature = "subprocess")] pub use runner_subprocess::{SubprocessConfig, SubprocessRunner}; diff --git a/packages/test-harness/src/service/runner_inproc.rs b/packages/test-harness/src/service/runner_inproc.rs index fa111b13c..6eeda0d40 100644 --- a/packages/test-harness/src/service/runner_inproc.rs +++ b/packages/test-harness/src/service/runner_inproc.rs @@ -24,10 +24,17 @@ use wavs_engine::{ operator::execute::execute as op_execute, }, }; + +// Re-export the aggregator action types so downstream tests can match on the +// runner's output without depending on `wavs-engine` paths directly. +pub use wavs_engine::worlds::aggregator::execute::{ + AggregatorAction as RunnerAggregatorAction, SubmitAction as RunnerSubmitAction, +}; use wavs_types::{ - AggregatorInput, AllowedHostPermission, Component, ComponentDigest, ComponentSource, EventId, - Permissions, Service, ServiceId, ServiceManager, ServiceStatus, SignatureKind, Submit, Trigger, - TriggerAction, TriggerConfig, TriggerData, WasmResponse, WorkflowId, + AggregatorInput, AllowedHostPermission, ChainConfigs, Component, ComponentDigest, + ComponentSource, EventId, Permissions, Service, ServiceId, ServiceManager, ServiceStatus, + SignatureKind, Submit, Trigger, TriggerAction, TriggerConfig, TriggerData, WasmResponse, + WorkflowId, }; use crate::service::ServiceSpec; @@ -41,6 +48,7 @@ pub struct InProcRunner { config: BTreeMap, service: Service, workflow_id: WorkflowId, + chain_configs: ChainConfigs, } impl InProcRunner { @@ -70,6 +78,7 @@ impl InProcRunner { config, service, workflow_id, + chain_configs: spec.chain_configs_ref().clone(), }) } @@ -85,11 +94,13 @@ impl InProcRunner { &self.workflow_id } - /// Execute the operator component once with the given raw input bytes. - /// Returns the list of payload bytes emitted by the component. - pub async fn run_component(&self, input: Vec) -> Result>> { - let engine = build_engine()?; - let trigger_action = TriggerAction { + /// Build the canonical [`TriggerAction`] for this runner from raw input + /// bytes. The same `TriggerAction` should be reused when constructing the + /// `AggregatorInput` for the aggregator stage so the derived `EventId` + /// matches between operator emit and aggregator consume — production wires + /// it the same way. + pub fn default_trigger_action(&self, input: Vec) -> TriggerAction { + TriggerAction { config: TriggerConfig { service_id: self.service.id().clone(), workflow_id: self.workflow_id.clone(), @@ -103,8 +114,30 @@ impl InProcRunner { .clone(), }, data: TriggerData::Raw(input), - }; + } + } + + /// Execute the operator component once with the given raw input bytes. + /// Returns the list of payload bytes emitted by the component. + /// + /// Thin wrapper around [`Self::run_component_full`] — kept for callers that + /// only need the payload bytes. Callers feeding output into the aggregator + /// should prefer `run_component_full` to preserve `ordering` and + /// `event_id_salt`. + pub async fn run_component(&self, input: Vec) -> Result>> { + let trigger_action = self.default_trigger_action(input); + let responses = self.run_component_full(trigger_action).await?; + Ok(responses.into_iter().map(|r| r.payload).collect()) + } + /// Execute the operator component and return the full set of + /// [`WasmResponse`]s — payload bytes plus `ordering` and `event_id_salt` + /// — so callers can build an [`AggregatorInput`] without losing fields. + pub async fn run_component_full( + &self, + trigger_action: TriggerAction, + ) -> Result> { + let engine = build_engine()?; let data_dir = tempfile::tempdir()?; let keyvalue_ctx = KeyValueCtx::new(WavsDb::new()?, "test".to_string()); @@ -116,7 +149,7 @@ impl InProcRunner { .map_err(|e| anyhow!("instantiate operator component: {e}"))?, engine: &engine, data_dir: data_dir.path().to_path_buf(), - chain_configs: &Default::default(), + chain_configs: &self.chain_configs, log: HostComponentLogger::OperatorHostComponentLogger(log_host), keyvalue_ctx, } @@ -132,7 +165,7 @@ impl InProcRunner { .await .map_err(map_engine_error)?; - Ok(responses.into_iter().map(|r| r.payload).collect()) + Ok(responses) } /// Execute the aggregator component once on a single packet input. @@ -162,7 +195,7 @@ impl InProcRunner { .map_err(|e| anyhow!("instantiate aggregator component: {e}"))?, engine: &engine, data_dir: data_dir.path().to_path_buf(), - chain_configs: &Default::default(), + chain_configs: &self.chain_configs, log: HostComponentLogger::AggregatorHostComponentLogger(log_host_agg), keyvalue_ctx, } diff --git a/packages/test-harness/tests/end_to_end_smoke.rs b/packages/test-harness/tests/end_to_end_smoke.rs index 0437498a2..d2dd05e42 100644 --- a/packages/test-harness/tests/end_to_end_smoke.rs +++ b/packages/test-harness/tests/end_to_end_smoke.rs @@ -1,14 +1,19 @@ -//! End-to-end lifecycle smoke test — the path called out by the PR review: -//! `trigger -> operator (real WASM) -> sign envelope -> submit on-chain -> assert`. +//! End-to-end lifecycle smoke tests — the path called out by the PR review: +//! `trigger -> operator (real WASM) -> aggregator (real WASM) -> sign envelope -> +//! submit on-chain -> assert`. //! -//! This test boots a local Anvil instance, deploys the `SimpleServiceManager` -//! and `SimpleSubmit` reference mocks, runs the `echo_data.wasm` operator -//! component through `InProcRunner`, signs the produced payload with an -//! operator key registered in the manager, submits the signed envelope via -//! `handleSignedEnvelope`, and asserts the handler stored the trigger as valid. -//! Then negative cases prove `validate()` actually rejects bad input. +//! Two flavors ship here: //! -//! Skips gracefully if `examples/build/components/echo_data.wasm` is missing. +//! - `lifecycle_component_through_aggregator_*` runs the FULL path including +//! the aggregator stage. This is the test that satisfies issue #1147's +//! acceptance criterion for the realistic end-to-end path. +//! - `lifecycle_component_then_sign_then_submit_without_aggregator` skips the +//! aggregator hop. Kept because the supporting negative-case tests +//! (`validate_rejects_*`) test the *handler's* `validate()` directly — those +//! gain no coverage from the extra aggregator hop and stay simple. +//! +//! Skips gracefully if `examples/build/components/{echo_data,simple_aggregator}.wasm` +//! is missing. use std::path::PathBuf; @@ -22,9 +27,11 @@ use wavs_test_harness::{ envelope::{self, sign_envelope, Envelope}, service::{ handler::{ISimpleSubmit, ISimpleTrigger, SimpleSubmit}, - InProcRunner, MockHandler, MockHandlerConfig, ServiceSpec, + InProcRunner, MockHandler, MockHandlerConfig, RunnerAggregatorAction, RunnerSubmitAction, + ServiceSpec, }, }; +use wavs_types::AggregatorInput; fn example_wasm(name: &str) -> PathBuf { let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -49,7 +56,7 @@ fn trigger_id(id: u64) -> ISimpleTrigger::TriggerId { } #[tokio::test] -async fn lifecycle_component_then_sign_then_submit_then_assert() { +async fn lifecycle_component_then_sign_then_submit_without_aggregator() { let _ = tracing_subscriber::fmt::try_init(); let component = example_wasm("echo_data.wasm"); @@ -278,3 +285,163 @@ async fn sort_signature_data_lets_submission_succeed() { ); assert!(handler.is_valid_trigger(11).await.unwrap()); } + +/// The realistic end-to-end path called out by #1147: +/// +/// `trigger -> operator (real WASM) -> aggregator (real WASM) -> sign envelope -> +/// on-chain handleSignedEnvelope -> handler state assertion`. +/// +/// What's exercised: +/// 1. `InProcRunner::run_component_full` runs the operator WASM and returns the +/// full `WasmResponse` (payload + ordering + event_id_salt). +/// 2. `InProcRunner::run_aggregator` runs the **aggregator WASM** on that +/// response and returns the routing `SubmitAction` — production-shape. +/// 3. The aggregator's `SubmitAction` is asserted to target the deployed +/// handler address + the registered chain key. This is the proof the +/// aggregator stage actually ran. +/// 4. `Envelope::from_operator_response` builds the on-chain envelope using +/// the aggregator-derived `EventId` (so the production-side EventId +/// derivation rule is exercised end-to-end). +/// 5. The harness signs the envelope and submits via the handler's +/// `handleSignedEnvelope`; the test asserts the handler stored the trigger +/// payload + signer set. +/// +/// Caveat: `simple_aggregator.wasm` is pass-through (no quorum check). A +/// multi-operator quorum smoke test belongs in a follow-up with a +/// quorum-aware aggregator component. This test exercises the **lifecycle +/// stage**, not quorum semantics. +#[tokio::test] +async fn lifecycle_component_through_aggregator_then_sign_then_submit_then_assert() { + let _ = tracing_subscriber::fmt::try_init(); + + let component = example_wasm("echo_data.wasm"); + let aggregator = example_wasm("simple_aggregator.wasm"); + if !component.exists() || !aggregator.exists() { + eprintln!( + "[skipping] {} or aggregator not found — run `just wasi-build-native`", + component.display() + ); + return; + } + + // 1. Anvil + wallet-bound provider. + let (provider, anvil, _deployer) = chain::spawn_local_with_deployer() + .await + .expect("spawn local anvil"); + let anvil_endpoint = anvil.endpoint(); + + // 2. Deploy SimpleServiceManager + SimpleSubmit; register one operator. + let operator = PrivateKeySigner::random(); + let handler_config = MockHandlerConfig::single_operator(operator.address()); + let handler = MockHandler::deploy(provider.clone(), &handler_config) + .await + .expect("deploy MockHandler"); + + // 3. Build the spec WITH chain configs + the aggregator's config vars. + // The aggregator reads `chain` and `service_handler` from + // host::config_var; without an `evm:local` entry in chain_configs, + // `host::get_evm_chain_config(...)` would return None and the + // aggregator would error out. + let chain_key_str = "evm:local"; + let spec = ServiceSpec::new() + .component_wasm(&component) + .aggregator_wasm(&aggregator) + .with_evm_local_chain("local", &anvil_endpoint) + .config_var("chain", chain_key_str) + .config_var("service_handler", handler.handler.to_string()) + .operator_count(1); + let runner = InProcRunner::from_spec(&spec).expect("build runner"); + + // 4. Operator stage — drive echo_data with an ABI-encoded DataWithId. + let trigger_id_value: u64 = 71; + let payload_in = data_with_id_payload(trigger_id_value, b"e2e-through-aggregator"); + let trigger_action = runner.default_trigger_action(payload_in.clone()); + + let responses = runner + .run_component_full(trigger_action.clone()) + .await + .expect("run component"); + assert_eq!(responses.len(), 1, "echo_data emits exactly one response"); + let operator_response = responses.into_iter().next().unwrap(); + assert_eq!( + operator_response.payload, payload_in, + "echo_data should return its input verbatim" + ); + + // 5. AGGREGATOR STAGE — the path the reviewer said was missing. + let agg_input = AggregatorInput { + trigger_action: trigger_action.clone(), + operator_response: operator_response.clone(), + }; + let event_id = agg_input + .event_id() + .expect("derive EventId for aggregation"); + eprintln!("[harness] derived EventId: {:?}", event_id.as_bytes()); + + let actions = runner + .run_aggregator(event_id.clone(), agg_input) + .await + .expect("run aggregator"); + assert_eq!( + actions.len(), + 1, + "simple_aggregator emits exactly one Submit per packet" + ); + + // Assert the aggregator emitted a Submit targeting our handler. + let RunnerAggregatorAction::Submit(submit_action) = &actions[0] else { + panic!("expected Submit action, got {:?}", &actions[0]); + }; + match submit_action { + RunnerSubmitAction::Evm(evm) => { + // chain string matches what we configured. + assert_eq!(evm.chain, chain_key_str, "aggregator chain mismatch"); + // address bytes match the deployed handler. + assert_eq!( + &evm.address.raw_bytes[..], + handler.handler.as_slice(), + "aggregator must submit to the deployed handler address" + ); + } + RunnerSubmitAction::Cosmos(_) => panic!("expected Evm submit action"), + } + eprintln!("[harness] aggregator Submit action verified — chain + handler match"); + + // 6. Build the envelope from the operator response using the + // aggregator-derived EventId (mirrors production submission.rs). + let env_msg = Envelope::from_operator_response(event_id, &operator_response); + + let block_number = provider.get_block_number().await.expect("block number"); + let reference_block = block_number.saturating_sub(1) as u32; + let sigdata = sign_envelope(&env_msg, std::slice::from_ref(&operator), reference_block) + .expect("sign envelope"); + + // 7. Submit + assert. + let receipt = handler + .submit_envelope(&env_msg, &sigdata) + .await + .expect("submit envelope"); + assert!(receipt.status(), "handleSignedEnvelope must succeed"); + + assert!( + handler + .is_valid_trigger(trigger_id_value) + .await + .expect("isValidTriggerId"), + "handler must mark triggerId {trigger_id_value} valid after handleSignedEnvelope" + ); + + let stored = SimpleSubmit::new(handler.handler, &provider) + .getSignedData(trigger_id_value) + .call() + .await + .expect("getSignedData"); + assert_eq!( + stored.envelope.payload, env_msg.payload, + "stored payload must match what the aggregator routed + we submitted" + ); + assert_eq!(stored.signatureData.signers[0], operator.address()); + + // Suppress unused warnings on builds where U256 isn't otherwise touched. + let _ = U256::ZERO; +} From 5a1231b4f59b62d44fd6dc1b1f4c6163d365f7e2 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sun, 17 May 2026 00:13:28 +0000 Subject: [PATCH 19/19] chore(deps): loosen exact alloy pins to caret to allow downstream unification (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace pinned alloy-rpc-types-eth, alloy-consensus, alloy-signer-local, etc. at `=1.0.42` exact. Downstream consumers of `wavs-test-harness` that resolve a newer alloy-consensus (e.g. wavs-defi at 1.4.1+) hit a type mismatch inside alloy-rpc-types-eth-1.0.42 (`BlobTransactionSidecarVariant` vs `BlobTransactionSidecar`) because the exact pin forces 1.0.42 while alloy-consensus is unified upward. Switching to caret (`1.0.42`) lets cargo unify these crates upward when a downstream constraint demands it, while still selecting 1.0.42 in WAVS's own lockfile. No code change required in WAVS itself — verified by `cargo check -p wavs-test-harness --tests`. Unblocks Lay3rLabs/wavs-defi#PR (consume-wavs-test-harness PoC). Co-Authored-By: Claude Haiku 4.5 --- Cargo.toml | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2cc1a1374..18d700cc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,30 +126,30 @@ walkdir = "2.5.0" wildmatch = "2.5.0" # EVM-specific dependencies -alloy-node-bindings = "=1.0.42" +alloy-node-bindings = "1.0.42" alloy-json-abi = "1.4.1" alloy-primitives = { version = "1.4.1", features = ["serde"] } -alloy-provider = { version = "=1.0.42", features = ["ws", "pubsub"] } +alloy-provider = { version = "1.0.42", features = ["ws", "pubsub"] } alloy-sol-types = "1.4.1" alloy-sol-macro = { version = "1.4.1", features = ["json"] } -alloy-transport = "=1.0.42" -alloy-transport-http = "=1.0.42" -alloy-rpc-client = "=1.0.42" -alloy-contract = "=1.0.42" -alloy-signer = "=1.0.42" -alloy-signer-local = { version = "=1.0.42", features = ["mnemonic"] } -alloy-network = "=1.0.42" -alloy-rpc-types-eth = "=1.0.42" +alloy-transport = "1.0.42" +alloy-transport-http = "1.0.42" +alloy-rpc-client = "1.0.42" +alloy-contract = "1.0.42" +alloy-signer = "1.0.42" +alloy-signer-local = { version = "1.0.42", features = ["mnemonic"] } +alloy-network = "1.0.42" +alloy-rpc-types-eth = "1.0.42" -alloy-consensus-any = "=1.0.42" -alloy-consensus = "=1.0.42" -alloy-eips = "=1.0.42" -alloy-network-primitives = "=1.0.42" -alloy-serde = "=1.0.42" -alloy-tx-macros = "=1.0.42" -alloy-eip2930 = "=0.2.1" -alloy-eip7702 = "=0.6.1" -alloy-json-rpc = "=1.6.3" +alloy-consensus-any = "1.0.42" +alloy-consensus = "1.0.42" +alloy-eips = "1.0.42" +alloy-network-primitives = "1.0.42" +alloy-serde = "1.0.42" +alloy-tx-macros = "1.0.42" +alloy-eip2930 = "0.2.1" +alloy-eip7702 = "0.6.1" +alloy-json-rpc = "1.6.3" layer-climb = "0.9.0" layer-climb-address = { version = "0.9.0", features = [