From 9007ad565c776566d3fee6aab2e224b292f78d03 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 18 Jul 2026 09:46:59 +0000 Subject: [PATCH 1/2] build: slim shepherd to the L3 CoW bundle over the split runtime Delete the nexum and videre groups (now the nexum-runtime and videre-nexum-module repos), promote the shepherd group to the repo root, and pin the cross-repo deps by commit (nexum-* rev 17005ba, videre-* rev 112868f). Drop the umbrella [patch] and the venue-agnostic and dep-sync CI gates, owned by the upstream repos now; keep extensions.toml, which registers the videre client capability the keepers need. De-link a cfg-gated CowAdapter rustdoc reference and repoint the Dockerfile, engine configs and scripts off the old group paths. --- shepherd/.env.example => .env.example | 0 .github/workflows/ci.yml | 50 +- .github/workflows/docker.yml | 2 +- Cargo.lock | 319 +- Cargo.toml | 212 +- shepherd/Dockerfile => Dockerfile | 19 +- .../composable-cow/Cargo.toml | 6 +- .../composable-cow/src/body.rs | 0 .../composable-cow/src/lib.rs | 0 .../composable-cow/src/poll.rs | 0 .../composable-cow/src/sweep.rs | 0 .../composable-cow/tests/sweep.rs | 0 .../crates => crates}/cow-venue/Cargo.toml | 6 +- .../crates => crates}/cow-venue/build.rs | 0 .../cow-venue/data/classification.toml | 0 .../cow-venue/module.load.toml | 0 .../cow-venue/module.sepolia.toml | 0 .../crates => crates}/cow-venue/module.toml | 0 .../cow-venue/src/adapter.rs | 0 .../cow-venue/src/assembly.rs | 0 .../crates => crates}/cow-venue/src/body.rs | 0 .../cow-venue/src/classification.rs | 0 .../cow-venue/src/classification_data.rs | 0 .../crates => crates}/cow-venue/src/client.rs | 0 .../crates => crates}/cow-venue/src/lib.rs | 2 +- .../crates => crates}/cow-venue/src/order.rs | 0 .../cow-venue/tests/conformance.rs | 0 .../tests/vectors/cow-header-goldens.json | 0 .../tests/vectors/cow-intent-body.json | 0 .../cow-venue/tests/wit_layering.rs | 0 .../shepherd-backtest/Cargo.toml | 6 +- .../shepherd-backtest/src/fixtures.rs | 0 .../shepherd-backtest/src/main.rs | 0 .../shepherd-backtest/src/replay.rs | 0 .../shepherd-backtest/src/report.rs | 0 .../crates => crates}/shepherd/Cargo.toml | 6 +- .../crates => crates}/shepherd/src/main.rs | 0 ...ompose.soak.yml => docker-compose.soak.yml | 0 .../docker-compose.yml => docker-compose.yml | 2 +- .../engine.docker.toml => engine.docker.toml | 8 - shepherd/engine.e2e.toml => engine.e2e.toml | 16 +- ...engine.example.toml => engine.example.toml | 2 +- shepherd/engine.load.toml => engine.load.toml | 6 +- shepherd/engine.m2.toml => engine.m2.toml | 6 +- shepherd/engine.m3.toml => engine.m3.toml | 12 +- ...oak.docker.toml => engine.soak.docker.toml | 8 - shepherd/engine.soak.toml => engine.soak.toml | 16 +- justfile | 120 +- .../ethflow-watcher/Cargo.toml | 6 +- .../ethflow-watcher/module.toml | 0 .../ethflow-watcher/src/lib.rs | 0 .../ethflow-watcher/src/strategy.rs | 0 .../examples/stop-loss/Cargo.toml | 6 +- .../examples/stop-loss/module.toml | 0 .../examples/stop-loss/src/lib.rs | 0 .../examples/stop-loss/src/strategy.rs | 0 .../twap-monitor/Cargo.toml | 6 +- .../twap-monitor/module.toml | 0 .../twap-monitor/src/lib.rs | 0 .../twap-monitor/src/strategy.rs | 0 nexum/Cargo.repo.toml | 120 - nexum/crates/nexum-cli/Cargo.toml | 20 - nexum/crates/nexum-cli/src/main.rs | 11 - nexum/crates/nexum-launch/Cargo.toml | 17 - nexum/crates/nexum-launch/src/cli.rs | 85 - nexum/crates/nexum-launch/src/lib.rs | 66 - nexum/crates/nexum-module-macros/Cargo.toml | 19 - nexum/crates/nexum-module-macros/src/lib.rs | 305 -- nexum/crates/nexum-runtime/Cargo.toml | 114 - nexum/crates/nexum-runtime/examples/embed.rs | 58 - nexum/crates/nexum-runtime/src/addons.rs | 104 - nexum/crates/nexum-runtime/src/bindings.rs | 26 - nexum/crates/nexum-runtime/src/bootstrap.rs | 54 - nexum/crates/nexum-runtime/src/builder.rs | 1055 ------- .../crates/nexum-runtime/src/engine_config.rs | 1495 ---------- nexum/crates/nexum-runtime/src/host/actor.rs | 108 - .../src/host/component/builder.rs | 293 -- .../nexum-runtime/src/host/component/chain.rs | 184 -- .../nexum-runtime/src/host/component/mod.rs | 97 - .../src/host/component/runtime_types.rs | 31 - .../nexum-runtime/src/host/component/state.rs | 89 - nexum/crates/nexum-runtime/src/host/error.rs | 176 -- .../nexum-runtime/src/host/extension.rs | 367 --- nexum/crates/nexum-runtime/src/host/http.rs | 766 ----- .../nexum-runtime/src/host/impls/chain.rs | 380 --- .../nexum-runtime/src/host/impls/identity.rs | 28 - .../src/host/impls/local_store.rs | 36 - .../nexum-runtime/src/host/impls/logging.rs | 31 - .../nexum-runtime/src/host/impls/messaging.rs | 89 - .../nexum-runtime/src/host/impls/mod.rs | 15 - .../src/host/impls/remote_store.rs | 34 - .../nexum-runtime/src/host/impls/types.rs | 8 - .../src/host/local_store_redb.rs | 312 -- .../src/host/local_store_redb/tests.rs | 408 --- .../crates/nexum-runtime/src/host/logs/mod.rs | 284 -- .../nexum-runtime/src/host/logs/stdio.rs | 285 -- .../nexum-runtime/src/host/logs/store.rs | 392 --- nexum/crates/nexum-runtime/src/host/mod.rs | 40 - .../nexum-runtime/src/host/provider_pool.rs | 690 ----- .../src/host/remote_store_bee.rs | 407 --- nexum/crates/nexum-runtime/src/host/state.rs | 93 - nexum/crates/nexum-runtime/src/lib.rs | 40 - .../src/manifest/capabilities.rs | 569 ---- .../nexum-runtime/src/manifest/error.rs | 68 - .../crates/nexum-runtime/src/manifest/load.rs | 474 --- .../crates/nexum-runtime/src/manifest/mod.rs | 43 - .../nexum-runtime/src/manifest/types.rs | 291 -- nexum/crates/nexum-runtime/src/preset.rs | 101 - .../src/runtime/dispatch_rate.rs | 157 - .../nexum-runtime/src/runtime/event_loop.rs | 838 ------ .../nexum-runtime/src/runtime/limits.rs | 6 - nexum/crates/nexum-runtime/src/runtime/mod.rs | 8 - .../src/runtime/poison_policy.rs | 91 - .../src/runtime/restart_policy.rs | 78 - nexum/crates/nexum-runtime/src/supervisor.rs | 2065 ------------- .../nexum-runtime/src/supervisor/tests.rs | 2554 ----------------- .../nexum-runtime/src/test_utils/builders.rs | 19 - .../nexum-runtime/src/test_utils/chain.rs | 300 -- .../nexum-runtime/src/test_utils/clock.rs | 141 - .../nexum-runtime/src/test_utils/harness.rs | 907 ------ .../nexum-runtime/src/test_utils/mod.rs | 294 -- .../nexum-runtime/src/test_utils/store.rs | 118 - .../nexum-runtime/src/test_utils/types.rs | 24 - nexum/crates/nexum-sdk-test/Cargo.toml | 15 - nexum/crates/nexum-sdk-test/src/lib.rs | 1582 ---------- nexum/crates/nexum-sdk/Cargo.toml | 73 - nexum/crates/nexum-sdk/src/address.rs | 157 - nexum/crates/nexum-sdk/src/chain/chainlink.rs | 197 -- nexum/crates/nexum-sdk/src/chain/eth_call.rs | 100 - nexum/crates/nexum-sdk/src/chain/id.rs | 125 - nexum/crates/nexum-sdk/src/chain/method.rs | 122 - nexum/crates/nexum-sdk/src/chain/mod.rs | 21 - nexum/crates/nexum-sdk/src/chain/provider.rs | 119 - nexum/crates/nexum-sdk/src/chain/transport.rs | 252 -- nexum/crates/nexum-sdk/src/config.rs | 212 -- nexum/crates/nexum-sdk/src/events.rs | 130 - nexum/crates/nexum-sdk/src/host.rs | 583 ---- nexum/crates/nexum-sdk/src/http.rs | 253 -- nexum/crates/nexum-sdk/src/keeper.rs | 447 --- nexum/crates/nexum-sdk/src/lib.rs | 156 - nexum/crates/nexum-sdk/src/prelude.rs | 10 - nexum/crates/nexum-sdk/src/proptests.rs | 145 - nexum/crates/nexum-sdk/src/tracing.rs | 286 -- .../crates/nexum-sdk/src/wit_bindgen_macro.rs | 390 --- nexum/crates/nexum-sdk/tests/keeper.rs | 598 ---- nexum/crates/nexum-status-body/Cargo.toml | 14 - nexum/crates/nexum-status-body/src/lib.rs | 248 -- nexum/crates/nexum-tasks/Cargo.toml | 15 - nexum/crates/nexum-tasks/src/lib.rs | 16 - nexum/crates/nexum-tasks/src/manager.rs | 293 -- nexum/crates/nexum-tasks/src/shutdown.rs | 114 - nexum/crates/nexum-tasks/src/task.rs | 77 - nexum/crates/nexum-world/Cargo.toml | 16 - nexum/crates/nexum-world/src/lib.rs | 743 ----- nexum/modules/example/Cargo.toml | 16 - nexum/modules/example/module.toml | 27 - nexum/modules/example/src/lib.rs | 84 - .../examples/balance-tracker/Cargo.toml | 18 - .../examples/balance-tracker/module.toml | 42 - .../examples/balance-tracker/src/lib.rs | 63 - .../examples/balance-tracker/src/strategy.rs | 391 --- nexum/modules/examples/http-probe/Cargo.toml | 19 - nexum/modules/examples/http-probe/module.toml | 38 - nexum/modules/examples/http-probe/src/lib.rs | 72 - .../examples/http-probe/src/strategy.rs | 305 -- nexum/modules/examples/price-alert/Cargo.toml | 22 - .../modules/examples/price-alert/module.toml | 43 - nexum/modules/examples/price-alert/src/lib.rs | 80 - .../examples/price-alert/src/strategy.rs | 405 --- .../modules/fixtures/clock-reader/Cargo.toml | 13 - .../modules/fixtures/clock-reader/module.toml | 21 - .../modules/fixtures/clock-reader/src/lib.rs | 51 - nexum/modules/fixtures/flaky-bomb/Cargo.toml | 13 - nexum/modules/fixtures/flaky-bomb/module.toml | 26 - nexum/modules/fixtures/flaky-bomb/src/lib.rs | 100 - nexum/modules/fixtures/fuel-bomb/Cargo.toml | 13 - nexum/modules/fixtures/fuel-bomb/module.toml | 21 - nexum/modules/fixtures/fuel-bomb/src/lib.rs | 50 - nexum/modules/fixtures/memory-bomb/Cargo.toml | 13 - .../modules/fixtures/memory-bomb/module.toml | 20 - nexum/modules/fixtures/memory-bomb/src/lib.rs | 51 - nexum/modules/fixtures/panic-bomb/Cargo.toml | 15 - nexum/modules/fixtures/panic-bomb/module.toml | 22 - nexum/modules/fixtures/panic-bomb/src/lib.rs | 68 - nexum/modules/fixtures/slow-host/Cargo.toml | 13 - nexum/modules/fixtures/slow-host/module.toml | 25 - nexum/modules/fixtures/slow-host/src/lib.rs | 59 - nexum/scripts/check-venue-agnostic.sh | 103 - nexum/wit/deps.lock | 0 nexum/wit/deps.toml | 1 - {shepherd/scripts => scripts}/README.md | 0 .../scripts => scripts}/_ethflow_quote.py | 0 .../scripts => scripts}/_twap_calldata.py | 0 .../check-cow-orderbook-only.sh | 0 scripts/check-dep-sync.sh | 335 --- {shepherd/scripts => scripts}/e2e-finish.sh | 2 +- {shepherd/scripts => scripts}/e2e-onchain.sh | 0 .../scripts => scripts}/e2e-report-gen.sh | 2 +- {shepherd/scripts => scripts}/e2e-run.sh | 0 {shepherd/scripts => scripts}/env-template | 0 {shepherd/scripts => scripts}/lib.sh | 6 +- .../scripts => scripts}/load-bootstrap.sh | 0 {shepherd/scripts => scripts}/load-run.sh | 0 .../scripts => scripts}/load-teardown.sh | 0 .../scripts => scripts}/soak-snapshot.sh | 0 shepherd/Cargo.repo.toml | 89 - shepherd/wit/deps.toml | 21 - shepherd/wit/deps/nexum-host/chain.wit | 60 - shepherd/wit/deps/nexum-host/event-module.wit | 24 - shepherd/wit/deps/nexum-host/identity.wit | 24 - shepherd/wit/deps/nexum-host/local-store.wit | 29 - shepherd/wit/deps/nexum-host/logging.wit | 15 - shepherd/wit/deps/nexum-host/messaging.wit | 19 - shepherd/wit/deps/nexum-host/query-module.wit | 25 - shepherd/wit/deps/nexum-host/remote-store.wit | 33 - shepherd/wit/deps/nexum-host/types.wit | 106 - .../backtest-collect/backtest_collect.py | 0 .../backtest-collect/fixtures-2026-06-22.json | 0 .../baseline-latency/.gitignore | 0 .../baseline-latency/baseline_latency.py | 0 .../baseline-latency/data/arbitrum_one.json | 0 .../baseline-latency/data/base.json | 0 .../baseline-latency/data/gnosis.json | 0 .../baseline-latency/data/mainnet.json | 0 .../baseline-latency/data/sepolia.json | 0 {shepherd/tools => tools}/load-gen/Cargo.toml | 0 .../tools => tools}/load-gen/src/main.rs | 0 .../tools => tools}/orderbook-mock/Cargo.toml | 0 .../orderbook-mock/src/main.rs | 0 videre/Cargo.repo.toml | 77 - videre/crates/no-std-probe/Cargo.toml | 18 - videre/crates/no-std-probe/src/lib.rs | 14 - videre/crates/videre-host/Cargo.toml | 32 - videre/crates/videre-host/src/bindings.rs | 275 -- videre/crates/videre-host/src/client.rs | 56 - videre/crates/videre-host/src/handshake.rs | 285 -- videre/crates/videre-host/src/lib.rs | 169 -- videre/crates/videre-host/src/registry.rs | 1786 ------------ videre/crates/videre-host/tests/platform.rs | 1068 ------- videre/crates/videre-host/tests/zero_leak.rs | 172 -- videre/crates/videre-macros/Cargo.toml | 19 - .../crates/videre-macros/src/intent_body.rs | 141 - videre/crates/videre-macros/src/keeper.rs | 292 -- videre/crates/videre-macros/src/lib.rs | 270 -- videre/crates/videre-macros/src/world.rs | 155 - videre/crates/videre-sdk/Cargo.toml | 42 - videre/crates/videre-sdk/src/adapter.rs | 116 - videre/crates/videre-sdk/src/bindings.rs | 43 - videre/crates/videre-sdk/src/body.rs | 102 - videre/crates/videre-sdk/src/client.rs | 310 -- videre/crates/videre-sdk/src/faults.rs | 233 -- videre/crates/videre-sdk/src/keeper.rs | 534 ---- videre/crates/videre-sdk/src/lib.rs | 115 - videre/crates/videre-sdk/src/rt.rs | 38 - videre/crates/videre-sdk/src/transport.rs | 235 -- videre/crates/videre-sdk/tests/adapter.rs | 314 -- videre/crates/videre-test/Cargo.toml | 40 - .../videre-test/goldens/reference-header.json | 48 - videre/crates/videre-test/src/codec.rs | 404 --- videre/crates/videre-test/src/fixture.rs | 126 - videre/crates/videre-test/src/header.rs | 417 --- videre/crates/videre-test/src/lib.rs | 81 - videre/crates/videre-test/src/reference.rs | 277 -- videre/crates/videre-test/src/report.rs | 51 - videre/crates/videre-test/src/transport.rs | 352 --- .../crates/videre-test/tests/conformance.rs | 142 - .../videre-test/vectors/reference-body.json | 72 - .../modules/examples/echo-client/Cargo.toml | 17 - .../modules/examples/echo-client/module.toml | 38 - .../modules/examples/echo-client/src/lib.rs | 85 - .../modules/examples/echo-keeper/Cargo.toml | 18 - .../modules/examples/echo-keeper/module.toml | 40 - .../modules/examples/echo-keeper/src/lib.rs | 109 - videre/modules/examples/echo-venue/Cargo.toml | 22 - .../modules/examples/echo-venue/module.toml | 29 - videre/modules/examples/echo-venue/src/lib.rs | 181 -- .../modules/fixtures/flaky-venue/Cargo.toml | 17 - .../modules/fixtures/flaky-venue/module.toml | 19 - .../modules/fixtures/flaky-venue/src/lib.rs | 79 - videre/wit/deps.lock | 5 - videre/wit/deps.toml | 8 - videre/wit/deps/nexum-host/chain.wit | 60 - videre/wit/deps/nexum-host/event-module.wit | 24 - videre/wit/deps/nexum-host/identity.wit | 24 - videre/wit/deps/nexum-host/local-store.wit | 29 - videre/wit/deps/nexum-host/logging.wit | 15 - videre/wit/deps/nexum-host/messaging.wit | 19 - videre/wit/deps/nexum-host/query-module.wit | 25 - videre/wit/deps/nexum-host/remote-store.wit | 33 - videre/wit/deps/nexum-host/types.wit | 106 - videre/wit/videre-types/types.wit | 83 - videre/wit/videre-value-flow/types.wit | 32 - videre/wit/videre-venue/venue.wit | 49 - {shepherd/wit => wit}/deps.lock | 0 wit/deps.toml | 21 + {nexum/wit => wit/deps}/nexum-host/chain.wit | 0 .../deps}/nexum-host/event-module.wit | 0 .../wit => wit/deps}/nexum-host/identity.wit | 0 .../deps}/nexum-host/local-store.wit | 0 .../wit => wit/deps}/nexum-host/logging.wit | 0 .../wit => wit/deps}/nexum-host/messaging.wit | 0 .../deps}/nexum-host/query-module.wit | 0 .../deps}/nexum-host/remote-store.wit | 0 {nexum/wit => wit/deps}/nexum-host/types.wit | 0 .../wit => wit}/deps/videre-types/types.wit | 0 .../deps/videre-value-flow/types.wit | 0 .../wit => wit}/deps/videre-venue/venue.wit | 0 .../wit => wit}/shepherd-cow/cow-events.wit | 0 308 files changed, 158 insertions(+), 39792 deletions(-) rename shepherd/.env.example => .env.example (100%) rename shepherd/Dockerfile => Dockerfile (84%) rename {shepherd/crates => crates}/composable-cow/Cargo.toml (87%) rename {shepherd/crates => crates}/composable-cow/src/body.rs (100%) rename {shepherd/crates => crates}/composable-cow/src/lib.rs (100%) rename {shepherd/crates => crates}/composable-cow/src/poll.rs (100%) rename {shepherd/crates => crates}/composable-cow/src/sweep.rs (100%) rename {shepherd/crates => crates}/composable-cow/tests/sweep.rs (100%) rename {shepherd/crates => crates}/cow-venue/Cargo.toml (94%) rename {shepherd/crates => crates}/cow-venue/build.rs (100%) rename {shepherd/crates => crates}/cow-venue/data/classification.toml (100%) rename {shepherd/crates => crates}/cow-venue/module.load.toml (100%) rename {shepherd/crates => crates}/cow-venue/module.sepolia.toml (100%) rename {shepherd/crates => crates}/cow-venue/module.toml (100%) rename {shepherd/crates => crates}/cow-venue/src/adapter.rs (100%) rename {shepherd/crates => crates}/cow-venue/src/assembly.rs (100%) rename {shepherd/crates => crates}/cow-venue/src/body.rs (100%) rename {shepherd/crates => crates}/cow-venue/src/classification.rs (100%) rename {shepherd/crates => crates}/cow-venue/src/classification_data.rs (100%) rename {shepherd/crates => crates}/cow-venue/src/client.rs (100%) rename {shepherd/crates => crates}/cow-venue/src/lib.rs (98%) rename {shepherd/crates => crates}/cow-venue/src/order.rs (100%) rename {shepherd/crates => crates}/cow-venue/tests/conformance.rs (100%) rename {shepherd/crates => crates}/cow-venue/tests/vectors/cow-header-goldens.json (100%) rename {shepherd/crates => crates}/cow-venue/tests/vectors/cow-intent-body.json (100%) rename {shepherd/crates => crates}/cow-venue/tests/wit_layering.rs (100%) rename {shepherd/crates => crates}/shepherd-backtest/Cargo.toml (85%) rename {shepherd/crates => crates}/shepherd-backtest/src/fixtures.rs (100%) rename {shepherd/crates => crates}/shepherd-backtest/src/main.rs (100%) rename {shepherd/crates => crates}/shepherd-backtest/src/replay.rs (100%) rename {shepherd/crates => crates}/shepherd-backtest/src/report.rs (100%) rename {shepherd/crates => crates}/shepherd/Cargo.toml (71%) rename {shepherd/crates => crates}/shepherd/src/main.rs (100%) rename shepherd/docker-compose.soak.yml => docker-compose.soak.yml (100%) rename shepherd/docker-compose.yml => docker-compose.yml (99%) rename shepherd/engine.docker.toml => engine.docker.toml (92%) rename shepherd/engine.e2e.toml => engine.e2e.toml (82%) rename shepherd/engine.example.toml => engine.example.toml (98%) rename shepherd/engine.load.toml => engine.load.toml (90%) rename shepherd/engine.m2.toml => engine.m2.toml (88%) rename shepherd/engine.m3.toml => engine.m3.toml (78%) rename shepherd/engine.soak.docker.toml => engine.soak.docker.toml (88%) rename shepherd/engine.soak.toml => engine.soak.toml (84%) rename {shepherd/modules => modules}/ethflow-watcher/Cargo.toml (86%) rename {shepherd/modules => modules}/ethflow-watcher/module.toml (100%) rename {shepherd/modules => modules}/ethflow-watcher/src/lib.rs (100%) rename {shepherd/modules => modules}/ethflow-watcher/src/strategy.rs (100%) rename {shepherd/modules => modules}/examples/stop-loss/Cargo.toml (88%) rename {shepherd/modules => modules}/examples/stop-loss/module.toml (100%) rename {shepherd/modules => modules}/examples/stop-loss/src/lib.rs (100%) rename {shepherd/modules => modules}/examples/stop-loss/src/strategy.rs (100%) rename {shepherd/modules => modules}/twap-monitor/Cargo.toml (86%) rename {shepherd/modules => modules}/twap-monitor/module.toml (100%) rename {shepherd/modules => modules}/twap-monitor/src/lib.rs (100%) rename {shepherd/modules => modules}/twap-monitor/src/strategy.rs (100%) delete mode 100644 nexum/Cargo.repo.toml delete mode 100644 nexum/crates/nexum-cli/Cargo.toml delete mode 100644 nexum/crates/nexum-cli/src/main.rs delete mode 100644 nexum/crates/nexum-launch/Cargo.toml delete mode 100644 nexum/crates/nexum-launch/src/cli.rs delete mode 100644 nexum/crates/nexum-launch/src/lib.rs delete mode 100644 nexum/crates/nexum-module-macros/Cargo.toml delete mode 100644 nexum/crates/nexum-module-macros/src/lib.rs delete mode 100644 nexum/crates/nexum-runtime/Cargo.toml delete mode 100644 nexum/crates/nexum-runtime/examples/embed.rs delete mode 100644 nexum/crates/nexum-runtime/src/addons.rs delete mode 100644 nexum/crates/nexum-runtime/src/bindings.rs delete mode 100644 nexum/crates/nexum-runtime/src/bootstrap.rs delete mode 100644 nexum/crates/nexum-runtime/src/builder.rs delete mode 100644 nexum/crates/nexum-runtime/src/engine_config.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/actor.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/component/builder.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/component/chain.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/component/mod.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/component/runtime_types.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/component/state.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/error.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/extension.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/http.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/impls/chain.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/impls/identity.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/impls/local_store.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/impls/logging.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/impls/messaging.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/impls/mod.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/impls/remote_store.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/impls/types.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/local_store_redb.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/local_store_redb/tests.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/logs/mod.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/logs/stdio.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/logs/store.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/mod.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/provider_pool.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/remote_store_bee.rs delete mode 100644 nexum/crates/nexum-runtime/src/host/state.rs delete mode 100644 nexum/crates/nexum-runtime/src/lib.rs delete mode 100644 nexum/crates/nexum-runtime/src/manifest/capabilities.rs delete mode 100644 nexum/crates/nexum-runtime/src/manifest/error.rs delete mode 100644 nexum/crates/nexum-runtime/src/manifest/load.rs delete mode 100644 nexum/crates/nexum-runtime/src/manifest/mod.rs delete mode 100644 nexum/crates/nexum-runtime/src/manifest/types.rs delete mode 100644 nexum/crates/nexum-runtime/src/preset.rs delete mode 100644 nexum/crates/nexum-runtime/src/runtime/dispatch_rate.rs delete mode 100644 nexum/crates/nexum-runtime/src/runtime/event_loop.rs delete mode 100644 nexum/crates/nexum-runtime/src/runtime/limits.rs delete mode 100644 nexum/crates/nexum-runtime/src/runtime/mod.rs delete mode 100644 nexum/crates/nexum-runtime/src/runtime/poison_policy.rs delete mode 100644 nexum/crates/nexum-runtime/src/runtime/restart_policy.rs delete mode 100644 nexum/crates/nexum-runtime/src/supervisor.rs delete mode 100644 nexum/crates/nexum-runtime/src/supervisor/tests.rs delete mode 100644 nexum/crates/nexum-runtime/src/test_utils/builders.rs delete mode 100644 nexum/crates/nexum-runtime/src/test_utils/chain.rs delete mode 100644 nexum/crates/nexum-runtime/src/test_utils/clock.rs delete mode 100644 nexum/crates/nexum-runtime/src/test_utils/harness.rs delete mode 100644 nexum/crates/nexum-runtime/src/test_utils/mod.rs delete mode 100644 nexum/crates/nexum-runtime/src/test_utils/store.rs delete mode 100644 nexum/crates/nexum-runtime/src/test_utils/types.rs delete mode 100644 nexum/crates/nexum-sdk-test/Cargo.toml delete mode 100644 nexum/crates/nexum-sdk-test/src/lib.rs delete mode 100644 nexum/crates/nexum-sdk/Cargo.toml delete mode 100644 nexum/crates/nexum-sdk/src/address.rs delete mode 100644 nexum/crates/nexum-sdk/src/chain/chainlink.rs delete mode 100644 nexum/crates/nexum-sdk/src/chain/eth_call.rs delete mode 100644 nexum/crates/nexum-sdk/src/chain/id.rs delete mode 100644 nexum/crates/nexum-sdk/src/chain/method.rs delete mode 100644 nexum/crates/nexum-sdk/src/chain/mod.rs delete mode 100644 nexum/crates/nexum-sdk/src/chain/provider.rs delete mode 100644 nexum/crates/nexum-sdk/src/chain/transport.rs delete mode 100644 nexum/crates/nexum-sdk/src/config.rs delete mode 100644 nexum/crates/nexum-sdk/src/events.rs delete mode 100644 nexum/crates/nexum-sdk/src/host.rs delete mode 100644 nexum/crates/nexum-sdk/src/http.rs delete mode 100644 nexum/crates/nexum-sdk/src/keeper.rs delete mode 100644 nexum/crates/nexum-sdk/src/lib.rs delete mode 100644 nexum/crates/nexum-sdk/src/prelude.rs delete mode 100644 nexum/crates/nexum-sdk/src/proptests.rs delete mode 100644 nexum/crates/nexum-sdk/src/tracing.rs delete mode 100644 nexum/crates/nexum-sdk/src/wit_bindgen_macro.rs delete mode 100644 nexum/crates/nexum-sdk/tests/keeper.rs delete mode 100644 nexum/crates/nexum-status-body/Cargo.toml delete mode 100644 nexum/crates/nexum-status-body/src/lib.rs delete mode 100644 nexum/crates/nexum-tasks/Cargo.toml delete mode 100644 nexum/crates/nexum-tasks/src/lib.rs delete mode 100644 nexum/crates/nexum-tasks/src/manager.rs delete mode 100644 nexum/crates/nexum-tasks/src/shutdown.rs delete mode 100644 nexum/crates/nexum-tasks/src/task.rs delete mode 100644 nexum/crates/nexum-world/Cargo.toml delete mode 100644 nexum/crates/nexum-world/src/lib.rs delete mode 100644 nexum/modules/example/Cargo.toml delete mode 100644 nexum/modules/example/module.toml delete mode 100644 nexum/modules/example/src/lib.rs delete mode 100644 nexum/modules/examples/balance-tracker/Cargo.toml delete mode 100644 nexum/modules/examples/balance-tracker/module.toml delete mode 100644 nexum/modules/examples/balance-tracker/src/lib.rs delete mode 100644 nexum/modules/examples/balance-tracker/src/strategy.rs delete mode 100644 nexum/modules/examples/http-probe/Cargo.toml delete mode 100644 nexum/modules/examples/http-probe/module.toml delete mode 100644 nexum/modules/examples/http-probe/src/lib.rs delete mode 100644 nexum/modules/examples/http-probe/src/strategy.rs delete mode 100644 nexum/modules/examples/price-alert/Cargo.toml delete mode 100644 nexum/modules/examples/price-alert/module.toml delete mode 100644 nexum/modules/examples/price-alert/src/lib.rs delete mode 100644 nexum/modules/examples/price-alert/src/strategy.rs delete mode 100644 nexum/modules/fixtures/clock-reader/Cargo.toml delete mode 100644 nexum/modules/fixtures/clock-reader/module.toml delete mode 100644 nexum/modules/fixtures/clock-reader/src/lib.rs delete mode 100644 nexum/modules/fixtures/flaky-bomb/Cargo.toml delete mode 100644 nexum/modules/fixtures/flaky-bomb/module.toml delete mode 100644 nexum/modules/fixtures/flaky-bomb/src/lib.rs delete mode 100644 nexum/modules/fixtures/fuel-bomb/Cargo.toml delete mode 100644 nexum/modules/fixtures/fuel-bomb/module.toml delete mode 100644 nexum/modules/fixtures/fuel-bomb/src/lib.rs delete mode 100644 nexum/modules/fixtures/memory-bomb/Cargo.toml delete mode 100644 nexum/modules/fixtures/memory-bomb/module.toml delete mode 100644 nexum/modules/fixtures/memory-bomb/src/lib.rs delete mode 100644 nexum/modules/fixtures/panic-bomb/Cargo.toml delete mode 100644 nexum/modules/fixtures/panic-bomb/module.toml delete mode 100644 nexum/modules/fixtures/panic-bomb/src/lib.rs delete mode 100644 nexum/modules/fixtures/slow-host/Cargo.toml delete mode 100644 nexum/modules/fixtures/slow-host/module.toml delete mode 100644 nexum/modules/fixtures/slow-host/src/lib.rs delete mode 100755 nexum/scripts/check-venue-agnostic.sh delete mode 100644 nexum/wit/deps.lock delete mode 100644 nexum/wit/deps.toml rename {shepherd/scripts => scripts}/README.md (100%) rename {shepherd/scripts => scripts}/_ethflow_quote.py (100%) rename {shepherd/scripts => scripts}/_twap_calldata.py (100%) rename {shepherd/scripts => scripts}/check-cow-orderbook-only.sh (100%) delete mode 100755 scripts/check-dep-sync.sh rename {shepherd/scripts => scripts}/e2e-finish.sh (95%) rename {shepherd/scripts => scripts}/e2e-onchain.sh (100%) rename {shepherd/scripts => scripts}/e2e-report-gen.sh (99%) rename {shepherd/scripts => scripts}/e2e-run.sh (100%) rename {shepherd/scripts => scripts}/env-template (100%) rename {shepherd/scripts => scripts}/lib.sh (93%) rename {shepherd/scripts => scripts}/load-bootstrap.sh (100%) rename {shepherd/scripts => scripts}/load-run.sh (100%) rename {shepherd/scripts => scripts}/load-teardown.sh (100%) rename {shepherd/scripts => scripts}/soak-snapshot.sh (100%) delete mode 100644 shepherd/Cargo.repo.toml delete mode 100644 shepherd/wit/deps.toml delete mode 100644 shepherd/wit/deps/nexum-host/chain.wit delete mode 100644 shepherd/wit/deps/nexum-host/event-module.wit delete mode 100644 shepherd/wit/deps/nexum-host/identity.wit delete mode 100644 shepherd/wit/deps/nexum-host/local-store.wit delete mode 100644 shepherd/wit/deps/nexum-host/logging.wit delete mode 100644 shepherd/wit/deps/nexum-host/messaging.wit delete mode 100644 shepherd/wit/deps/nexum-host/query-module.wit delete mode 100644 shepherd/wit/deps/nexum-host/remote-store.wit delete mode 100644 shepherd/wit/deps/nexum-host/types.wit rename {shepherd/tools => tools}/backtest-collect/backtest_collect.py (100%) rename {shepherd/tools => tools}/backtest-collect/fixtures-2026-06-22.json (100%) rename {shepherd/tools => tools}/baseline-latency/.gitignore (100%) rename {shepherd/tools => tools}/baseline-latency/baseline_latency.py (100%) rename {shepherd/tools => tools}/baseline-latency/data/arbitrum_one.json (100%) rename {shepherd/tools => tools}/baseline-latency/data/base.json (100%) rename {shepherd/tools => tools}/baseline-latency/data/gnosis.json (100%) rename {shepherd/tools => tools}/baseline-latency/data/mainnet.json (100%) rename {shepherd/tools => tools}/baseline-latency/data/sepolia.json (100%) rename {shepherd/tools => tools}/load-gen/Cargo.toml (100%) rename {shepherd/tools => tools}/load-gen/src/main.rs (100%) rename {shepherd/tools => tools}/orderbook-mock/Cargo.toml (100%) rename {shepherd/tools => tools}/orderbook-mock/src/main.rs (100%) delete mode 100644 videre/Cargo.repo.toml delete mode 100644 videre/crates/no-std-probe/Cargo.toml delete mode 100644 videre/crates/no-std-probe/src/lib.rs delete mode 100644 videre/crates/videre-host/Cargo.toml delete mode 100644 videre/crates/videre-host/src/bindings.rs delete mode 100644 videre/crates/videre-host/src/client.rs delete mode 100644 videre/crates/videre-host/src/handshake.rs delete mode 100644 videre/crates/videre-host/src/lib.rs delete mode 100644 videre/crates/videre-host/src/registry.rs delete mode 100644 videre/crates/videre-host/tests/platform.rs delete mode 100644 videre/crates/videre-host/tests/zero_leak.rs delete mode 100644 videre/crates/videre-macros/Cargo.toml delete mode 100644 videre/crates/videre-macros/src/intent_body.rs delete mode 100644 videre/crates/videre-macros/src/keeper.rs delete mode 100644 videre/crates/videre-macros/src/lib.rs delete mode 100644 videre/crates/videre-macros/src/world.rs delete mode 100644 videre/crates/videre-sdk/Cargo.toml delete mode 100644 videre/crates/videre-sdk/src/adapter.rs delete mode 100644 videre/crates/videre-sdk/src/bindings.rs delete mode 100644 videre/crates/videre-sdk/src/body.rs delete mode 100644 videre/crates/videre-sdk/src/client.rs delete mode 100644 videre/crates/videre-sdk/src/faults.rs delete mode 100644 videre/crates/videre-sdk/src/keeper.rs delete mode 100644 videre/crates/videre-sdk/src/lib.rs delete mode 100644 videre/crates/videre-sdk/src/rt.rs delete mode 100644 videre/crates/videre-sdk/src/transport.rs delete mode 100644 videre/crates/videre-sdk/tests/adapter.rs delete mode 100644 videre/crates/videre-test/Cargo.toml delete mode 100644 videre/crates/videre-test/goldens/reference-header.json delete mode 100644 videre/crates/videre-test/src/codec.rs delete mode 100644 videre/crates/videre-test/src/fixture.rs delete mode 100644 videre/crates/videre-test/src/header.rs delete mode 100644 videre/crates/videre-test/src/lib.rs delete mode 100644 videre/crates/videre-test/src/reference.rs delete mode 100644 videre/crates/videre-test/src/report.rs delete mode 100644 videre/crates/videre-test/src/transport.rs delete mode 100644 videre/crates/videre-test/tests/conformance.rs delete mode 100644 videre/crates/videre-test/vectors/reference-body.json delete mode 100644 videre/modules/examples/echo-client/Cargo.toml delete mode 100644 videre/modules/examples/echo-client/module.toml delete mode 100644 videre/modules/examples/echo-client/src/lib.rs delete mode 100644 videre/modules/examples/echo-keeper/Cargo.toml delete mode 100644 videre/modules/examples/echo-keeper/module.toml delete mode 100644 videre/modules/examples/echo-keeper/src/lib.rs delete mode 100644 videre/modules/examples/echo-venue/Cargo.toml delete mode 100644 videre/modules/examples/echo-venue/module.toml delete mode 100644 videre/modules/examples/echo-venue/src/lib.rs delete mode 100644 videre/modules/fixtures/flaky-venue/Cargo.toml delete mode 100644 videre/modules/fixtures/flaky-venue/module.toml delete mode 100644 videre/modules/fixtures/flaky-venue/src/lib.rs delete mode 100644 videre/wit/deps.lock delete mode 100644 videre/wit/deps.toml delete mode 100644 videre/wit/deps/nexum-host/chain.wit delete mode 100644 videre/wit/deps/nexum-host/event-module.wit delete mode 100644 videre/wit/deps/nexum-host/identity.wit delete mode 100644 videre/wit/deps/nexum-host/local-store.wit delete mode 100644 videre/wit/deps/nexum-host/logging.wit delete mode 100644 videre/wit/deps/nexum-host/messaging.wit delete mode 100644 videre/wit/deps/nexum-host/query-module.wit delete mode 100644 videre/wit/deps/nexum-host/remote-store.wit delete mode 100644 videre/wit/deps/nexum-host/types.wit delete mode 100644 videre/wit/videre-types/types.wit delete mode 100644 videre/wit/videre-value-flow/types.wit delete mode 100644 videre/wit/videre-venue/venue.wit rename {shepherd/wit => wit}/deps.lock (100%) create mode 100644 wit/deps.toml rename {nexum/wit => wit/deps}/nexum-host/chain.wit (100%) rename {nexum/wit => wit/deps}/nexum-host/event-module.wit (100%) rename {nexum/wit => wit/deps}/nexum-host/identity.wit (100%) rename {nexum/wit => wit/deps}/nexum-host/local-store.wit (100%) rename {nexum/wit => wit/deps}/nexum-host/logging.wit (100%) rename {nexum/wit => wit/deps}/nexum-host/messaging.wit (100%) rename {nexum/wit => wit/deps}/nexum-host/query-module.wit (100%) rename {nexum/wit => wit/deps}/nexum-host/remote-store.wit (100%) rename {nexum/wit => wit/deps}/nexum-host/types.wit (100%) rename {shepherd/wit => wit}/deps/videre-types/types.wit (100%) rename {shepherd/wit => wit}/deps/videre-value-flow/types.wit (100%) rename {shepherd/wit => wit}/deps/videre-venue/venue.wit (100%) rename {shepherd/wit => wit}/shepherd-cow/cow-events.wit (100%) diff --git a/shepherd/.env.example b/.env.example similarity index 100% rename from shepherd/.env.example rename to .env.example diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1415435d..053834f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,19 +84,15 @@ jobs: - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 with: tool: nextest - # Build all 18 guest wasms ONCE (17 modules + the cow adapter, - # release/wasm32-wasip2): the single - # source of truth for guest buildability and the artifacts the integration - # tests load. Replaces the deleted 9-way build-module matrix, which recompiled - # the shared wasm dependency graph ~9x cold. Per-module size report folded in; - # a compile break still names the offending crate and -D warnings still applies. + # Build the shepherd guest wasms ONCE (the CoW keepers + the cow adapter, + # release/wasm32-wasip2): the single source of truth for guest + # buildability and the artifacts the integration tests load. Per-module + # size report folded in; a compile break still names the offending crate + # and -D warnings still applies. - name: build module wasms (+ size report) run: | cargo build --release --target wasm32-wasip2 --locked \ - -p example -p twap-monitor -p ethflow-watcher -p price-alert \ - -p balance-tracker -p stop-loss -p http-probe -p echo-venue \ - -p echo-client -p echo-keeper -p clock-reader -p flaky-bomb -p flaky-venue \ - -p fuel-bomb -p memory-bomb -p panic-bomb -p slow-host + -p twap-monitor -p ethflow-watcher -p stop-loss # Separate invocation on purpose: unifying `cow-venue/adapter` # into the module build would link the adapter's component # export glue into every keeper module wasm. @@ -156,23 +152,8 @@ jobs: AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar run: cargo check --workspace --all-features --locked --target aarch64-unknown-linux-gnu - # Blocking zero-leak gate: host-layer crate graphs stay venue-free, the - # runtime Rust sources carry no charter symbol and no privileged router - # field, and nexum:host names no foreign WIT package and resolves as a - # leaf (nexum/scripts/check-venue-agnostic.sh). - venue-agnostic: - name: venue-agnostic - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: ./.github/actions/rust-setup - - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 - with: - tool: wasm-tools,ripgrep - - run: ./nexum/scripts/check-venue-agnostic.sh - # Blocking orderbook-only gate: the CoW venue crate carries no - # composable symbol (shepherd/scripts/check-cow-orderbook-only.sh). + # composable symbol (scripts/check-cow-orderbook-only.sh). cow-orderbook-only: name: cow-orderbook-only runs-on: ubuntu-latest @@ -181,19 +162,4 @@ jobs: - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 with: tool: ripgrep - - run: ./shepherd/scripts/check-cow-orderbook-only.sh - - # Blocking dep-sync gate: the three-grouping crate DAG points strictly - # up, and the carve dependency artefacts (umbrella [patch], git-tag - # pins, per-group Cargo.repo.toml, wit-deps locks) agree wherever they - # exist (scripts/check-dep-sync.sh; docs/design/cross-repo-deps.md). - dep-sync: - name: dep-sync - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: ./.github/actions/rust-setup - - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 - with: - tool: ripgrep - - run: ./scripts/check-dep-sync.sh + - run: ./scripts/check-cow-orderbook-only.sh diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4db3f43e..fee2ac94 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -73,7 +73,7 @@ jobs: uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . - file: ./shepherd/Dockerfile + file: ./Dockerfile # Push on every non-PR event; PR builds are local-only smoke. push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} diff --git a/Cargo.lock b/Cargo.lock index 3dae4517..5c7afcc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,16 +885,6 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" -[[package]] -name = "assert-json-diff" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "async-stream" version = "0.3.6" @@ -1042,16 +1032,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "balance-tracker" -version = "0.1.0" -dependencies = [ - "nexum-sdk", - "nexum-sdk-test", - "tracing", - "wit-bindgen 0.59.0", -] - [[package]] name = "base-x" version = "0.2.11" @@ -1470,13 +1450,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "clock-reader" -version = "0.1.0" -dependencies = [ - "wit-bindgen 0.59.0", -] - [[package]] name = "cmake" version = "0.1.58" @@ -2023,24 +1996,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "deadpool" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" -dependencies = [ - "deadpool-runtime", - "lazy_static", - "num_cpus", - "tokio", -] - -[[package]] -name = "deadpool-runtime" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" - [[package]] name = "debugid" version = "0.8.0" @@ -2199,32 +2154,6 @@ dependencies = [ "spki", ] -[[package]] -name = "echo-client" -version = "0.1.0" -dependencies = [ - "nexum-sdk", - "wit-bindgen 0.58.0", -] - -[[package]] -name = "echo-keeper" -version = "0.1.0" -dependencies = [ - "nexum-sdk", - "videre-sdk", - "wit-bindgen 0.59.0", -] - -[[package]] -name = "echo-venue" -version = "0.1.0" -dependencies = [ - "videre-sdk", - "videre-test", - "wit-bindgen 0.59.0", -] - [[package]] name = "educe" version = "0.6.0" @@ -2349,14 +2278,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "example" -version = "0.1.0" -dependencies = [ - "nexum-sdk", - "wit-bindgen 0.59.0", -] - [[package]] name = "fastrand" version = "1.9.0" @@ -2438,21 +2359,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" -[[package]] -name = "flaky-bomb" -version = "0.1.0" -dependencies = [ - "wit-bindgen 0.59.0", -] - -[[package]] -name = "flaky-venue" -version = "0.1.0" -dependencies = [ - "videre-sdk", - "wit-bindgen 0.59.0", -] - [[package]] name = "fnv" version = "1.0.7" @@ -2491,13 +2397,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "fuel-bomb" -version = "0.1.0" -dependencies = [ - "wit-bindgen 0.59.0", -] - [[package]] name = "funty" version = "2.0.0" @@ -2858,17 +2757,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http-probe" -version = "0.1.0" -dependencies = [ - "http", - "nexum-sdk", - "nexum-sdk-test", - "tracing", - "wit-bindgen 0.59.0", -] - [[package]] name = "httparse" version = "1.10.1" @@ -3553,13 +3441,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "memory-bomb" -version = "0.1.0" -dependencies = [ - "wit-bindgen 0.59.0", -] - [[package]] name = "metrics" version = "0.24.6" @@ -3657,19 +3538,10 @@ dependencies = [ "unsigned-varint", ] -[[package]] -name = "nexum-cli" -version = "0.2.0" -dependencies = [ - "anyhow", - "nexum-launch", - "nexum-runtime", - "tokio", -] - [[package]] name = "nexum-launch" version = "0.2.0" +source = "git+https://github.com/nullislabs/nexum-runtime?rev=17005ba50466e18a64b2f145544c75198549d8a1#17005ba50466e18a64b2f145544c75198549d8a1" dependencies = [ "anyhow", "clap", @@ -3681,6 +3553,7 @@ dependencies = [ [[package]] name = "nexum-module-macros" version = "0.1.0" +source = "git+https://github.com/nullislabs/nexum-runtime?rev=17005ba50466e18a64b2f145544c75198549d8a1#17005ba50466e18a64b2f145544c75198549d8a1" dependencies = [ "nexum-world", "proc-macro2", @@ -3691,6 +3564,7 @@ dependencies = [ [[package]] name = "nexum-runtime" version = "0.2.0" +source = "git+https://github.com/nullislabs/nexum-runtime?rev=17005ba50466e18a64b2f145544c75198549d8a1#17005ba50466e18a64b2f145544c75198549d8a1" dependencies = [ "alloy-chains", "alloy-primitives", @@ -3709,30 +3583,27 @@ dependencies = [ "http-body-util", "metrics", "metrics-exporter-prometheus", - "nexum-runtime", "nexum-tasks", "nexum-world", "redb", "serde", "serde_json", "strum", - "tempfile", "thiserror 2.0.18", "tokio", "toml 1.1.2+spec-1.1.0", "tracing", "tracing-core", - "tracing-subscriber", "url", "wasmtime", "wasmtime-wasi", "wasmtime-wasi-http", - "wiremock", ] [[package]] name = "nexum-sdk" version = "0.1.0" +source = "git+https://github.com/nullislabs/nexum-runtime?rev=17005ba50466e18a64b2f145544c75198549d8a1#17005ba50466e18a64b2f145544c75198549d8a1" dependencies = [ "alloy-json-rpc", "alloy-primitives", @@ -3743,10 +3614,7 @@ dependencies = [ "alloy-transport", "http", "nexum-module-macros", - "nexum-sdk-test", "nexum-status-body", - "nexum-world", - "proptest", "serde_json", "strum", "thiserror 2.0.18", @@ -3759,6 +3627,7 @@ dependencies = [ [[package]] name = "nexum-sdk-test" version = "0.1.0" +source = "git+https://github.com/nullislabs/nexum-runtime?rev=17005ba50466e18a64b2f145544c75198549d8a1#17005ba50466e18a64b2f145544c75198549d8a1" dependencies = [ "nexum-sdk", "tracing", @@ -3767,6 +3636,7 @@ dependencies = [ [[package]] name = "nexum-status-body" version = "0.1.0" +source = "git+https://github.com/nullislabs/nexum-runtime?rev=17005ba50466e18a64b2f145544c75198549d8a1#17005ba50466e18a64b2f145544c75198549d8a1" dependencies = [ "borsh", "thiserror 2.0.18", @@ -3775,6 +3645,7 @@ dependencies = [ [[package]] name = "nexum-tasks" version = "0.1.0" +source = "git+https://github.com/nullislabs/nexum-runtime?rev=17005ba50466e18a64b2f145544c75198549d8a1#17005ba50466e18a64b2f145544c75198549d8a1" dependencies = [ "futures", "tokio", @@ -3784,18 +3655,11 @@ dependencies = [ [[package]] name = "nexum-world" version = "0.1.0" +source = "git+https://github.com/nullislabs/nexum-runtime?rev=17005ba50466e18a64b2f145544c75198549d8a1#17005ba50466e18a64b2f145544c75198549d8a1" dependencies = [ - "tempfile", "toml 1.1.2+spec-1.1.0", ] -[[package]] -name = "no-std-probe" -version = "0.1.0" -dependencies = [ - "videre-sdk", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3933,15 +3797,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "panic-bomb" -version = "0.1.0" -dependencies = [ - "nexum-sdk", - "tracing", - "wit-bindgen 0.59.0", -] - [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -4159,18 +4014,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "price-alert" -version = "0.1.0" -dependencies = [ - "alloy-primitives", - "alloy-sol-types", - "nexum-sdk", - "nexum-sdk-test", - "tracing", - "wit-bindgen 0.59.0", -] - [[package]] name = "primitive-types" version = "0.12.2" @@ -4573,18 +4416,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "regex" -version = "1.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - [[package]] name = "regex-automata" version = "0.4.14" @@ -5341,13 +5172,6 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" -[[package]] -name = "slow-host" -version = "0.1.0" -dependencies = [ - "wit-bindgen 0.59.0", -] - [[package]] name = "smallvec" version = "1.15.2" @@ -6130,15 +5954,14 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "videre-host" version = "0.1.0" +source = "git+https://github.com/nullislabs/videre-nexum-module?rev=112868fb9dc3d9021a0fd17739d1443383b06612#112868fb9dc3d9021a0fd17739d1443383b06612" dependencies = [ "anyhow", "async-trait", "futures", "nexum-runtime", "nexum-status-body", - "nexum-tasks", "serde", - "tempfile", "thiserror 2.0.18", "tokio", "toml 1.1.2+spec-1.1.0", @@ -6149,6 +5972,7 @@ dependencies = [ [[package]] name = "videre-macros" version = "0.1.0" +source = "git+https://github.com/nullislabs/videre-nexum-module?rev=112868fb9dc3d9021a0fd17739d1443383b06612#112868fb9dc3d9021a0fd17739d1443383b06612" dependencies = [ "nexum-world", "proc-macro2", @@ -6159,11 +5983,11 @@ dependencies = [ [[package]] name = "videre-sdk" version = "0.1.0" +source = "git+https://github.com/nullislabs/videre-nexum-module?rev=112868fb9dc3d9021a0fd17739d1443383b06612#112868fb9dc3d9021a0fd17739d1443383b06612" dependencies = [ "borsh", "http", "nexum-sdk", - "nexum-sdk-test", "strum", "thiserror 2.0.18", "tracing", @@ -6174,6 +5998,7 @@ dependencies = [ [[package]] name = "videre-test" version = "0.1.0" +source = "git+https://github.com/nullislabs/videre-nexum-module?rev=112868fb9dc3d9021a0fd17739d1443383b06612#112868fb9dc3d9021a0fd17739d1443383b06612" dependencies = [ "borsh", "hex", @@ -6182,7 +6007,6 @@ dependencies = [ "nexum-sdk-test", "serde", "serde_json", - "tempfile", "thiserror 2.0.18", "videre-sdk", ] @@ -6338,18 +6162,6 @@ dependencies = [ "wasmparser 0.253.0", ] -[[package]] -name = "wasm-metadata" -version = "0.251.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f998ccc6e012f7b86865eb2a106c8a0422017a1a88977ce01a69f2244be2e57" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder 0.251.0", - "wasmparser 0.251.0", -] - [[package]] name = "wasm-metadata" version = "0.253.0" @@ -7071,29 +6883,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "wiremock" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" -dependencies = [ - "assert-json-diff", - "base64", - "deadpool", - "futures", - "http", - "http-body-util", - "hyper", - "hyper-util", - "log", - "once_cell", - "regex", - "serde", - "serde_json", - "tokio", - "url", -] - [[package]] name = "wit-bindgen" version = "0.57.1" @@ -7103,33 +6892,13 @@ dependencies = [ "bitflags", ] -[[package]] -name = "wit-bindgen" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a43552cfa071f246cfd99e5dbb23710dfe7336b3259e09339818483359470749" -dependencies = [ - "wit-bindgen-rust-macro 0.58.0", -] - [[package]] name = "wit-bindgen" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94c5e45f6d4cfaca727c1c48989ab3e05bb289bf84fbad226e1cfbbef2c04b7f" dependencies = [ - "wit-bindgen-rust-macro 0.59.0", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4738d1c9a78e97bc7f664bfafd5d8e67d7bb26faa5c41e6d628e8bbdad3ec351" -dependencies = [ - "anyhow", - "heck", - "wit-parser 0.251.0", + "wit-bindgen-rust-macro", ] [[package]] @@ -7143,22 +6912,6 @@ dependencies = [ "wit-parser 0.253.0", ] -[[package]] -name = "wit-bindgen-rust" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1130ce1f531bc9f9a75922244aa773bf5e2117fda1ef4a86b9f98d6b8135eb46" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.118", - "wasm-metadata 0.251.0", - "wit-bindgen-core 0.58.0", - "wit-component 0.251.0", -] - [[package]] name = "wit-bindgen-rust" version = "0.59.0" @@ -7170,24 +6923,9 @@ dependencies = [ "indexmap 2.14.0", "prettyplease", "syn 2.0.118", - "wasm-metadata 0.253.0", - "wit-bindgen-core 0.59.0", - "wit-component 0.253.0", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07296369e4d598e7e79b64eef66f724d83324ea671bcf677d78fc5cf92604ae5" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.118", - "wit-bindgen-core 0.58.0", - "wit-bindgen-rust 0.58.0", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", ] [[package]] @@ -7201,27 +6939,8 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.118", - "wit-bindgen-core 0.59.0", - "wit-bindgen-rust 0.59.0", -] - -[[package]] -name = "wit-component" -version = "0.251.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a5e60173c413659c689f0581b0cf5d1a2404077568f9ffdce748a9eb2fc913" -dependencies = [ - "anyhow", - "bitflags", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder 0.251.0", - "wasm-metadata 0.251.0", - "wasmparser 0.251.0", - "wit-parser 0.251.0", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] @@ -7238,7 +6957,7 @@ dependencies = [ "serde_derive", "serde_json", "wasm-encoder 0.253.0", - "wasm-metadata 0.253.0", + "wasm-metadata", "wasmparser 0.253.0", "wit-parser 0.253.0", ] diff --git a/Cargo.toml b/Cargo.toml index aa783df3..91820110 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,42 +1,18 @@ +# Workspace root for the standalone shepherd repo; renamed to Cargo.toml +# at the carve. Carries no cross-group [patch]: nexum-* and videre-* deps +# resolve from their real git tags. + [workspace] members = [ - "nexum/crates/nexum-cli", - "nexum/crates/nexum-launch", - "nexum/crates/nexum-module-macros", - "nexum/crates/nexum-runtime", - "nexum/crates/nexum-sdk", - "nexum/crates/nexum-sdk-test", - "nexum/crates/nexum-status-body", - "nexum/crates/nexum-tasks", - "nexum/crates/nexum-world", - "nexum/modules/example", - "nexum/modules/examples/balance-tracker", - "nexum/modules/examples/http-probe", - "nexum/modules/examples/price-alert", - "nexum/modules/fixtures/clock-reader", - "nexum/modules/fixtures/flaky-bomb", - "nexum/modules/fixtures/fuel-bomb", - "nexum/modules/fixtures/memory-bomb", - "nexum/modules/fixtures/panic-bomb", - "nexum/modules/fixtures/slow-host", - "videre/crates/no-std-probe", - "videre/crates/videre-host", - "videre/crates/videre-macros", - "videre/crates/videre-sdk", - "videre/crates/videre-test", - "videre/modules/examples/echo-client", - "videre/modules/examples/echo-keeper", - "videre/modules/examples/echo-venue", - "videre/modules/fixtures/flaky-venue", - "shepherd/crates/composable-cow", - "shepherd/crates/cow-venue", - "shepherd/crates/shepherd", - "shepherd/crates/shepherd-backtest", - "shepherd/modules/ethflow-watcher", - "shepherd/modules/examples/stop-loss", - "shepherd/modules/twap-monitor", - "shepherd/tools/load-gen", - "shepherd/tools/orderbook-mock", + "crates/composable-cow", + "crates/cow-venue", + "crates/shepherd", + "crates/shepherd-backtest", + "modules/ethflow-watcher", + "modules/examples/stop-loss", + "modules/twap-monitor", + "tools/load-gen", + "tools/orderbook-mock", ] resolver = "2" @@ -45,201 +21,67 @@ edition = "2024" license = "AGPL-3.0" repository = "https://github.com/nullislabs/shepherd" -# Shared dependency table. Every external dependency is hoisted here. -# Core workspace crates (the engine, SDK, tooling) inherit with -# `dep.workspace = true` and may add features per call site via -# `dep = { workspace = true, features = ["extra"] }`. Standalone guest -# modules under `modules/` do NOT inherit external deps from here: a -# real module author has no access to this table, so they express-declare -# their own external deps and inherit only local crates via `path`. -# Version drift across the core crates (the failure mode that prompted -# hoisting, e.g. cowprotocol on `1.0.0-alpha` vs `1.0.0-alpha.3`) is now -# impossible by construction. +# Hoisted external deps. Core crates inherit with `dep.workspace = true`; +# guest modules express-declare their own external deps. [workspace.dependencies] # Error + async plumbing. anyhow = "1" thiserror = "2" tokio = { version = "1", features = ["full"] } futures = "0.3" -# Cold `dyn` boot paths only (`ProviderKind::install`); hot guest traits -# use native async-fn-in-trait. -async-trait = "0.1" # Serde + config. serde = { version = "1", features = ["derive"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } +toml = "1" -# Borsh wire codec behind the venue SDK's versioned `IntentBody` bodies. -# The venue SDK re-exports the runtime crate for its derive's generated -# code; `derive` is on so venue payload types can `#[derive(BorshSerialize, -# BorshDeserialize)]` through the same dependency. +# Wire codec. borsh = { version = "1", features = ["derive"] } # Observability. tracing = "0.1" -# `tracing-core` alone (no subscriber registry) backs the guest-side -# tracing facade in `nexum-sdk`: a wasm module links only the event -# dispatch, not the host-oriented fmt/registry machinery. -tracing-core = { version = "0.1", default-features = false, features = ["std"] } tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "ansi", "json"] } -# `strum::IntoStaticStr` on every error / event enum gives a free -# snake_case `&'static str` for every variant, which feeds directly -# into `metrics::counter!(..., "error_kind" => name)` and -# `tracing::warn!(error_kind = name, ...)` recordings without an -# ad-hoc `match err { ... => "connect" ... }` ladder per call site. -strum = { version = "0.28", default-features = false, features = ["derive"] } - -# `auto_impl::auto_impl(&, Arc, Box)` forwarding impls for traits -# held through smart pointers. Available workspace-wide so any future -# `Arc` boundary can opt in without touching root manifest. -auto_impl = "1" - -# `derive_more` newtype boilerplate (`Deref`, `From`, `Display`, ...). -# `default-features = false, features = ["full"]` keeps the proc-macro -# surface predictable; per-derive opt-in via the standard `#[derive(...)]` -# syntax. Available workspace-wide; not pulled in by default. -derive_more = { version = "2", default-features = false, features = ["full"] } - -# CLI parser. Used by every binary crate (engine, load-gen, -# orderbook-mock, shepherd-backtest) via the derive macro. +# CLI parser. clap = { version = "4", features = ["derive"] } -# alloy stack. Engine uses the full provider/transport surface; -# guest-facing crates use `alloy-primitives` + `alloy-sol-types` for -# typed protocol values. Pinned together so a single workspace bump -# moves every consumer at once. +# alloy stack; featureless provider, call sites add transports. alloy-primitives = { version = "1.6", default-features = false, features = ["std", "serde"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } -# Featureless here so the guest SDK's wasm build stays transport-free; -# the engine and tooling add ws/ipc/pubsub/reqwest at their call sites. alloy-provider = { version = "2.1", default-features = false } alloy-rpc-types-eth = { version = "2.1", default-features = false, features = ["std"] } alloy-transport-ws = { version = "2.1", default-features = false } -# Typed EIP-155 chain ids for config keys, provider/orderbook pools, and -# the chain seam. Already in the graph via alloy-provider; named here so -# the engine can key maps and signatures on `Chain` instead of a bare u64. -alloy-chains = { version = "0.2", default-features = false, features = ["std", "serde"] } -# HTTP transport for the SDK helpers and the orderbook-mock test -# surface. +# HTTP surface. reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } -# Typed HTTP method/request/response for the engine's wasi:http gate. -# Single `http` version in the graph via reqwest, so `reqwest::Method` -# is `http::Method`. http = "1" -# Body trait + combinators (already in the graph via wasmtime-wasi-http) -# for the engine's response-body cap on the wasi:http gate. -http-body = "1" -http-body-util = "0.1" -bytes = "1" - -# Bee HTTP API client (lib name `bee`) behind the engine's Swarm -# `remote-store` backend. -bee-rs = "1.7" - -# Proc-macro toolkit backing `nexum-module-macros` and `videre-macros`. -# Host-side only: a proc-macro crate always builds for the host, even -# when the module consuming it targets wasm. -proc-macro2 = "1" -quote = "1" -syn = { version = "2", features = ["full"] } - -# `wit-bindgen` is consumed by every guest module crate (example + -# every strategy + every fixture). Hoisted so a single bump moves -# them in lock-step. -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } - -# Guest-side wasi:http client behind the SDK's `http::fetch` helper. -# Binds wasi 0.2.x through the `wasip2` crate, matching the wasi:http -# WIT the engine's wasmtime hosts. `default-features = false` drops -# the serde-json body helpers the SDK does not expose. -# Licence: Apache-2.0 WITH LLVM-exception. -wstd = { version = "0.6", default-features = false } - -# WASM Component Model runtime (engine host). -wasmtime = { version = "46", features = ["component-model"] } -wasmtime-wasi = "46" -# Host implementation of wasi:http; default features keep the hyper + -# rustls outgoing backend (`default-send-request`) the engine fronts -# with its per-module allowlist gate. -wasmtime-wasi-http = "46" - -# Manifest parsing. -toml = "1" - -# Metrics facade + Prometheus exporter (engine `/metrics` listener). -metrics = "0.24" -metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] } - -# alloy JSON-RPC client + transport (engine chain backend and the -# guest SDK's host-backed transport). -alloy-json-rpc = { version = "2.1", default-features = false } -alloy-rpc-client = { version = "2.1", default-features = false } -alloy-transport = { version = "2.1", default-features = false } +axum = "0.8" -# Embedded key-value store (engine local-store backend). -redb = "4" +# Guest bindings. +wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } -# Misc engine host deps. +# Misc. url = "2" - -# HTTP server for the orderbook mock. -axum = "0.8" - -# Randomness for tooling. rand = "0.10" - -# Hex codec for the backtest harness. hex = "0.4" # Dev/test helpers. -tempfile = "3" -wiremock = "0.6" proptest = "1" tower = "0.5" -# Workspace-standard lint set. New crates inherit via -# `[lints] workspace = true` in their package manifest. `unsafe_code` -# cannot be denied workspace-wide because every wit-bindgen guest -# module emits an `unsafe extern "C"` shim; modules carrying that -# macro keep the default-warn allowance, and unsafe in non-binding -# code still trips review by convention. [workspace.lints.rust] unsafe_op_in_unsafe_fn = "warn" [workspace.lints.clippy] -# Deny the easy footguns. Each crate carries its own narrower -# `#![deny(...)]` where the cost of a violation is high (e.g. the -# binary entrypoints carry `unused_crate_dependencies` warn). dbg_macro = "deny" todo = "deny" -# `cowprotocol` 0.2.0 (the `OrderCreationAppData` hash-only submission -# shape plus the WASI clock fix that keeps `js_sys` out of non-browser -# wasm builds) is not published yet; the latest crates.io release is -# 0.2.0-alpha.1 without either. Patch to the cow-rs rev carrying both. -# Drop once a cowprotocol release with the hash-only constructor ships. +# `cowprotocol` 0.2.0 (hash-only submission shape + the WASI clock fix) +# is unpublished; patch to the cow-rs rev carrying both. Drop once a +# release with the hash-only constructor ships. [patch.crates-io] cowprotocol = { git = "https://github.com/nullislabs/cow-rs", rev = "17fc0c5f55d04fad38963911da07d5da9290fefb" } -# Cross-group deps already carry their final git-tag form. Resolve every -# such crate from the in-tree group until the split repos are populated; -# a fully patched source is never fetched. -[patch."https://github.com/nullislabs/nexum-runtime"] -nexum-launch = { path = "nexum/crates/nexum-launch" } -nexum-runtime = { path = "nexum/crates/nexum-runtime" } -nexum-sdk = { path = "nexum/crates/nexum-sdk" } -nexum-sdk-test = { path = "nexum/crates/nexum-sdk-test" } -nexum-status-body = { path = "nexum/crates/nexum-status-body" } -nexum-tasks = { path = "nexum/crates/nexum-tasks" } -nexum-world = { path = "nexum/crates/nexum-world" } - -[patch."https://github.com/nullislabs/videre-nexum-module"] -videre-host = { path = "videre/crates/videre-host" } -videre-sdk = { path = "videre/crates/videre-sdk" } -videre-test = { path = "videre/crates/videre-test" } - [profile.dev] panic = "abort" diff --git a/shepherd/Dockerfile b/Dockerfile similarity index 84% rename from shepherd/Dockerfile rename to Dockerfile index 1bd226bc..b20a7254 100644 --- a/shepherd/Dockerfile +++ b/Dockerfile @@ -68,8 +68,7 @@ FROM chef AS build COPY --from=planner /src/recipe.json recipe.json RUN cargo chef cook --release -p shepherd --recipe-path recipe.json \ && cargo chef cook --release --target wasm32-wasip2 \ - -p twap-monitor -p ethflow-watcher -p price-alert \ - -p balance-tracker -p stop-loss --recipe-path recipe.json \ + -p twap-monitor -p ethflow-watcher -p stop-loss --recipe-path recipe.json \ && cargo chef cook --release --target wasm32-wasip2 \ -p cow-venue --features cow-venue/adapter --recipe-path recipe.json @@ -82,13 +81,11 @@ COPY . . # is used verbatim so builds are reproducible. RUN cargo build -p shepherd --release --locked -# Five production modules plus the bundled cow venue adapter. The wasm +# Three production modules plus the bundled cow venue adapter. The wasm # artefacts land under # `target/wasm32-wasip2/release/.wasm`. RUN cargo build -p twap-monitor --target wasm32-wasip2 --release --locked \ && cargo build -p ethflow-watcher --target wasm32-wasip2 --release --locked \ - && cargo build -p price-alert --target wasm32-wasip2 --release --locked \ - && cargo build -p balance-tracker --target wasm32-wasip2 --release --locked \ && cargo build -p stop-loss --target wasm32-wasip2 --release --locked \ && cargo build -p cow-venue --target wasm32-wasip2 --release --locked --features adapter @@ -122,18 +119,16 @@ COPY --from=build /src/target/wasm32-wasip2/release/*.wasm /opt/shepherd/modules # Module manifests (the `module.toml` next to each cdylib crate). The # engine resolves capability declarations + chain subscriptions from # these at supervisor boot. -COPY --from=build /src/shepherd/modules/twap-monitor/module.toml /opt/shepherd/manifests/twap-monitor.toml -COPY --from=build /src/shepherd/modules/ethflow-watcher/module.toml /opt/shepherd/manifests/ethflow-watcher.toml -COPY --from=build /src/nexum/modules/examples/price-alert/module.toml /opt/shepherd/manifests/price-alert.toml -COPY --from=build /src/nexum/modules/examples/balance-tracker/module.toml /opt/shepherd/manifests/balance-tracker.toml -COPY --from=build /src/shepherd/modules/examples/stop-loss/module.toml /opt/shepherd/manifests/stop-loss.toml +COPY --from=build /src/modules/twap-monitor/module.toml /opt/shepherd/manifests/twap-monitor.toml +COPY --from=build /src/modules/ethflow-watcher/module.toml /opt/shepherd/manifests/ethflow-watcher.toml +COPY --from=build /src/modules/examples/stop-loss/module.toml /opt/shepherd/manifests/stop-loss.toml # The bundled cow venue adapter's manifests; installed via the # engine.toml [[adapters]] stanza, never compiled into the engine. # One manifest per chain: mainnet (cow-venue.toml) and Sepolia # (cow-venue.sepolia.toml); pick the one matching the run's chain. -COPY --from=build /src/shepherd/crates/cow-venue/module.toml /opt/shepherd/manifests/cow-venue.toml -COPY --from=build /src/shepherd/crates/cow-venue/module.sepolia.toml /opt/shepherd/manifests/cow-venue.sepolia.toml +COPY --from=build /src/crates/cow-venue/module.toml /opt/shepherd/manifests/cow-venue.toml +COPY --from=build /src/crates/cow-venue/module.sepolia.toml /opt/shepherd/manifests/cow-venue.sepolia.toml # Drop privileges. The engine never needs root at runtime: it only # reads /etc/shepherd/engine.toml, writes to /var/lib/shepherd, and diff --git a/shepherd/crates/composable-cow/Cargo.toml b/crates/composable-cow/Cargo.toml similarity index 87% rename from shepherd/crates/composable-cow/Cargo.toml rename to crates/composable-cow/Cargo.toml index 3b28a7f7..3d3e784d 100644 --- a/shepherd/crates/composable-cow/Cargo.toml +++ b/crates/composable-cow/Cargo.toml @@ -18,11 +18,11 @@ alloy-primitives.workspace = true alloy-sol-types.workspace = true borsh.workspace = true cowprotocol = { version = "0.2.0", default-features = false } -nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } +nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", rev = "17005ba50466e18a64b2f145544c75198549d8a1" } # `sweep` slice: the keeper run over the typed CoW client on the # `videre:venue/client` seam. cow-venue = { path = "../cow-venue", features = ["client", "assembly"], optional = true } -videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", tag = "v0.1.0", optional = true } +videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "112868fb9dc3d9021a0fd17739d1443383b06612", optional = true } tracing = { workspace = true, optional = true } [features] @@ -32,7 +32,7 @@ sweep = ["dep:cow-venue", "dep:videre-sdk", "dep:tracing"] [dev-dependencies] proptest.workspace = true -nexum-sdk-test = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } +nexum-sdk-test = { git = "https://github.com/nullislabs/nexum-runtime", rev = "17005ba50466e18a64b2f145544c75198549d8a1" } [[test]] name = "sweep" diff --git a/shepherd/crates/composable-cow/src/body.rs b/crates/composable-cow/src/body.rs similarity index 100% rename from shepherd/crates/composable-cow/src/body.rs rename to crates/composable-cow/src/body.rs diff --git a/shepherd/crates/composable-cow/src/lib.rs b/crates/composable-cow/src/lib.rs similarity index 100% rename from shepherd/crates/composable-cow/src/lib.rs rename to crates/composable-cow/src/lib.rs diff --git a/shepherd/crates/composable-cow/src/poll.rs b/crates/composable-cow/src/poll.rs similarity index 100% rename from shepherd/crates/composable-cow/src/poll.rs rename to crates/composable-cow/src/poll.rs diff --git a/shepherd/crates/composable-cow/src/sweep.rs b/crates/composable-cow/src/sweep.rs similarity index 100% rename from shepherd/crates/composable-cow/src/sweep.rs rename to crates/composable-cow/src/sweep.rs diff --git a/shepherd/crates/composable-cow/tests/sweep.rs b/crates/composable-cow/tests/sweep.rs similarity index 100% rename from shepherd/crates/composable-cow/tests/sweep.rs rename to crates/composable-cow/tests/sweep.rs diff --git a/shepherd/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml similarity index 94% rename from shepherd/crates/cow-venue/Cargo.toml rename to crates/cow-venue/Cargo.toml index 2758dc09..b9c5e43c 100644 --- a/shepherd/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -23,12 +23,12 @@ workspace = true borsh = { workspace = true, optional = true } # Source of the `IntentBody` derive and trait the version enum implements, # and the typed intent client the `client` slice binds to the CoW venue. -videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", tag = "v0.1.0", optional = true } +videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "112868fb9dc3d9021a0fd17739d1443383b06612", optional = true } # `client` slice only: the keeper `RetryAction` the generated # classification table maps each errorType to. The TOML parse happens in # `build.rs`, so serde/toml/thiserror are build- and dev-only and never # reach a guest that links this slice. -nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0", optional = true } +nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", rev = "17005ba50466e18a64b2f145544c75198549d8a1", optional = true } # `assembly` slice: the chain-edge order projections and orderbook # submission bodies. Express-declared (not workspace-inherited) so the # guest build never inherits the native `http-client` feature. @@ -55,7 +55,7 @@ serde = { workspace = true } toml = { workspace = true } thiserror = { workspace = true } # The conformance kit: holds the body codec to its published vector set. -videre-test = { git = "https://github.com/nullislabs/videre-nexum-module", tag = "v0.1.0" } +videre-test = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "112868fb9dc3d9021a0fd17739d1443383b06612" } # Parity tests only: the upstream errorType enum and `retry_hint()` the # shipped table is reconciled against. Never a runtime dependency. cowprotocol = { version = "0.2.0", default-features = false } diff --git a/shepherd/crates/cow-venue/build.rs b/crates/cow-venue/build.rs similarity index 100% rename from shepherd/crates/cow-venue/build.rs rename to crates/cow-venue/build.rs diff --git a/shepherd/crates/cow-venue/data/classification.toml b/crates/cow-venue/data/classification.toml similarity index 100% rename from shepherd/crates/cow-venue/data/classification.toml rename to crates/cow-venue/data/classification.toml diff --git a/shepherd/crates/cow-venue/module.load.toml b/crates/cow-venue/module.load.toml similarity index 100% rename from shepherd/crates/cow-venue/module.load.toml rename to crates/cow-venue/module.load.toml diff --git a/shepherd/crates/cow-venue/module.sepolia.toml b/crates/cow-venue/module.sepolia.toml similarity index 100% rename from shepherd/crates/cow-venue/module.sepolia.toml rename to crates/cow-venue/module.sepolia.toml diff --git a/shepherd/crates/cow-venue/module.toml b/crates/cow-venue/module.toml similarity index 100% rename from shepherd/crates/cow-venue/module.toml rename to crates/cow-venue/module.toml diff --git a/shepherd/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs similarity index 100% rename from shepherd/crates/cow-venue/src/adapter.rs rename to crates/cow-venue/src/adapter.rs diff --git a/shepherd/crates/cow-venue/src/assembly.rs b/crates/cow-venue/src/assembly.rs similarity index 100% rename from shepherd/crates/cow-venue/src/assembly.rs rename to crates/cow-venue/src/assembly.rs diff --git a/shepherd/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs similarity index 100% rename from shepherd/crates/cow-venue/src/body.rs rename to crates/cow-venue/src/body.rs diff --git a/shepherd/crates/cow-venue/src/classification.rs b/crates/cow-venue/src/classification.rs similarity index 100% rename from shepherd/crates/cow-venue/src/classification.rs rename to crates/cow-venue/src/classification.rs diff --git a/shepherd/crates/cow-venue/src/classification_data.rs b/crates/cow-venue/src/classification_data.rs similarity index 100% rename from shepherd/crates/cow-venue/src/classification_data.rs rename to crates/cow-venue/src/classification_data.rs diff --git a/shepherd/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs similarity index 100% rename from shepherd/crates/cow-venue/src/client.rs rename to crates/cow-venue/src/client.rs diff --git a/shepherd/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs similarity index 98% rename from shepherd/crates/cow-venue/src/lib.rs rename to crates/cow-venue/src/lib.rs index 8cafcd00..990c4363 100644 --- a/shepherd/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -29,7 +29,7 @@ //! //! The `assembly` slice carries the chain-edge order projections and //! orderbook submission bodies; the `adapter` slice on top of it is -//! the venue-adapter component itself ([`CowAdapter`] under +//! the venue-adapter component itself (`CowAdapter` under //! `#[videre_sdk::venue]`), built as the cdylib for wasm32-wasip2 and //! never linked by a keeper module (linking it would export the //! adapter face). diff --git a/shepherd/crates/cow-venue/src/order.rs b/crates/cow-venue/src/order.rs similarity index 100% rename from shepherd/crates/cow-venue/src/order.rs rename to crates/cow-venue/src/order.rs diff --git a/shepherd/crates/cow-venue/tests/conformance.rs b/crates/cow-venue/tests/conformance.rs similarity index 100% rename from shepherd/crates/cow-venue/tests/conformance.rs rename to crates/cow-venue/tests/conformance.rs diff --git a/shepherd/crates/cow-venue/tests/vectors/cow-header-goldens.json b/crates/cow-venue/tests/vectors/cow-header-goldens.json similarity index 100% rename from shepherd/crates/cow-venue/tests/vectors/cow-header-goldens.json rename to crates/cow-venue/tests/vectors/cow-header-goldens.json diff --git a/shepherd/crates/cow-venue/tests/vectors/cow-intent-body.json b/crates/cow-venue/tests/vectors/cow-intent-body.json similarity index 100% rename from shepherd/crates/cow-venue/tests/vectors/cow-intent-body.json rename to crates/cow-venue/tests/vectors/cow-intent-body.json diff --git a/shepherd/crates/cow-venue/tests/wit_layering.rs b/crates/cow-venue/tests/wit_layering.rs similarity index 100% rename from shepherd/crates/cow-venue/tests/wit_layering.rs rename to crates/cow-venue/tests/wit_layering.rs diff --git a/shepherd/crates/shepherd-backtest/Cargo.toml b/crates/shepherd-backtest/Cargo.toml similarity index 85% rename from shepherd/crates/shepherd-backtest/Cargo.toml rename to crates/shepherd-backtest/Cargo.toml index 43e8d096..6044cd82 100644 --- a/shepherd/crates/shepherd-backtest/Cargo.toml +++ b/crates/shepherd-backtest/Cargo.toml @@ -16,9 +16,9 @@ path = "src/main.rs" # `strategy::on_chain_logs` directly without an embedded runtime. ethflow-watcher = { path = "../../modules/ethflow-watcher" } cow-venue = { path = "../cow-venue", features = ["client"] } -nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } -nexum-sdk-test = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } -videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", tag = "v0.1.0" } +nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", rev = "17005ba50466e18a64b2f145544c75198549d8a1" } +nexum-sdk-test = { git = "https://github.com/nullislabs/nexum-runtime", rev = "17005ba50466e18a64b2f145544c75198549d8a1" } +videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "112868fb9dc3d9021a0fd17739d1443383b06612" } anyhow.workspace = true clap.workspace = true diff --git a/shepherd/crates/shepherd-backtest/src/fixtures.rs b/crates/shepherd-backtest/src/fixtures.rs similarity index 100% rename from shepherd/crates/shepherd-backtest/src/fixtures.rs rename to crates/shepherd-backtest/src/fixtures.rs diff --git a/shepherd/crates/shepherd-backtest/src/main.rs b/crates/shepherd-backtest/src/main.rs similarity index 100% rename from shepherd/crates/shepherd-backtest/src/main.rs rename to crates/shepherd-backtest/src/main.rs diff --git a/shepherd/crates/shepherd-backtest/src/replay.rs b/crates/shepherd-backtest/src/replay.rs similarity index 100% rename from shepherd/crates/shepherd-backtest/src/replay.rs rename to crates/shepherd-backtest/src/replay.rs diff --git a/shepherd/crates/shepherd-backtest/src/report.rs b/crates/shepherd-backtest/src/report.rs similarity index 100% rename from shepherd/crates/shepherd-backtest/src/report.rs rename to crates/shepherd-backtest/src/report.rs diff --git a/shepherd/crates/shepherd/Cargo.toml b/crates/shepherd/Cargo.toml similarity index 71% rename from shepherd/crates/shepherd/Cargo.toml rename to crates/shepherd/Cargo.toml index eba2e8de..948bfa8a 100644 --- a/shepherd/crates/shepherd/Cargo.toml +++ b/crates/shepherd/Cargo.toml @@ -13,9 +13,9 @@ name = "shepherd" path = "src/main.rs" [dependencies] -nexum-launch = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } -nexum-runtime = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } -videre-host = { git = "https://github.com/nullislabs/videre-nexum-module", tag = "v0.1.0" } +nexum-launch = { git = "https://github.com/nullislabs/nexum-runtime", rev = "17005ba50466e18a64b2f145544c75198549d8a1" } +nexum-runtime = { git = "https://github.com/nullislabs/nexum-runtime", rev = "17005ba50466e18a64b2f145544c75198549d8a1" } +videre-host = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "112868fb9dc3d9021a0fd17739d1443383b06612" } anyhow.workspace = true tokio.workspace = true diff --git a/shepherd/crates/shepherd/src/main.rs b/crates/shepherd/src/main.rs similarity index 100% rename from shepherd/crates/shepherd/src/main.rs rename to crates/shepherd/src/main.rs diff --git a/shepherd/docker-compose.soak.yml b/docker-compose.soak.yml similarity index 100% rename from shepherd/docker-compose.soak.yml rename to docker-compose.soak.yml diff --git a/shepherd/docker-compose.yml b/docker-compose.yml similarity index 99% rename from shepherd/docker-compose.yml rename to docker-compose.yml index bdfaf8d9..11f88c6d 100644 --- a/shepherd/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ services: # isn't published yet. build: context: .. - dockerfile: shepherd/Dockerfile + dockerfile: Dockerfile container_name: shepherd restart: unless-stopped # The engine handles SIGINT for graceful shutdown; diff --git a/shepherd/engine.docker.toml b/engine.docker.toml similarity index 92% rename from shepherd/engine.docker.toml rename to engine.docker.toml index e2e697a3..74d9f03f 100644 --- a/shepherd/engine.docker.toml +++ b/engine.docker.toml @@ -66,14 +66,6 @@ manifest = "/opt/shepherd/manifests/twap-monitor.toml" path = "/opt/shepherd/modules/ethflow_watcher.wasm" manifest = "/opt/shepherd/manifests/ethflow-watcher.toml" -[[modules]] -path = "/opt/shepherd/modules/price_alert.wasm" -manifest = "/opt/shepherd/manifests/price-alert.toml" - -[[modules]] -path = "/opt/shepherd/modules/balance_tracker.wasm" -manifest = "/opt/shepherd/manifests/balance-tracker.toml" - [[modules]] path = "/opt/shepherd/modules/stop_loss.wasm" manifest = "/opt/shepherd/manifests/stop-loss.toml" diff --git a/shepherd/engine.e2e.toml b/engine.e2e.toml similarity index 82% rename from shepherd/engine.e2e.toml rename to engine.e2e.toml index 61ddedc3..d491281d 100644 --- a/shepherd/engine.e2e.toml +++ b/engine.e2e.toml @@ -48,23 +48,15 @@ rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" [[modules]] path = "target/wasm32-wasip2/release/twap_monitor.wasm" -manifest = "shepherd/modules/twap-monitor/module.toml" +manifest = "modules/twap-monitor/module.toml" [[modules]] path = "target/wasm32-wasip2/release/ethflow_watcher.wasm" -manifest = "shepherd/modules/ethflow-watcher/module.toml" - -[[modules]] -path = "target/wasm32-wasip2/release/price_alert.wasm" -manifest = "nexum/modules/examples/price-alert/module.toml" - -[[modules]] -path = "target/wasm32-wasip2/release/balance_tracker.wasm" -manifest = "nexum/modules/examples/balance-tracker/module.toml" +manifest = "modules/ethflow-watcher/module.toml" [[modules]] path = "target/wasm32-wasip2/release/stop_loss.wasm" -manifest = "shepherd/modules/examples/stop-loss/module.toml" +manifest = "modules/examples/stop-loss/module.toml" # --- adapters --------------------------------------------------------- @@ -73,5 +65,5 @@ manifest = "shepherd/modules/examples/stop-loss/module.toml" # match the chain twap indexes. [[adapters]] path = "target/wasm32-wasip2/release/cow_venue.wasm" -manifest = "shepherd/crates/cow-venue/module.sepolia.toml" +manifest = "crates/cow-venue/module.sepolia.toml" http_allow = ["api.cow.fi"] diff --git a/shepherd/engine.example.toml b/engine.example.toml similarity index 98% rename from shepherd/engine.example.toml rename to engine.example.toml index 7a119317..be346456 100644 --- a/shepherd/engine.example.toml +++ b/engine.example.toml @@ -113,5 +113,5 @@ rpc_url = "${BASE_RPC_URL}" # [[adapters]] # path = "target/wasm32-wasip2/release/cow_venue.wasm" -# manifest = "shepherd/crates/cow-venue/module.toml" +# manifest = "crates/cow-venue/module.toml" # http_allow = ["api.cow.fi"] diff --git a/shepherd/engine.load.toml b/engine.load.toml similarity index 90% rename from shepherd/engine.load.toml rename to engine.load.toml index 4c837856..4f8072b2 100644 --- a/shepherd/engine.load.toml +++ b/engine.load.toml @@ -35,16 +35,16 @@ rpc_url = "ws://localhost:8545" [[modules]] path = "./target/wasm32-wasip2/release/twap_monitor.wasm" -manifest = "./shepherd/modules/twap-monitor/module.toml" +manifest = "./modules/twap-monitor/module.toml" # The cow venue adapter twap-monitor submits through (`just # build-cow-venue`). Load-variant manifest: Sepolia chain id with the # orderbook re-pointed at tools/orderbook-mock. [[adapters]] path = "./target/wasm32-wasip2/release/cow_venue.wasm" -manifest = "./shepherd/crates/cow-venue/module.load.toml" +manifest = "./crates/cow-venue/module.load.toml" http_allow = ["localhost"] [[modules]] path = "./target/wasm32-wasip2/release/ethflow_watcher.wasm" -manifest = "./shepherd/modules/ethflow-watcher/module.toml" +manifest = "./modules/ethflow-watcher/module.toml" diff --git a/shepherd/engine.m2.toml b/engine.m2.toml similarity index 88% rename from shepherd/engine.m2.toml rename to engine.m2.toml index d4ee2181..255ef7f7 100644 --- a/shepherd/engine.m2.toml +++ b/engine.m2.toml @@ -28,16 +28,16 @@ rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" [[modules]] path = "target/wasm32-wasip2/release/twap_monitor.wasm" -manifest = "shepherd/modules/twap-monitor/module.toml" +manifest = "modules/twap-monitor/module.toml" [[modules]] path = "target/wasm32-wasip2/release/ethflow_watcher.wasm" -manifest = "shepherd/modules/ethflow-watcher/module.toml" +manifest = "modules/ethflow-watcher/module.toml" # The cow venue adapter twap-monitor submits through (`just # build-cow-venue`). Sepolia manifest: the adapter's orderbook must # match the chain twap indexes. [[adapters]] path = "target/wasm32-wasip2/release/cow_venue.wasm" -manifest = "shepherd/crates/cow-venue/module.sepolia.toml" +manifest = "crates/cow-venue/module.sepolia.toml" http_allow = ["api.cow.fi"] diff --git a/shepherd/engine.m3.toml b/engine.m3.toml similarity index 78% rename from shepherd/engine.m3.toml rename to engine.m3.toml index 98eb0731..c8a598ed 100644 --- a/shepherd/engine.m3.toml +++ b/engine.m3.toml @@ -24,17 +24,9 @@ log_level = "info,nexum_runtime=debug" [chains.11155111] rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" -[[modules]] -path = "target/wasm32-wasip2/release/price_alert.wasm" -manifest = "nexum/modules/examples/price-alert/module.toml" - -[[modules]] -path = "target/wasm32-wasip2/release/balance_tracker.wasm" -manifest = "nexum/modules/examples/balance-tracker/module.toml" - [[modules]] path = "target/wasm32-wasip2/release/stop_loss.wasm" -manifest = "shepherd/modules/examples/stop-loss/module.toml" +manifest = "modules/examples/stop-loss/module.toml" # --- adapters --------------------------------------------------------- @@ -43,5 +35,5 @@ manifest = "shepherd/modules/examples/stop-loss/module.toml" # match the chain the oracle is read on. [[adapters]] path = "target/wasm32-wasip2/release/cow_venue.wasm" -manifest = "shepherd/crates/cow-venue/module.sepolia.toml" +manifest = "crates/cow-venue/module.sepolia.toml" http_allow = ["api.cow.fi"] diff --git a/shepherd/engine.soak.docker.toml b/engine.soak.docker.toml similarity index 88% rename from shepherd/engine.soak.docker.toml rename to engine.soak.docker.toml index 7efd05cc..0b871519 100644 --- a/shepherd/engine.soak.docker.toml +++ b/engine.soak.docker.toml @@ -35,14 +35,6 @@ manifest = "/opt/shepherd/manifests/twap-monitor.toml" path = "/opt/shepherd/modules/ethflow_watcher.wasm" manifest = "/opt/shepherd/manifests/ethflow-watcher.toml" -[[modules]] -path = "/opt/shepherd/modules/price_alert.wasm" -manifest = "/opt/shepherd/manifests/price-alert.toml" - -[[modules]] -path = "/opt/shepherd/modules/balance_tracker.wasm" -manifest = "/opt/shepherd/manifests/balance-tracker.toml" - [[modules]] path = "/opt/shepherd/modules/stop_loss.wasm" manifest = "/opt/shepherd/manifests/stop-loss.toml" diff --git a/shepherd/engine.soak.toml b/engine.soak.toml similarity index 84% rename from shepherd/engine.soak.toml rename to engine.soak.toml index 3500a3eb..8428d2fb 100644 --- a/shepherd/engine.soak.toml +++ b/engine.soak.toml @@ -51,23 +51,15 @@ rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" [[modules]] path = "target/wasm32-wasip2/release/twap_monitor.wasm" -manifest = "shepherd/modules/twap-monitor/module.toml" +manifest = "modules/twap-monitor/module.toml" [[modules]] path = "target/wasm32-wasip2/release/ethflow_watcher.wasm" -manifest = "shepherd/modules/ethflow-watcher/module.toml" - -[[modules]] -path = "target/wasm32-wasip2/release/price_alert.wasm" -manifest = "nexum/modules/examples/price-alert/module.toml" - -[[modules]] -path = "target/wasm32-wasip2/release/balance_tracker.wasm" -manifest = "nexum/modules/examples/balance-tracker/module.toml" +manifest = "modules/ethflow-watcher/module.toml" [[modules]] path = "target/wasm32-wasip2/release/stop_loss.wasm" -manifest = "shepherd/modules/examples/stop-loss/module.toml" +manifest = "modules/examples/stop-loss/module.toml" # --- adapters --------------------------------------------------------- @@ -76,5 +68,5 @@ manifest = "shepherd/modules/examples/stop-loss/module.toml" # match the chain twap indexes. [[adapters]] path = "target/wasm32-wasip2/release/cow_venue.wasm" -manifest = "shepherd/crates/cow-venue/module.sepolia.toml" +manifest = "crates/cow-venue/module.sepolia.toml" http_allow = ["api.cow.fi"] diff --git a/justfile b/justfile index 5a1429cf..aed463c5 100644 --- a/justfile +++ b/justfile @@ -1,108 +1,49 @@ -# Build the engine binaries: the bare `nexum` engine and the cow -# composition root `shepherd`. +# Build the shepherd engine binary. build-engine: - cargo build -p nexum-cli -p shepherd - -# Build the example WASM module -build-module: - cargo build --target wasm32-wasip2 --release -p example - -# Build the reference venue adapter (echo-venue) for wasm32-wasip2. Its -# per-component world pins the #[videre_sdk::venue] acceptance test. -build-venue: - cargo build --target wasm32-wasip2 --release -p echo-venue + cargo build -p shepherd # Build the bundled cow venue adapter component. Install via the # engine.toml [[adapters]] stanza; the venue id is its manifest name. build-cow-venue: cargo build --target wasm32-wasip2 --release -p cow-venue --features adapter -# Build everything -build: build-engine build-module - -# Build the module then run the engine with it. The second argument is the -# module's module.toml — without it the engine prints the 0.1-compat -# deprecation warning and proceeds with empty capabilities/config. -run: build-module build-engine - cargo run -p nexum-cli -- target/wasm32-wasip2/release/example.wasm nexum/modules/example/module.toml - -# Run host engine unit tests -test: - cargo test -p nexum-runtime - -# Build module + engine, then run E2E integration tests -test-e2e: build-module build-engine - cargo test -p nexum-runtime supervisor::tests::e2e - -# Build the M2 modules (twap-monitor + ethflow-watcher) for wasm32-wasip2. -build-m2: - cargo build -p twap-monitor --target wasm32-wasip2 --release - cargo build -p ethflow-watcher --target wasm32-wasip2 --release - -# Run nexum wired for the M2 smoke / round-trip scenario -# (Sepolia, both M2 modules). See `docs/operations/m2-testnet-runbook.md`. -# --pretty-logs keeps the runbook-friendly human-readable formatter; -# production deploys omit the flag and emit JSON. -run-m2: build-m2 build-cow-venue build-engine - cargo run -p shepherd -- --engine-config shepherd/engine.m2.toml --pretty-logs - -# Build the M3 example modules (price-alert + balance-tracker + stop-loss) +# Build the CoW keeper modules (twap-monitor, ethflow-watcher, stop-loss) # for wasm32-wasip2. -build-m3: - cargo build -p price-alert --target wasm32-wasip2 --release - cargo build -p balance-tracker --target wasm32-wasip2 --release - cargo build -p stop-loss --target wasm32-wasip2 --release +build-modules: + cargo build --target wasm32-wasip2 --release \ + -p twap-monitor -p ethflow-watcher -p stop-loss -# Run nexum wired for the M3 smoke / validation scenario -# (Sepolia, 3 example modules). See `docs/operations/m3-testnet-runbook.md`. -# --pretty-logs keeps the runbook-friendly human-readable formatter; -# production deploys omit the flag and emit JSON. -run-m3: build-m3 build-cow-venue build-engine - cargo run -p shepherd -- --engine-config shepherd/engine.m3.toml --pretty-logs +# Build everything. +build: build-engine build-cow-venue build-modules -# Build the http-probe example module (wasi:http fetch + allowlist -# denial demo) for wasm32-wasip2. -build-http-probe: - cargo build -p http-probe --target wasm32-wasip2 --release - -# Build all 5 modules required by the E2E run (twap-monitor + -# ethflow-watcher + price-alert + balance-tracker + stop-loss). -build-e2e: build-m2 build-m3 +# Run host engine unit tests. +test: + cargo test -p shepherd -# Run the 4-6 h E2E integration scenario on Sepolia. All 5 modules -# dispatched simultaneously against a live RPC; metrics scraped at -# 127.0.0.1:9100/metrics. JSON logs (no --pretty-logs) so a -# downstream `jq` filter can mine submitted/dropped/backoff markers -# for the e2e report. See `docs/operations/e2e-testnet-runbook.md`. -run-e2e: build-e2e build-cow-venue build-engine - cargo run -p shepherd -- --engine-config shepherd/engine.e2e.toml +# Run shepherd wired for the M2 smoke / round-trip scenario (Sepolia, +# twap-monitor + ethflow-watcher). --pretty-logs keeps the human-readable +# formatter; production deploys omit the flag and emit JSON. +run-m2: build-modules build-cow-venue build-engine + cargo run -p shepherd -- --engine-config engine.m2.toml --pretty-logs -# Zero-leak gate: host-layer crate graphs, runtime charter-symbol and -# router-field scans, and the nexum:host WIT leaf and foreign-namespace -# scans. Blocking in CI. -check-venue-agnostic: - ./nexum/scripts/check-venue-agnostic.sh +# Run the E2E integration scenario on Sepolia. JSON logs (no --pretty-logs) +# so a downstream `jq` filter can mine submitted/dropped/backoff markers. +run-e2e: build-modules build-cow-venue build-engine + cargo run -p shepherd -- --engine-config engine.e2e.toml -# Orderbook-only gate: the CoW venue crate carries no composable -# symbol. Blocking in CI. +# Orderbook-only gate: the CoW venue crate carries no composable symbol. +# Blocking in CI. check-cow-orderbook-only: - ./shepherd/scripts/check-cow-orderbook-only.sh - -# Dep-sync gate: the three-grouping crate DAG points strictly up and -# the carve dependency artefacts agree. Blocking in CI. -check-dep-sync: - ./scripts/check-dep-sync.sh + ./scripts/check-cow-orderbook-only.sh -# Check the entire workspace +# Check the workspace. check: - cargo check --target wasm32-wasip2 -p example - cargo check -p nexum-runtime - cargo check -p nexum-cli -p shepherd + cargo check --workspace # Run the full CI series locally before pushing. Mirrors # .github/workflows/ci.yml one-to-one: rustfmt, clippy, rustdoc, the -# module wasms the integration tests need, and the workspace test -# suite, all under the `-D warnings` the CI workflow sets globally. +# module wasms the integration tests need, and the workspace test suite, +# all under the `-D warnings` the CI workflow sets globally. ci: #!/usr/bin/env bash set -euo pipefail @@ -112,8 +53,7 @@ ci: cargo clippy --workspace --all-targets --all-features -- -D warnings cargo doc --workspace --no-deps cargo build --release --target wasm32-wasip2 \ - -p example -p twap-monitor -p ethflow-watcher -p price-alert \ - -p balance-tracker -p stop-loss -p http-probe -p echo-venue \ - -p echo-client -p clock-reader -p flaky-bomb -p flaky-venue -p fuel-bomb \ - -p memory-bomb -p panic-bomb -p slow-host + -p twap-monitor -p ethflow-watcher -p stop-loss + cargo build --release --target wasm32-wasip2 -p cow-venue --features cow-venue/adapter cargo test --workspace --all-features --no-fail-fast + ./scripts/check-cow-orderbook-only.sh diff --git a/shepherd/modules/ethflow-watcher/Cargo.toml b/modules/ethflow-watcher/Cargo.toml similarity index 86% rename from shepherd/modules/ethflow-watcher/Cargo.toml rename to modules/ethflow-watcher/Cargo.toml index 90c71ae4..b57fd6d4 100644 --- a/shepherd/modules/ethflow-watcher/Cargo.toml +++ b/modules/ethflow-watcher/Cargo.toml @@ -13,8 +13,8 @@ repository.workspace = true crate-type = ["cdylib", "rlib"] [dependencies] -nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } -videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", tag = "v0.1.0" } +nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", rev = "17005ba50466e18a64b2f145544c75198549d8a1" } +videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "112868fb9dc3d9021a0fd17739d1443383b06612" } cow-venue = { path = "../../crates/cow-venue", features = ["client", "assembly"] } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } @@ -23,4 +23,4 @@ tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] -nexum-sdk-test = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } +nexum-sdk-test = { git = "https://github.com/nullislabs/nexum-runtime", rev = "17005ba50466e18a64b2f145544c75198549d8a1" } diff --git a/shepherd/modules/ethflow-watcher/module.toml b/modules/ethflow-watcher/module.toml similarity index 100% rename from shepherd/modules/ethflow-watcher/module.toml rename to modules/ethflow-watcher/module.toml diff --git a/shepherd/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs similarity index 100% rename from shepherd/modules/ethflow-watcher/src/lib.rs rename to modules/ethflow-watcher/src/lib.rs diff --git a/shepherd/modules/ethflow-watcher/src/strategy.rs b/modules/ethflow-watcher/src/strategy.rs similarity index 100% rename from shepherd/modules/ethflow-watcher/src/strategy.rs rename to modules/ethflow-watcher/src/strategy.rs diff --git a/shepherd/modules/examples/stop-loss/Cargo.toml b/modules/examples/stop-loss/Cargo.toml similarity index 88% rename from shepherd/modules/examples/stop-loss/Cargo.toml rename to modules/examples/stop-loss/Cargo.toml index dc4a99e6..27ca3e0e 100644 --- a/shepherd/modules/examples/stop-loss/Cargo.toml +++ b/modules/examples/stop-loss/Cargo.toml @@ -11,8 +11,8 @@ crate-type = ["cdylib"] [dependencies] cow-venue = { path = "../../../crates/cow-venue", features = ["client"] } -nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } -videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", tag = "v0.1.0" } +nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", rev = "17005ba50466e18a64b2f145544c75198549d8a1" } +videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "112868fb9dc3d9021a0fd17739d1443383b06612" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } tracing = { version = "0.1", default-features = false } @@ -21,7 +21,7 @@ wit-bindgen = { version = "0.59", default-features = false, features = ["macros" [dev-dependencies] # The chain-edge projections back the pinned-UID regression test. cow-venue = { path = "../../../crates/cow-venue", features = ["client", "assembly"] } -nexum-sdk-test = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } +nexum-sdk-test = { git = "https://github.com/nullislabs/nexum-runtime", rev = "17005ba50466e18a64b2f145544c75198549d8a1" } # Only used by tests in `strategy.rs` to encode a synthetic oracle # return body; the production code uses `nexum_sdk::chain::chainlink`. alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } diff --git a/shepherd/modules/examples/stop-loss/module.toml b/modules/examples/stop-loss/module.toml similarity index 100% rename from shepherd/modules/examples/stop-loss/module.toml rename to modules/examples/stop-loss/module.toml diff --git a/shepherd/modules/examples/stop-loss/src/lib.rs b/modules/examples/stop-loss/src/lib.rs similarity index 100% rename from shepherd/modules/examples/stop-loss/src/lib.rs rename to modules/examples/stop-loss/src/lib.rs diff --git a/shepherd/modules/examples/stop-loss/src/strategy.rs b/modules/examples/stop-loss/src/strategy.rs similarity index 100% rename from shepherd/modules/examples/stop-loss/src/strategy.rs rename to modules/examples/stop-loss/src/strategy.rs diff --git a/shepherd/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml similarity index 86% rename from shepherd/modules/twap-monitor/Cargo.toml rename to modules/twap-monitor/Cargo.toml index 6fafc1bb..376023ff 100644 --- a/shepherd/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -11,8 +11,8 @@ crate-type = ["cdylib"] [dependencies] composable-cow = { path = "../../crates/composable-cow", features = ["sweep"] } cow-venue = { path = "../../crates/cow-venue", features = ["client"] } -nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } -videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", tag = "v0.1.0" } +nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", rev = "17005ba50466e18a64b2f145544c75198549d8a1" } +videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "112868fb9dc3d9021a0fd17739d1443383b06612" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } @@ -22,4 +22,4 @@ wit-bindgen = { version = "0.59", default-features = false, features = ["macros" [dev-dependencies] cow-venue = { path = "../../crates/cow-venue", features = ["client", "assembly"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } -nexum-sdk-test = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } +nexum-sdk-test = { git = "https://github.com/nullislabs/nexum-runtime", rev = "17005ba50466e18a64b2f145544c75198549d8a1" } diff --git a/shepherd/modules/twap-monitor/module.toml b/modules/twap-monitor/module.toml similarity index 100% rename from shepherd/modules/twap-monitor/module.toml rename to modules/twap-monitor/module.toml diff --git a/shepherd/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs similarity index 100% rename from shepherd/modules/twap-monitor/src/lib.rs rename to modules/twap-monitor/src/lib.rs diff --git a/shepherd/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs similarity index 100% rename from shepherd/modules/twap-monitor/src/strategy.rs rename to modules/twap-monitor/src/strategy.rs diff --git a/nexum/Cargo.repo.toml b/nexum/Cargo.repo.toml deleted file mode 100644 index feeedfbc..00000000 --- a/nexum/Cargo.repo.toml +++ /dev/null @@ -1,120 +0,0 @@ -# Workspace root for the standalone nexum-runtime repo; renamed to -# Cargo.toml at the carve. Carries no [patch]: this group has no -# cross-group deps to neutralise. - -[workspace] -members = [ - "crates/nexum-cli", - "crates/nexum-launch", - "crates/nexum-module-macros", - "crates/nexum-runtime", - "crates/nexum-sdk", - "crates/nexum-sdk-test", - "crates/nexum-status-body", - "crates/nexum-tasks", - "crates/nexum-world", - "modules/example", - "modules/examples/balance-tracker", - "modules/examples/http-probe", - "modules/examples/price-alert", - "modules/fixtures/clock-reader", - "modules/fixtures/flaky-bomb", - "modules/fixtures/fuel-bomb", - "modules/fixtures/memory-bomb", - "modules/fixtures/panic-bomb", - "modules/fixtures/slow-host", -] -resolver = "2" - -[workspace.package] -edition = "2024" -license = "AGPL-3.0" -repository = "https://github.com/nullislabs/nexum-runtime" - -# Hoisted external deps. Core crates inherit with `dep.workspace = true`; -# guest modules express-declare their own external deps. -[workspace.dependencies] -# Error + async plumbing. -anyhow = "1" -thiserror = "2" -tokio = { version = "1", features = ["full"] } -futures = "0.3" -async-trait = "0.1" - -# Serde + config. -serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } -toml = "1" - -# Wire codec. -borsh = { version = "1", features = ["derive"] } - -# Observability. -tracing = "0.1" -tracing-core = { version = "0.1", default-features = false, features = ["std"] } -tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "ansi", "json"] } -strum = { version = "0.28", default-features = false, features = ["derive"] } -metrics = "0.24" -metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] } - -# CLI parser. -clap = { version = "4", features = ["derive"] } - -# alloy stack; featureless provider so the guest SDK's wasm build stays -# transport-free, call sites add ws/ipc/pubsub/reqwest. -alloy-primitives = { version = "1.6", default-features = false, features = ["std", "serde"] } -alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } -alloy-provider = { version = "2.1", default-features = false } -alloy-rpc-types-eth = { version = "2.1", default-features = false, features = ["std"] } -alloy-transport-ws = { version = "2.1", default-features = false } -alloy-chains = { version = "0.2", default-features = false, features = ["std", "serde"] } -alloy-json-rpc = { version = "2.1", default-features = false } -alloy-rpc-client = { version = "2.1", default-features = false } -alloy-transport = { version = "2.1", default-features = false } - -# HTTP surface. -http = "1" -http-body = "1" -http-body-util = "0.1" -bytes = "1" - -# Bee HTTP API client behind the Swarm `remote-store` backend. -bee-rs = "1.7" - -# Proc-macro toolkit. -proc-macro2 = "1" -quote = "1" -syn = { version = "2", features = ["full"] } - -# Guest-side wasi:http client behind the SDK's `http::fetch` helper. -wstd = { version = "0.6", default-features = false } - -# WASM Component Model runtime. -wasmtime = { version = "46", features = ["component-model"] } -wasmtime-wasi = "46" -wasmtime-wasi-http = "46" - -# Embedded key-value store (local-store backend). -redb = "4" - -# Misc engine host deps. -url = "2" - -# Dev/test helpers. -tempfile = "3" -wiremock = "0.6" -proptest = "1" -tower = "0.5" - -[workspace.lints.rust] -unsafe_op_in_unsafe_fn = "warn" - -[workspace.lints.clippy] -dbg_macro = "deny" -todo = "deny" - -[profile.dev] -panic = "abort" - -[profile.release] -panic = "abort" diff --git a/nexum/crates/nexum-cli/Cargo.toml b/nexum/crates/nexum-cli/Cargo.toml deleted file mode 100644 index 05bab22d..00000000 --- a/nexum/crates/nexum-cli/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "nexum-cli" -version = "0.2.0" -edition.workspace = true -license.workspace = true -repository.workspace = true - -[lints] -workspace = true - -[[bin]] -name = "nexum" -path = "src/main.rs" - -[dependencies] -nexum-launch = { path = "../nexum-launch" } -nexum-runtime = { path = "../nexum-runtime" } - -anyhow.workspace = true -tokio.workspace = true diff --git a/nexum/crates/nexum-cli/src/main.rs b/nexum/crates/nexum-cli/src/main.rs deleted file mode 100644 index c7be67c3..00000000 --- a/nexum/crates/nexum-cli/src/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! The bare `nexum` engine binary: the core lattice with no extension -//! payload, composed over the generic launcher. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] - -use nexum_runtime::preset::CoreRuntime; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - nexum_launch::run("nexum", CoreRuntime).await -} diff --git a/nexum/crates/nexum-launch/Cargo.toml b/nexum/crates/nexum-launch/Cargo.toml deleted file mode 100644 index aff18adc..00000000 --- a/nexum/crates/nexum-launch/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "nexum-launch" -version = "0.2.0" -edition.workspace = true -license.workspace = true -repository.workspace = true - -[lints] -workspace = true - -[dependencies] -nexum-runtime = { path = "../nexum-runtime" } - -anyhow.workspace = true -clap.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true diff --git a/nexum/crates/nexum-launch/src/cli.rs b/nexum/crates/nexum-launch/src/cli.rs deleted file mode 100644 index b9af9baa..00000000 --- a/nexum/crates/nexum-launch/src/cli.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! Shared CLI surface for engine binaries, derived via clap. - -use std::path::PathBuf; - -use clap::{CommandFactory, FromArgMatches, Parser}; - -/// Parsed CLI surface. -/// -/// ` [ []] [--engine-config ] [--pretty-logs]` -/// -/// Positional `` synthesises a one-module engine config. -/// Production deployments pass `--engine-config` and declare modules in -/// TOML. -/// -/// `--pretty-logs` selects the human-readable tracing formatter; without -/// it the engine emits JSON log lines per the structured-logging contract. -#[derive(Parser, Debug, Default)] -#[command( - about = "Run one or more Wasm Component modules under the engine supervisor", - long_about = None, - version, -)] -pub struct Cli { - /// Optional positional path to a Wasm Component file. Synthesises - /// a one-module engine config when no `--engine-config` is given. - pub wasm: Option, - - /// Optional positional path to the module's `module.toml` manifest. - /// Only consulted alongside the positional `wasm` shortcut. - pub manifest: Option, - - /// Optional explicit path to the engine-wide `engine.toml` config. - /// When omitted, the engine resolves the default search path - /// documented in `engine_config::load_or_default`. - #[arg(long = "engine-config")] - pub engine_config: Option, - - /// Use the human-readable tracing formatter instead of the - /// default JSON formatter (structured-logging contract). - #[arg(long = "pretty-logs")] - pub pretty_logs: bool, - - /// Override the chain-log poller's per-block `eth_getLogs` - /// concurrency during backfill. Higher catches up faster at more - /// node load. Overrides `[engine] log_backfill_concurrency` when - /// set. - #[arg(long = "log-backfill-concurrency")] - pub log_backfill_concurrency: Option, -} - -impl Cli { - /// Parse the process arguments under the binary's `name`, exiting on - /// `--help`/`--version` or a usage error. - #[must_use] - pub fn parse_as(name: &'static str) -> Self { - let matches = Self::command().name(name).get_matches(); - Self::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The flags land on the parsed surface under a caller-supplied name. - #[test] - fn flags_parse_under_a_supplied_name() { - let matches = Cli::command() - .name("nexum") - .try_get_matches_from([ - "nexum", - "--engine-config", - "engine.toml", - "--pretty-logs", - "--log-backfill-concurrency", - "8", - ]) - .expect("valid arguments parse"); - let cli = Cli::from_arg_matches(&matches).expect("matches destructure"); - assert_eq!(cli.engine_config, Some(PathBuf::from("engine.toml"))); - assert!(cli.pretty_logs); - assert_eq!(cli.log_backfill_concurrency, Some(8)); - assert!(cli.wasm.is_none()); - } -} diff --git a/nexum/crates/nexum-launch/src/lib.rs b/nexum/crates/nexum-launch/src/lib.rs deleted file mode 100644 index 6994ca29..00000000 --- a/nexum/crates/nexum-launch/src/lib.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! Generic engine launcher: parse the shared CLI, load the engine config, -//! initialise tracing, and drive a [`Runtime`] preset until shutdown. -//! -//! A binary is one line: `nexum_launch::run("nexum", CoreRuntime)`. The -//! preset supplies the lattice, backends, extension list, and add-ons; -//! this crate knows nothing beyond the runtime seam. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] - -mod cli; - -pub use cli::Cli; - -use nexum_runtime::builder::RuntimeBuilder; -use nexum_runtime::engine_config::{self, EngineConfig}; -use nexum_runtime::preset::Runtime; -use tracing::info; -use tracing_subscriber::EnvFilter; - -/// Parse the process arguments as `name`, then [`launch`] the preset. -pub async fn run(name: &'static str, preset: R) -> anyhow::Result<()> { - launch(name, preset, Cli::parse_as(name)).await -} - -/// Load the config, initialise tracing, and run the preset until shutdown. -pub async fn launch(name: &str, preset: R, cli: Cli) -> anyhow::Result<()> { - let mut engine_cfg = engine_config::load_or_default(cli.engine_config.as_deref())?; - if let Some(n) = cli.log_backfill_concurrency { - engine_cfg.engine.log_backfill_concurrency = n; - } - - init_tracing(cli.pretty_logs, &engine_cfg); - - info!("{name} starting"); - - RuntimeBuilder::new(&engine_cfg) - .with_runtime(preset) - .with_module_source(cli.wasm, cli.manifest) - .launch() - .await? - .wait() - .await -} - -/// Install the global tracing subscriber: JSON by default (machine-readable -/// for production), the human-readable formatter behind `--pretty-logs`. -/// The same [`EnvFilter`] applies to both, so `RUST_LOG` works identically. -fn init_tracing(pretty: bool, engine_cfg: &EngineConfig) { - let env_filter = EnvFilter::try_from_default_env() - .or_else(|_| EnvFilter::try_new(&engine_cfg.engine.log_level)) - .unwrap_or_else(|_| EnvFilter::new("info")); - if pretty { - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .with_target(true) - .init(); - } else { - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .with_target(true) - .json() - .flatten_event(true) - .with_current_span(false) - .init(); - } -} diff --git a/nexum/crates/nexum-module-macros/Cargo.toml b/nexum/crates/nexum-module-macros/Cargo.toml deleted file mode 100644 index 4bef46f5..00000000 --- a/nexum/crates/nexum-module-macros/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "nexum-module-macros" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Proc-macro glue for nexum runtime modules: #[module] emits the per-cdylib wit-bindgen, host adapter, event dispatch, and export." - -[lib] -proc-macro = true - -[lints] -workspace = true - -[dependencies] -nexum-world = { path = "../nexum-world" } -proc-macro2.workspace = true -quote.workspace = true -syn = { workspace = true, features = ["full"] } diff --git a/nexum/crates/nexum-module-macros/src/lib.rs b/nexum/crates/nexum-module-macros/src/lib.rs deleted file mode 100644 index bb90eb72..00000000 --- a/nexum/crates/nexum-module-macros/src/lib.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! Proc-macro glue for nexum runtime modules. -//! -//! [`module`] turns an `impl` block of named handlers into a complete -//! per-cdylib module: it emits the `wit_bindgen::generate!` call for a -//! per-module world derived from the crate's `module.toml` -//! `[capabilities]` declarations, the host adapter (via -//! `nexum_sdk::bind_host_via_wit_bindgen!`), the `Guest` implementation -//! whose `on-event` dispatches to the handlers present, and `export!`. -//! -//! The venue-side macros (`#[venue]`, `derive(IntentBody)`) live in -//! `videre-macros`. -//! -//! Consumers reach this through the SDK re-export (`nexum_sdk::module`) -//! rather than depending on this crate directly. - -use proc_macro::TokenStream; -use quote::quote; -use syn::{ImplItem, ItemImpl, Type}; - -/// The handler names recognised on a `#[module]` impl. Any method not in -/// this set is left untouched on the type, except that names starting -/// with `on_` are rejected at compile time (a typo'd handler would -/// otherwise silently never fire); any handler in the set that is absent -/// is treated as a no-op in the generated `on-event` dispatch. -const HANDLERS: [&str; 6] = [ - "init", - "on_block", - "on_chain_logs", - "on_tick", - "on_message", - "on_intent_status", -]; - -/// Generate the per-cdylib glue for a nexum module. -/// -/// Apply to an `impl` block whose associated functions are the event -/// handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, -/// `on_message`, `on_intent_status`). Each handler takes the wit-bindgen -/// payload for its event and returns `Result<(), Fault>`; `init` takes -/// the config table. -/// Handlers left undefined are ignored (their events become no-ops). The -/// macro emits `wit_bindgen::generate!`, the host adapter, the `Guest` -/// impl, and `export!` around the untouched impl. -/// -/// The world is per module, not shared: the macro reads the crate's -/// `module.toml` and synthesizes a world whose imports are exactly the -/// `[capabilities].required` and `optional` declarations, so the built -/// component imports what the manifest declares and nothing else - the -/// runtime's load-time capability check passes by construction instead -/// of relying on the toolchain eliding unused imports. Corollaries: the -/// manifest must sit at the crate root and carry a `[capabilities]` -/// section, an undeclared capability's bindings simply do not exist -/// (using one is a compile error, the cue to declare it), and only the -/// host-adapter pieces for declared capabilities are emitted. -/// -/// The other non-obvious invariant: the wit-bindgen output (`Guest`, -/// `Fault`, the `nexum::host::*` modules) lands at the module crate -/// root, so the emitted glue and the handler bodies resolve those names -/// there; the WIT package directories resolve against the crate's own -/// `wit/` and `wit/deps/`, then the nearest ancestor carrying the -/// package. Two corollaries: the consuming crate must -/// declare `wit-bindgen` as a direct dependency (the emitted -/// `wit_bindgen::generate!` call resolves against the consumer's -/// namespace), and the crate root must not shadow std prelude names -/// such as `Result`, `Vec`, or `Ok` (wit-bindgen's generated `Guest` -/// trait refers to them unqualified). -#[proc_macro_attribute] -pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { - if !attr.is_empty() { - return syn::Error::new( - proc_macro2::Span::call_site(), - "#[nexum_sdk::module] takes no arguments", - ) - .to_compile_error() - .into(); - } - - let input = syn::parse_macro_input!(item as ItemImpl); - - let self_ty = &input.self_ty; - if !is_plain_type(self_ty) { - return syn::Error::new_spanned( - self_ty, - "#[nexum_sdk::module] must be applied to an inherent impl of a named type", - ) - .to_compile_error() - .into(); - } - if let Some((_, trait_path, _)) = &input.trait_ { - return syn::Error::new_spanned( - trait_path, - "#[nexum_sdk::module] must be applied to an inherent impl, not a trait impl", - ) - .to_compile_error() - .into(); - } - if !input.generics.params.is_empty() { - return syn::Error::new_spanned( - &input.generics, - "#[nexum_sdk::module] must be applied to a non-generic impl", - ) - .to_compile_error() - .into(); - } - - // A typo'd handler (`on_blocks`, `on_chainlogs`, ...) would otherwise - // compile as an ordinary helper while its event silently no-ops, so - // reserve the `on_` prefix for the recognised handler set. - for item in &input.items { - if let ImplItem::Fn(f) = item { - let name = f.sig.ident.to_string(); - if name.starts_with("on_") && !HANDLERS.contains(&name.as_str()) { - return syn::Error::new_spanned( - &f.sig.ident, - format!( - "`{name}` is not a recognised #[nexum_sdk::module] handler; expected one \ - of {HANDLERS:?} (rename helpers so they do not start with `on_`)" - ), - ) - .to_compile_error() - .into(); - } - } - } - - let present: Vec<&str> = input - .items - .iter() - .filter_map(|item| match item { - ImplItem::Fn(f) => { - let name = f.sig.ident.to_string(); - HANDLERS.into_iter().find(|h| *h == name) - } - _ => None, - }) - .collect(); - if present.is_empty() { - return syn::Error::new_spanned( - self_ty, - "#[nexum_sdk::module] found no recognised handlers on this impl; define at least one \ - of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_intent_status`", - ) - .to_compile_error() - .into(); - } - let has = |name: &str| present.contains(&name); - - let (anchors, module_world) = match derive_module_world() { - Ok(parts) => parts, - Err(msg) => { - return syn::Error::new(proc_macro2::Span::call_site(), msg) - .to_compile_error() - .into(); - } - }; - let wit_paths = match resolve_wit_packages(&module_world.packages) { - Ok(paths) => paths, - Err(msg) => { - return syn::Error::new(proc_macro2::Span::call_site(), msg) - .to_compile_error() - .into(); - } - }; - let inline_world = &module_world.wit; - let adapter_caps: Vec = module_world - .adapters - .iter() - .map(|cap| syn::Ident::new(cap, proc_macro2::Span::call_site())) - .collect(); - - // `init` is a required export; when the handler is absent the config - // is bound but unused, so drop it to keep the module warning-clean. - let init_impl = if has("init") { - quote! { - fn init( - config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, - ) -> ::core::result::Result<(), Fault> { - <#self_ty>::init(config) - } - } - } else { - quote! { - fn init( - _config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, - ) -> ::core::result::Result<(), Fault> { - ::core::result::Result::Ok(()) - } - } - }; - - let arm = |handler: &str, variant| -> proc_macro2::TokenStream { - let variant = syn::Ident::new(variant, proc_macro2::Span::call_site()); - if has(handler) { - let call = syn::Ident::new(handler, proc_macro2::Span::call_site()); - quote! { nexum::host::types::Event::#variant(payload) => <#self_ty>::#call(payload), } - } else { - quote! { nexum::host::types::Event::#variant(_) => ::core::result::Result::Ok(()), } - } - }; - let block_arm = arm("on_block", "Block"); - let logs_arm = arm("on_chain_logs", "ChainLogs"); - let tick_arm = arm("on_tick", "Tick"); - let message_arm = arm("on_message", "Message"); - let intent_status_arm = arm("on_intent_status", "IntentStatus"); - - quote! { - // Anchor a rebuild on the manifest and the extension registry: - // the emitted world is derived from them, so an edit to either - // must recompile the module. - #(const _: &[u8] = ::core::include_bytes!(#anchors);)* - - wit_bindgen::generate!({ - inline: #inline_world, - path: [#(#wit_paths),*], - world: "nexum:module-world/module", - generate_all, - }); - - ::nexum_sdk::bind_host_via_wit_bindgen!(caps: [#(#adapter_caps),*]); - - #input - - #[doc(hidden)] - struct __NexumModuleExport; - - impl Guest for __NexumModuleExport { - #init_impl - - fn on_event(event: nexum::host::types::Event) -> ::core::result::Result<(), Fault> { - match event { - #block_arm - #logs_arm - #tick_arm - #message_arm - #intent_status_arm - } - } - } - - export!(__NexumModuleExport); - } - .into() -} - -/// Whether a type is a plain named path (`Foo`), the only shape a module -/// export type may take. -fn is_plain_type(ty: &Type) -> bool { - matches!(ty, Type::Path(tp) if tp.qself.is_none()) -} - -/// The consuming crate's manifest directory, the root every crate-local -/// lookup starts from. -fn manifest_dir() -> Result { - std::env::var("CARGO_MANIFEST_DIR") - .map(std::path::PathBuf::from) - .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string()) -} - -/// Read the consuming crate's `module.toml` and synthesize the -/// per-module world from its `[capabilities]` declarations plus the -/// extension rows registered in the nearest ancestor `extensions.toml`. -/// Returns the rebuild anchor paths (the manifest, then the registry -/// when one exists) alongside the world. -fn derive_module_world() -> Result<(Vec, nexum_world::ModuleWorld), String> { - let manifest_path = manifest_dir()?.join("module.toml"); - let text = std::fs::read_to_string(&manifest_path).map_err(|e| { - format!( - "could not read {} ({e}); #[nexum_sdk::module] derives the component's WIT world \ - from the manifest's [capabilities] section, so the manifest must sit next to \ - Cargo.toml", - manifest_path.display() - ) - })?; - let declared = nexum_world::manifest_capabilities(&text) - .map_err(|e| format!("{}: {e}", manifest_path.display()))?; - let manifest_path = manifest_path.to_string_lossy().into_owned(); - - let mut anchors = vec![manifest_path.clone()]; - let extensions = match nexum_world::find_extensions_manifest(&manifest_dir()?) { - None => Vec::new(), - Some(registry) => { - let text = std::fs::read_to_string(®istry) - .map_err(|e| format!("could not read {}: {e}", registry.display()))?; - let rows = nexum_world::manifest_extensions(&text) - .map_err(|e| format!("{}: {e}", registry.display()))?; - anchors.push(registry.to_string_lossy().into_owned()); - rows - } - }; - let module_world = nexum_world::synthesize(&declared, &extensions) - .map_err(|e| format!("{manifest_path}: {e}"))?; - Ok((anchors, module_world)) -} - -/// Resolve each needed WIT package directory crate-locally (vendored -/// `wit/deps/`, then own `wit/`), falling back through -/// ancestors for the transitional monorepo layout. -fn resolve_wit_packages(packages: &[String]) -> Result, String> { - Ok( - nexum_world::resolve_wit_packages(&manifest_dir()?, packages)? - .into_iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect(), - ) -} diff --git a/nexum/crates/nexum-runtime/Cargo.toml b/nexum/crates/nexum-runtime/Cargo.toml deleted file mode 100644 index affc1a01..00000000 --- a/nexum/crates/nexum-runtime/Cargo.toml +++ /dev/null @@ -1,114 +0,0 @@ -[package] -name = "nexum-runtime" -version = "0.2.0" -edition.workspace = true -license.workspace = true -repository.workspace = true - -[lints] -workspace = true - -[lib] -path = "src/lib.rs" - -[dependencies] -# WASM Component Model runtime. wasi:http is serviced by -# wasmtime-wasi-http's hyper + rustls backend behind the per-module -# allowlist gate in `host::http`. -wasmtime.workspace = true -wasmtime-wasi.workspace = true -wasmtime-wasi-http.workspace = true - -# Async + error plumbing. -anyhow.workspace = true -thiserror.workspace = true -async-trait.workspace = true -# `strum::IntoStaticStr` on error enums gives metric labels (`error_kind`) -# free via a snake_case `&'static str` for every variant. Used at -# `tracing::warn!(error_kind = .into(), ...)` sites and -# any `metrics::counter!(... "error_kind" => kind)` recordings, so the -# Prometheus labels stay in lock-step with the Rust enum source of -# truth instead of needing a `match err { ... => "connect" ... }` -# ladder per call site. Pinned via the workspace so every consumer -# moves in lockstep. -strum.workspace = true -tokio.workspace = true -# Task lifecycle and graceful shutdown; the sole crate that raw-spawns -# tokio tasks. Every engine task routes through its executor. -nexum-tasks = { path = "../nexum-tasks" } -# Single-source capability and fault-label vocabularies; the registry's -# core interface set is emitted from its table. -nexum-world = { path = "../nexum-world" } - -# Manifest parsing. -serde.workspace = true -toml.workspace = true -serde_json = { workspace = true, features = ["std"] } - -# Observability. `tracing` replaces the prior `eprintln!` debug log -# so the engine can drop into a structured log pipeline in production. -tracing.workspace = true -# `tracing_core::Level` is the level vocabulary carried on log records; -# the two WIT edges are the only place a generated severity enum appears. -tracing-core.workspace = true - -# Prometheus exporter. `metrics` is the facade every -# recording site (dispatch, host backends) calls; the exporter -# crate installs the recorder + binds the `/metrics` HTTP listener. -metrics.workspace = true -metrics-exporter-prometheus.workspace = true - -# Typed HTTP request/URI values for the wasi:http allowlist gate, plus -# the body trait + combinators the gate's response-body cap wraps -# incoming bodies with. -http.workspace = true -http-body.workspace = true -http-body-util.workspace = true -bytes.workspace = true - -# `chain` backend. Each configured chain owns a `DynProvider` built -# from a `WsConnect`/`Http` transport so the host's `request` / -# `request-batch` impls can hand a raw `(method, params)` pair to -# alloy's JSON-RPC layer without reimplementing the codec. -alloy-provider = { workspace = true, features = ["ws", "ipc", "pubsub", "reqwest"] } -alloy-rpc-client.workspace = true -alloy-rpc-types-eth.workspace = true -alloy-transport.workspace = true -alloy-transport-ws.workspace = true -alloy-primitives.workspace = true -# Typed EIP-155 chain ids for engine config keys, the provider/orderbook -# pools, and the chain seam. -alloy-chains.workspace = true -futures.workspace = true - -# `local-store` backend. Per-module namespacing is enforced -# host-side via a `[len:u8][module_name][raw_key]` prefix. -redb.workspace = true - -# `remote-store` backend: the Swarm network over a Bee node's HTTP API. -bee-rs.workspace = true - -# Misc. -url.workspace = true - -# Enabled by `test-utils`: the `TestRuntime` harness temp-files inline -# manifests before boot. -tempfile = { workspace = true, optional = true } - -[features] -# Test-only helpers under `test_utils`: the engine-side mock backends and the -# `MockTypes` lattice for launching an in-process runtime entirely on fakes, -# the manually-driven `ManualClock`, and the `TestRuntime` harness. Off by -# default; a downstream test crate opts in. The harness temp-files inline -# manifests, so the feature pulls `tempfile`. The self dev-dependency below -# turns it on for this crate's own tests. -test-utils = ["dep:tempfile"] - -[dev-dependencies] -# Self dev-dependency enabling `test-utils` for this crate's own test, doc, -# and example targets, so `cargo test -p nexum-runtime` sees the mocks -# without every invocation passing `--features test-utils`. -nexum-runtime = { path = ".", features = ["test-utils"] } -tempfile.workspace = true -wiremock.workspace = true -tracing-subscriber.workspace = true diff --git a/nexum/crates/nexum-runtime/examples/embed.rs b/nexum/crates/nexum-runtime/examples/embed.rs deleted file mode 100644 index 129f9890..00000000 --- a/nexum/crates/nexum-runtime/examples/embed.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Embed the runtime without the CLI: point the builder at a loaded config -//! and a [`Runtime`] preset, then launch and run until shutdown. -//! -//! [`CoreRuntime`] is the domain-free preset: it bundles the reference core -//! backends (chain provider pool, local redb store, empty extension slot) and -//! the Prometheus add-on. A domain capability is added by -//! writing a preset that names its extension builder in the `Ext` slot and -//! returns its linker extensions, or by dropping to the explicit -//! `with_components` builder path. The returned [`RuntimeHandle`] carries the -//! in-process log read side; clone it to keep reading after `wait` consumes -//! the handle. -//! -//! Build the example module first (`just build-module`), then run -//! `cargo run -p nexum-runtime --example embed` from the repo root. -//! -//! [`Runtime`]: nexum_runtime::preset::Runtime -//! [`RuntimeHandle`]: nexum_runtime::builder::RuntimeHandle - -use nexum_runtime::builder::RuntimeBuilder; -use nexum_runtime::engine_config::{EngineConfig, ModuleEntry}; -use nexum_runtime::preset::CoreRuntime; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // The embedder owns the tracing subscriber; the library never - // installs one. - tracing_subscriber::fmt().init(); - - let cfg = EngineConfig { - modules: vec![ModuleEntry { - path: "target/wasm32-wasip2/release/example.wasm".into(), - manifest: Some("nexum/modules/example/module.toml".into()), - }], - ..EngineConfig::default() - }; - - // Bind the default preset and launch: the component builders open the - // backends, the add-ons install, and the event loop runs until shutdown. - let handle = RuntimeBuilder::new(&cfg) - .runtime::() - .launch() - .await?; - - // The operator surface: the handle's log pipeline serves the run/log - // read side while (and after) the runtime runs. - let logs = handle.logs().clone(); - handle.wait().await?; - - for meta in logs.list_runs("example") { - let page = logs.read(&meta.run, 0); - println!( - "run {:?} retained {} record(s)", - meta.run, - page.records.len() - ); - } - Ok(()) -} diff --git a/nexum/crates/nexum-runtime/src/addons.rs b/nexum/crates/nexum-runtime/src/addons.rs deleted file mode 100644 index 114d2357..00000000 --- a/nexum/crates/nexum-runtime/src/addons.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! Cross-cutting runtime add-ons: process-wide facilities that attach to -//! the launch path without the core knowing their concrete type. -//! -//! An add-on installs a facility from the resolved config (a metrics -//! recorder today) and hands back a handle the launcher keeps alive for the -//! run. The composition root chooses the set, so an embedder omits or -//! replaces any of them instead of inheriting a fixed install. -//! -//! A future control-surface add-on (an admin or RPC socket) slots in beside -//! [`PrometheusAddOn`]: implement [`RuntimeAddOn`], read its own section -//! from [`AddOnsContext`], and add it to the launcher's list at the -//! composition root. - -use tracing::info; - -use crate::engine_config::MetricsSection; - -/// Inputs an add-on reads at install time. Grows as add-ons are added: a -/// future control-surface add-on carries its own resolved section here. -pub struct AddOnsContext<'a> { - /// Resolved `[engine.metrics]` config. - pub metrics: &'a MetricsSection, -} - -/// A live add-on installation, retained by the launcher for the length of -/// the run. Names the add-on for diagnostics; an add-on that needs RAII -/// teardown grows a resource slot here when one arrives. -pub struct AddOnHandle { - /// The add-on's name, for diagnostics. - pub name: &'static str, -} - -impl AddOnHandle { - /// A handle for an add-on that needs no teardown resource. - pub fn named(name: &'static str) -> Self { - Self { name } - } -} - -/// A process-wide facility attached to the launch path. `install` reads the -/// resolved config from `ctx` and returns a handle the launcher retains. -pub trait RuntimeAddOn { - /// Install the facility, returning its live handle. - fn install(&self, ctx: &AddOnsContext<'_>) -> anyhow::Result; -} - -/// An owned, ordered add-on set gathered behind one value. A preset or -/// composition root returns this so a heterogeneous set travels together; -/// the launcher borrows each element to install it. -pub type AddOns = Vec>; - -/// The Prometheus exporter add-on. With `[engine.metrics].enabled = true` -/// it binds an HTTP listener serving `/metrics`; otherwise it installs the -/// recorder alone so `metrics::counter!` call sites stay live but no port -/// opens. The same binary thus runs in CI without binding a port and in -/// production with observability by flipping one config flag. -pub struct PrometheusAddOn; - -impl RuntimeAddOn for PrometheusAddOn { - fn install(&self, ctx: &AddOnsContext<'_>) -> anyhow::Result { - if ctx.metrics.enabled { - let addr: std::net::SocketAddr = ctx.metrics.bind_addr.parse().map_err(|e| { - anyhow::anyhow!( - "invalid [engine.metrics].bind_addr `{}`: {e}", - ctx.metrics.bind_addr - ) - })?; - metrics_exporter_prometheus::PrometheusBuilder::new() - .with_http_listener(addr) - .install() - .map_err(|e| anyhow::anyhow!("install Prometheus exporter on {addr}: {e}"))?; - info!(addr = %addr, "metrics exporter listening at /metrics"); - } else { - // Recorder installed globally so metrics call sites stay live; - // no HTTP port is opened. It accumulates samples in memory, unread. - metrics_exporter_prometheus::PrometheusBuilder::new() - .install_recorder() - .map_err(|e| anyhow::anyhow!("install Prometheus recorder: {e}"))?; - } - Ok(AddOnHandle::named("prometheus")) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::engine_config::MetricsSection; - - /// An enabled exporter with an unparseable bind address surfaces the - /// wrapped error at install, before any recorder is touched. - #[test] - fn prometheus_add_on_rejects_an_invalid_bind_addr() { - let metrics = MetricsSection { - enabled: true, - bind_addr: "not-a-socket-addr".to_owned(), - }; - let ctx = AddOnsContext { metrics: &metrics }; - let err = match PrometheusAddOn.install(&ctx) { - Ok(_) => panic!("invalid bind_addr must not install"), - Err(err) => err, - }; - assert!(err.to_string().contains("bind_addr"), "{err}"); - } -} diff --git a/nexum/crates/nexum-runtime/src/bindings.rs b/nexum/crates/nexum-runtime/src/bindings.rs deleted file mode 100644 index b3debecb..00000000 --- a/nexum/crates/nexum-runtime/src/bindings.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! WIT bindings generated by `wasmtime::component::bindgen!`. -//! -//! The core host binds the `nexum:host/event-module` world: the six core -//! primitives. Outbound HTTP is not a `nexum:host` interface: it is -//! wasi:http, linked separately; clocks are ambient wasi:clocks. Domain -//! extensions bind their own world and wire themselves in at the -//! composition root; they are not part of this core surface. -//! -//! Every `Host` trait impl in `crate::host::impls` consumes types -//! generated here. -//! -//! `nexum:host` is a leaf package: the host `event` variant carries a -//! status transition as opaque bytes, so the core world resolves against -//! `wit/nexum-host` alone; the group `wit/deps.toml` and its lock stay -//! empty. An extension's bindgen remaps onto the shared -//! interfaces here with `with`, so the `Host` impls and the `fault` type -//! its components see are the very ones the core host constructs. -//! `PartialEq` is derived so extension services can compare event payloads. - -wasmtime::component::bindgen!({ - path: ["../../wit/nexum-host"], - world: "nexum:host/event-module", - imports: { default: async }, - exports: { default: async }, - additional_derives: [PartialEq], -}); diff --git a/nexum/crates/nexum-runtime/src/bootstrap.rs b/nexum/crates/nexum-runtime/src/bootstrap.rs deleted file mode 100644 index 9ae57a94..00000000 --- a/nexum/crates/nexum-runtime/src/bootstrap.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Generic launch entry point: assemble the [`AssembledRuntime`] from -//! pre-built backends and run it until shutdown. -//! -//! Parameterised over the [`RuntimeTypes`] lattice. The composition root -//! builds the concrete [`Components`] and the extension list (including any -//! domain extension) and hands them here; this thin wrapper -//! forwards to the [`builder`](crate::builder) launcher and blocks until the -//! event loop returns. A launcher that wants the -//! [`RuntimeHandle`](crate::builder::RuntimeHandle) back drives -//! [`LaunchRuntime`] directly. - -use std::path::Path; -use std::sync::Arc; - -use crate::addons::RuntimeAddOn; -use crate::builder::{AssembledRuntime, LaunchContext, LaunchRuntime}; -use crate::engine_config::EngineConfig; -use crate::host::component::{Components, RuntimeTypes}; -use nexum_tasks::TaskManager; - -use crate::host::extension::Extension; - -/// Launch the runtime from a loaded config and run until shutdown. -/// -/// `components` carries the shared backends threaded into every module -/// store; `extensions` carries the linker hooks and capability namespaces -/// assembled at the composition root. Both must agree: a module importing -/// an extension interface boots only if that extension is present in both. -/// -/// `add_ons` carries the cross-cutting facilities (the Prometheus exporter -/// today) installed before the engine boots; the composition root picks the -/// set so an embedder omits or replaces any of them. -pub async fn run( - engine_cfg: &EngineConfig, - wasm: Option<&Path>, - manifest: Option<&Path>, - components: &Components, - extensions: &[Arc>], - add_ons: &[&dyn RuntimeAddOn], -) -> anyhow::Result<()> { - let runtime = AssembledRuntime { - components: components.clone(), - extensions: extensions.to_vec(), - add_ons, - wasm, - manifest, - clocks: None, - }; - let ctx = LaunchContext { - tasks: TaskManager::new(), - config: engine_cfg, - }; - runtime.launch(ctx).await?.wait().await -} diff --git a/nexum/crates/nexum-runtime/src/builder.rs b/nexum/crates/nexum-runtime/src/builder.rs deleted file mode 100644 index 5b98e2dd..00000000 --- a/nexum/crates/nexum-runtime/src/builder.rs +++ /dev/null @@ -1,1055 +0,0 @@ -//! Type-state runtime builder and the imperative launcher it drives. -//! -//! [`RuntimeBuilder`] accumulates the assembly (config, the [`RuntimeTypes`] -//! lattice, extensions, the component builders, add-ons) through a type-state -//! chain; [`ReadyBuilder::launch`] opens the backends and hands off to -//! [`LaunchRuntime::launch`]. The launcher runs one imperative sequence - -//! install add-ons, build the engine and linker, boot the supervisor, open -//! subscriptions through the [`TaskManager`]'s executor, spawn the event -//! loop - and returns a [`RuntimeHandle`] owning the manager and the -//! running tasks. -//! -//! The engine binaries reach this through the `nexum-launch` preset run; -//! an embedder holding pre-built backends constructs an [`AssembledRuntime`] -//! and calls [`LaunchRuntime::launch`] directly. For the common case, -//! [`RuntimeBuilder::runtime`] binds a [`Runtime`] preset that bundles the -//! lattice, component builders, extensions, and add-ons in one call; -//! [`RuntimeBuilder::with_runtime`] binds a preset value carrying pre-built -//! backends. - -use std::future::{Future, IntoFuture}; -use std::marker::PhantomData; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -use nexum_tasks::{DrainOutcome, TaskExit, TaskHandle, TaskManager, TaskSet}; -use tracing::{error, info, warn}; -use wasmtime::Engine; - -use crate::addons::{AddOnHandle, AddOnsContext, RuntimeAddOn}; -use crate::engine_config::EngineConfig; -use crate::host::component::{ - BuilderContext, ComponentBuilder, Components, ComponentsBuilder, RuntimeTypes, -}; -use crate::host::extension::{EventSources, Extension}; -use crate::host::logs::LogPipeline; -use crate::preset::Runtime; -use crate::runtime::event_loop; -pub use crate::supervisor::WasiClockOverride; -use crate::supervisor::{self, Supervisor}; - -/// Ambient inputs the imperative launcher reads: the task manager every -/// runtime task spawns through, and the loaded config. -pub struct LaunchContext<'a> { - /// Owns task spawning and graceful shutdown for the run. - pub tasks: TaskManager, - /// The loaded engine config. - pub config: &'a EngineConfig, -} - -/// Upper bound on how long the top level blocks for the event loop's final -/// durable flush after shutdown is signalled before forcing exit. -const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(10); - -/// A running runtime: the event-loop task, the task manager, and add-on -/// handles. [`shutdown`](Self::shutdown) or dropping fires shutdown; -/// [`wait`](Self::wait) blocks on the bounded drain. -pub struct RuntimeHandle { - event_loop: TaskHandle, - tasks: TaskManager, - logs: LogPipeline, - // Held for the length of the run; dropped once the event loop has joined. - _add_ons: Vec, -} - -impl RuntimeHandle { - /// Signal the event loop to stop. The in-flight dispatch finishes first. - pub fn shutdown(&mut self) { - self.tasks.trigger().fire(); - } - - /// The shared log pipeline: the read side for module runs and log pages. - /// Clone it to keep reading after [`wait`](Self::wait) consumes the handle. - pub fn logs(&self) -> &LogPipeline { - &self.logs - } - - /// Block until the loop stops (on its own, on shutdown, or on a critical - /// task ending), bounding the final durable flush; a drain past the - /// timeout forces exit. A `None` join reason means the task panicked or - /// was aborted. - pub async fn wait(self) -> anyhow::Result<()> { - let RuntimeHandle { - event_loop, - mut tasks, - _add_ons, - .. - } = self; - let mut signal = tasks.subscribe(); - let join = event_loop.join(); - tokio::pin!(join); - tokio::select! { - biased; - joined = &mut join => return finish_wait(joined), - name = tasks.on_critical_failure() => { - warn!(task = %name, "critical task ended, draining"); - } - () = signal.recv() => {} - } - // Signalled: block on the bounded drain. The event-loop task holds - // the flush guard until it returns, not the abort-only reconnect - // pumps. - match tasks - .graceful_shutdown_with_timeout(SHUTDOWN_DRAIN_TIMEOUT) - .await - { - DrainOutcome::Drained => finish_wait(join.await), - DrainOutcome::TimedOut { outstanding } => { - error!( - outstanding, - timeout = ?SHUTDOWN_DRAIN_TIMEOUT, - "shutdown drain exceeded deadline, forcing exit" - ); - std::process::exit(1); - } - } - } -} - -/// Map an event-loop join outcome to the [`wait`](RuntimeHandle::wait) result. -fn finish_wait(joined: Option) -> anyhow::Result<()> { - match joined { - Some(_) => Ok(()), - None => anyhow::bail!("event loop task terminated abnormally"), - } -} - -/// A fully-assembled runtime: concrete backends, extensions, add-ons, and the -/// optional module-source override. Implements [`LaunchRuntime`]. -pub struct AssembledRuntime<'a, T: RuntimeTypes> { - /// Shared backends threaded into every module store. - pub components: Components, - /// Extensions: namespaces, capabilities, linker hooks, services, and - /// provider kinds. - pub extensions: Vec>>, - /// Cross-cutting facilities installed before the engine boots. - pub add_ons: &'a [&'a dyn RuntimeAddOn], - /// Single-module source override; `None` runs `[[modules]]`. - pub wasm: Option<&'a Path>, - /// Manifest paired with `wasm`. - pub manifest: Option<&'a Path>, - /// Per-store WASI clock override; `None` leaves the ambient host clocks. - pub clocks: Option, -} - -/// An assembled runtime launchable from a [`LaunchContext`]. -pub trait LaunchRuntime { - /// Run the imperative launch sequence and return the running handle. - fn launch(self, ctx: LaunchContext<'_>) -> impl Future>; -} - -impl LaunchRuntime for AssembledRuntime<'_, T> { - async fn launch(self, ctx: LaunchContext<'_>) -> anyhow::Result { - let AssembledRuntime { - components, - extensions, - add_ons, - wasm, - manifest, - clocks, - } = self; - let LaunchContext { - tasks, - config: engine_cfg, - } = ctx; - - // Install cross-cutting add-ons before the engine boots so any metric - // recorder is live for the whole run. The handles move into the - // returned handle and drop once the event loop joins. - let addons_ctx = AddOnsContext { - metrics: &engine_cfg.engine.metrics, - }; - let add_on_handles = add_ons - .iter() - .map(|add_on| add_on.install(&addons_ctx)) - .collect::>>()?; - - // wasmtime engine + linker - one of each, shared across modules. - let mut config = wasmtime::Config::new(); - config.wasm_component_model(true); - config.consume_fuel(true); - let engine = Engine::new(&config)?; - let linker = supervisor::build_linker::(&engine, &extensions)?; - - // Boot supervisor - a module-source override wins over - // `engine.toml.[[modules]]`. - let wasm_override = wasm.is_some(); - let supervisor = if let Some(wasm) = wasm { - if !engine_cfg.modules.is_empty() { - warn!( - "ignoring engine.toml [[modules]] because a module source override was given" - ); - } - Supervisor::boot_single( - &engine, - &linker, - wasm, - manifest, - &components, - &engine_cfg.limits, - &extensions, - clocks, - ) - .await? - } else if !engine_cfg.modules.is_empty() || !engine_cfg.adapters.is_empty() { - Supervisor::boot( - &engine, - &linker, - engine_cfg, - &components, - &extensions, - clocks, - ) - .await? - } else { - anyhow::bail!( - "no modules to run - set a module source or declare [[modules]] or \ - [[adapters]] entries in engine.toml" - ); - }; - - let alive = supervisor.alive_count(); - let block_chains = supervisor.block_chains(); - info!( - modules = supervisor.module_count(), - adapters = supervisor.adapter_count(), - alive, - chains = block_chains.len(), - "supervisor ready" - ); - if alive == 0 { - if wasm_override { - anyhow::bail!( - "all {} module(s) failed initialisation - check the logs above for \ - per-module errors and fix the wasm binary passed as an override", - supervisor.module_count(), - ); - } else { - anyhow::bail!( - "all {} module(s) failed initialisation - check the logs above for \ - per-module errors and fix or remove the failing module from engine.toml", - supervisor.module_count(), - ); - } - } - - // The OS signal listener: SIGINT/SIGTERM ends it, and its end (or - // panic) fires the shutdown signal via the critical-task path. It - // also watches the signal itself so a programmatic shutdown or a - // handle drop winds it down rather than leaking it. - let executor = tasks.executor(); - let mut listener_signal = tasks.subscribe(); - let mut fallback_signal = tasks.subscribe(); - executor.spawn_critical("os-signal-listener", async move { - tokio::select! { - res = event_loop::wait_for_shutdown_signal() => match res { - Ok(name) => info!(signal = %name, "shutdown signal received"), - Err(err) => { - warn!(error = %err, "signal handler failed - programmatic shutdown only"); - fallback_signal.recv().await; - } - }, - () = listener_signal.recv() => {} - } - }); - - // The handle keeps the log read side reachable after launch consumes - // the components. - let logs = components.logs.clone(); - let chain_log_subs = supervisor.chain_log_subscriptions(); - // Extension event sources open only for subscription kinds some - // loaded module declares; each extension gates further on its own - // service state and returns no stream when it has nothing to - // observe. - let subscribed = supervisor.extension_subscription_kinds(); - let mut reconnect_tasks = TaskSet::new(); - let mut extension_streams = Vec::new(); - { - let mut sources = EventSources::new( - engine_cfg, - supervisor.services(), - &subscribed, - &executor, - &mut reconnect_tasks, - ); - for ext in &extensions { - extension_streams.extend(ext.events(&mut sources)?); - } - } - - // No subscriptions: nothing to drive. Return a handle whose event loop - // is already complete so `wait` resolves immediately. - if block_chains.is_empty() && chain_log_subs.is_empty() && extension_streams.is_empty() { - if supervisor.dead_modules_hold_subscriptions() { - anyhow::bail!( - "every declared [[subscription]] belongs to an init-failed module - \ - the engine would idle with nothing to run; fix or remove the \ - failing module(s)" - ); - } - info!("no [[subscription]] entries - engine has nothing to run; exiting"); - let event_loop = executor.spawn(async { TaskExit::ReceiverGone }); - return Ok(RuntimeHandle { - event_loop, - tasks, - logs, - _add_ons: add_on_handles, - }); - } - - // Open per-chain block subscriptions + per-module chain-log - // subscriptions through the executor, then drive them in the event - // loop until shutdown. - let block_streams = event_loop::open_block_streams( - &components.chain, - &block_chains, - &executor, - &mut reconnect_tasks, - ); - let chain_log_streams = event_loop::open_chain_log_streams( - &components.chain, - chain_log_subs, - &executor, - &mut reconnect_tasks, - ); - // The event-loop task holds the graceful guard until `run` returns - // (after its final dispatch and cursor commit); shutdown ends the - // loop between dispatches rather than cancelling it, so the drain - // blocks on it. - let event_loop = executor.spawn_graceful(move |graceful| async move { - let mut supervisor = supervisor; // rebind as mut: the dispatch calls below take &mut self - event_loop::run( - &mut supervisor, - block_streams, - chain_log_streams, - extension_streams, - reconnect_tasks, - graceful.into_future(), - ) - .await; - info!("done"); - TaskExit::ReceiverGone - }); - - Ok(RuntimeHandle { - event_loop, - tasks, - logs, - _add_ons: add_on_handles, - }) - } -} - -/// Entry stage of the type-state runtime builder: only the config is bound. -pub struct RuntimeBuilder<'a> { - config: &'a EngineConfig, -} - -impl<'a> RuntimeBuilder<'a> { - /// Start a builder over a loaded config. - pub fn new(config: &'a EngineConfig) -> Self { - Self { config } - } - - /// Bind the [`RuntimeTypes`] lattice. - pub fn with_types(self) -> TypedBuilder<'a, T> { - TypedBuilder { - config: self.config, - extensions: Vec::new(), - wasm: None, - manifest: None, - clocks: None, - _t: PhantomData, - } - } - - /// Bind a [`Runtime`] preset by marker. Sugar over - /// [`with_runtime`](Self::with_runtime) for a `Default` preset: an - /// embedder writes `RuntimeBuilder::new(cfg).runtime::().launch()`. - pub fn runtime(self) -> PresetBuilder<'a, R> { - self.with_runtime(R::default()) - } - - /// Bind a [`Runtime`] preset by value, so a preset can carry pre-built - /// backends and extensions into the launch. - pub fn with_runtime(self, preset: R) -> PresetBuilder<'a, R> { - PresetBuilder { - config: self.config, - preset, - extensions: Vec::new(), - wasm: None, - manifest: None, - clocks: None, - } - } -} - -/// Terminal stage of the preset shortcut: the [`Runtime`] preset supplies the -/// lattice, the component builders, its extensions, and the add-on set, -/// leaving only the optional extension hooks and module source before -/// [`launch`](Self::launch). -pub struct PresetBuilder<'a, R: Runtime> { - config: &'a EngineConfig, - preset: R, - extensions: Vec>>, - wasm: Option, - manifest: Option, - clocks: Option, -} - -impl<'a, R: Runtime> PresetBuilder<'a, R> { - /// Append extensions on top of the preset's own. - pub fn with_extensions( - mut self, - extensions: impl IntoIterator>>, - ) -> Self { - self.extensions.extend(extensions); - self - } - - /// Set the single-module source override, taking precedence over engine.toml - /// `[[modules]]`. Both `None` runs the configured modules. - pub fn with_module_source(mut self, wasm: Option, manifest: Option) -> Self { - self.wasm = wasm; - self.manifest = manifest; - self - } - - /// Override the per-store WASI wall and monotonic clocks. Every module - /// store, including the ones rebuilt on restart, reads these instead of - /// the ambient host clocks. Omitting it is behaviour-neutral. - pub fn with_wasi_clocks(mut self, clocks: WasiClockOverride) -> Self { - self.clocks = Some(clocks); - self - } - - /// Open the preset's backends and launch. Builds the [`Components`] bundle - /// from the preset's component builders, gathers the preset's extensions - /// (appended ones after), installs the preset's add-ons, then drives - /// [`LaunchRuntime::launch`] with a fresh [`TaskManager`]. - pub async fn launch(self) -> anyhow::Result { - let tasks = TaskManager::new(); - let executor = tasks.executor(); - let data_dir = self.config.engine.state_dir.clone(); - let build_ctx = BuilderContext { - config: self.config, - data_dir: &data_dir, - executor: &executor, - }; - let mut extensions = self.preset.extensions(self.config); - extensions.extend(self.extensions); - // `add_ons` owns the boxed add-ons; `add_on_refs` borrows into it and is - // consumed by the launch call, so both must stay in scope for that call. - let add_ons = self.preset.add_ons(); - let add_on_refs: Vec<&dyn RuntimeAddOn> = add_ons.iter().map(|a| &**a).collect(); - let components = self - .preset - .components() - .build::(&build_ctx) - .await?; - - let runtime = AssembledRuntime { - components, - extensions, - add_ons: &add_on_refs, - wasm: self.wasm.as_deref(), - manifest: self.manifest.as_deref(), - clocks: self.clocks, - }; - let ctx = LaunchContext { - tasks, - config: self.config, - }; - runtime.launch(ctx).await - } -} - -/// The lattice is bound; extensions and an optional module-source override -/// may be added before the component builders. -pub struct TypedBuilder<'a, T: RuntimeTypes> { - config: &'a EngineConfig, - extensions: Vec>>, - wasm: Option, - manifest: Option, - clocks: Option, - _t: PhantomData T>, -} - -impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { - /// Add the extensions. - pub fn with_extensions( - mut self, - extensions: impl IntoIterator>>, - ) -> Self { - self.extensions.extend(extensions); - self - } - - /// Set the single-module source override, taking precedence over engine.toml - /// `[[modules]]`. Both `None` runs the configured modules. - pub fn with_module_source(mut self, wasm: Option, manifest: Option) -> Self { - self.wasm = wasm; - self.manifest = manifest; - self - } - - /// Override the per-store WASI wall and monotonic clocks. Every module - /// store, including the ones rebuilt on restart, reads these instead of - /// the ambient host clocks. Omitting it is behaviour-neutral. - pub fn with_wasi_clocks(mut self, clocks: WasiClockOverride) -> Self { - self.clocks = Some(clocks); - self - } - - /// Bind the component builders that open the backends at launch. - pub fn with_components( - self, - components: ComponentsBuilder, - ) -> ComponentsStage<'a, T, C, S, E, L> { - ComponentsStage { - config: self.config, - extensions: self.extensions, - wasm: self.wasm, - manifest: self.manifest, - clocks: self.clocks, - components, - _t: PhantomData, - } - } -} - -/// The component builders are bound; the add-on set remains. -pub struct ComponentsStage<'a, T: RuntimeTypes, C, S, E, L> { - config: &'a EngineConfig, - extensions: Vec>>, - wasm: Option, - manifest: Option, - clocks: Option, - components: ComponentsBuilder, - _t: PhantomData T>, -} - -impl<'a, T: RuntimeTypes, C, S, E, L> ComponentsStage<'a, T, C, S, E, L> { - /// Bind the cross-cutting add-on set installed before the engine boots. - pub fn with_add_ons( - self, - add_ons: &'a [&'a dyn RuntimeAddOn], - ) -> ReadyBuilder<'a, T, C, S, E, L> { - ReadyBuilder { - config: self.config, - extensions: self.extensions, - wasm: self.wasm, - manifest: self.manifest, - clocks: self.clocks, - components: self.components, - add_ons, - } - } -} - -/// The assembly is complete; [`launch`](Self::launch) opens the backends and -/// runs. -pub struct ReadyBuilder<'a, T: RuntimeTypes, C, S, E, L> { - config: &'a EngineConfig, - extensions: Vec>>, - wasm: Option, - manifest: Option, - clocks: Option, - components: ComponentsBuilder, - add_ons: &'a [&'a dyn RuntimeAddOn], -} - -impl ReadyBuilder<'_, T, C, S, E, L> -where - T: RuntimeTypes, - C: ComponentBuilder, - S: ComponentBuilder, - E: ComponentBuilder, - L: ComponentBuilder, -{ - /// Open the backends and launch. Builds the [`Components`] bundle from the - /// bound builders, then drives [`LaunchRuntime::launch`] with a fresh - /// [`TaskManager`]. - pub async fn launch(self) -> anyhow::Result { - let tasks = TaskManager::new(); - let executor = tasks.executor(); - let data_dir = self.config.engine.state_dir.clone(); - let build_ctx = BuilderContext { - config: self.config, - data_dir: &data_dir, - executor: &executor, - }; - let components = self.components.build::(&build_ctx).await?; - - let runtime = AssembledRuntime { - components, - extensions: self.extensions, - add_ons: self.add_ons, - wasm: self.wasm.as_deref(), - manifest: self.manifest.as_deref(), - clocks: self.clocks, - }; - let ctx = LaunchContext { - tasks, - config: self.config, - }; - runtime.launch(ctx).await - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - use super::*; - use crate::addons::AddOns; - use crate::engine_config::EngineConfig; - use crate::host::component::{LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder}; - use crate::host::state::HostState; - use crate::manifest::NamespaceCaps; - use crate::preset::{CoreRuntime, Runtime as RuntimePreset}; - use crate::test_utils::Prebuilt; - use wasmtime::component::Linker; - - /// The preset shortcut is exercised at runtime, not just compiled: the - /// component builders open the backends, the add-ons install, and the - /// launch reaches the supervisor boot, which bails because the default - /// config declares no modules. Locks the sugar path so a builder-chain - /// refactor cannot silently break it. - #[tokio::test] - async fn preset_launch_runs_the_build_path_then_bails_without_modules() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut config = EngineConfig::default(); - config.engine.state_dir = dir.path().join("state"); - - let err = match RuntimeBuilder::new(&config) - .runtime::() - .launch() - .await - { - Ok(_) => panic!("default config declares no modules; launch must bail"), - Err(err) => err, - }; - assert!(err.to_string().contains("no modules to run"), "{err}"); - } - - /// Counts linker hook runs, so a test observes an extension reaching the - /// launch's linker build. - struct CountingExt { - namespace: &'static str, - prefix: &'static str, - linked: Arc, - } - - impl Extension for CountingExt { - fn namespace(&self) -> &'static str { - self.namespace - } - fn capabilities(&self) -> NamespaceCaps { - NamespaceCaps { - prefix: self.prefix, - ifaces: &[], - } - } - fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { - self.linked.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - } - - /// A value-bound preset carrying its own extension. - struct ExtPreset { - linked: Arc, - } - - impl crate::sealed::SealedRuntime for ExtPreset {} - - impl RuntimePreset for ExtPreset { - type Types = CoreRuntime; - type ChainBuilder = ProviderPoolBuilder; - type StoreBuilder = LocalStoreBuilder; - type ExtBuilder = (); - type LogsBuilder = LogPipelineBuilder; - - fn components(self) -> ComponentsBuilder { - ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) - } - - fn add_ons(&self) -> AddOns { - Vec::new() - } - - fn extensions(&self, _config: &EngineConfig) -> Vec>> { - vec![Arc::new(CountingExt { - namespace: "alpha", - prefix: "alpha:ext/", - linked: self.linked.clone(), - })] - } - } - - /// The preset's own extensions and the appended ones both reach the - /// launch's linker build, each linked exactly once, before the boot - /// bails on the empty module set. - #[tokio::test] - async fn preset_extensions_and_appended_extensions_both_link() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut config = EngineConfig::default(); - config.engine.state_dir = dir.path().join("state"); - - let preset_linked = Arc::new(AtomicUsize::new(0)); - let appended_linked = Arc::new(AtomicUsize::new(0)); - let appended: Arc> = Arc::new(CountingExt { - namespace: "beta", - prefix: "beta:ext/", - linked: appended_linked.clone(), - }); - - let err = match RuntimeBuilder::new(&config) - .with_runtime(ExtPreset { - linked: preset_linked.clone(), - }) - .with_extensions([appended]) - .launch() - .await - { - Ok(_) => panic!("default config declares no modules; launch must bail"), - Err(err) => err, - }; - assert!(err.to_string().contains("no modules to run"), "{err}"); - assert_eq!(preset_linked.load(Ordering::SeqCst), 1, "preset extension"); - assert_eq!( - appended_linked.load(Ordering::SeqCst), - 1, - "appended extension" - ); - } - - /// A value-bound preset handing back an already-built backend. - struct PrebuiltLogsPreset { - logs: LogPipeline, - } - - impl crate::sealed::SealedRuntime for PrebuiltLogsPreset {} - - impl RuntimePreset for PrebuiltLogsPreset { - type Types = CoreRuntime; - type ChainBuilder = ProviderPoolBuilder; - type StoreBuilder = LocalStoreBuilder; - type ExtBuilder = (); - type LogsBuilder = Prebuilt; - - fn components( - self, - ) -> ComponentsBuilder> - { - ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) - .with_logs(Prebuilt(self.logs)) - } - - fn add_ons(&self) -> AddOns { - Vec::new() - } - } - - /// `components(self)` hands a pre-built instance through the preset seam: - /// the built bundle carries the exact pipeline the preset owned. - #[tokio::test] - async fn preset_hands_over_a_prebuilt_backend() { - let dir = tempfile::tempdir().expect("tempdir"); - let config = EngineConfig::default(); - let tasks = TaskManager::new(); - let executor = tasks.executor(); - let build_ctx = BuilderContext { - config: &config, - data_dir: dir.path(), - executor: &executor, - }; - - let custom = LogPipeline::in_memory(config.limits.logs()); - let components = PrebuiltLogsPreset { - logs: custom.clone(), - } - .components() - .build::(&build_ctx) - .await - .expect("build from the preset's builders"); - - assert!( - Arc::ptr_eq(&components.logs.router(), &custom.router()), - "bundle carries the preset's pre-built pipeline", - ); - } - - /// when every configured module fails `init`, launch must - /// abort with an operator-facing error instead of idling behind an - /// empty event loop. - #[tokio::test] - async fn launch_bails_when_all_modules_fail_init() { - let wasm = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("crates dir") - .parent() - .expect("nexum root") - .parent() - .expect("workspace root") - .join("target/wasm32-wasip2/release/price_alert.wasm"); - if !wasm.exists() { - eprintln!( - "SKIP: {} not found - build with `cargo build -p price-alert --target wasm32-wasip2 --release`", - wasm.display() - ); - return; - } - - let dir = tempfile::tempdir().expect("tempdir"); - // Unparseable threshold: the module loads, then `init` fails. - let manifest = dir.path().join("module.toml"); - std::fs::write( - &manifest, - r#" -[module] -name = "price-alert" - -[capabilities] -required = ["logging", "chain"] - -[[subscription]] -kind = "block" -chain_id = 11155111 - -[config] -oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" -decimals = "8" -threshold = "not-a-number" -direction = "below" -every_n_blocks = "1" -"#, - ) - .expect("write manifest"); - - let mut config = EngineConfig::default(); - config.engine.state_dir = dir.path().join("state"); - - let err = match RuntimeBuilder::new(&config) - .with_types::() - .with_module_source(Some(wasm), Some(manifest)) - .with_components(ComponentsBuilder::new( - ProviderPoolBuilder, - LocalStoreBuilder, - (), - )) - .with_add_ons(&[]) - .launch() - .await - { - Ok(_) => panic!("init-failing module must abort launch"), - Err(err) => err, - }; - assert!(err.to_string().contains("failed initialisation"), "{err}"); - } - - /// The add-on set installs before the supervisor boots: a stub add-on's - /// `install` runs exactly once even though the launch bails on the - /// no-modules boot that follows. - #[tokio::test] - async fn assembled_runtime_installs_add_ons_before_boot() { - struct CountingAddOn(Arc); - impl RuntimeAddOn for CountingAddOn { - fn install(&self, _ctx: &AddOnsContext<'_>) -> anyhow::Result { - self.0.fetch_add(1, Ordering::SeqCst); - Ok(AddOnHandle::named("counting")) - } - } - - let dir = tempfile::tempdir().expect("tempdir"); - let data_dir = dir.path().join("state"); - let mut config = EngineConfig::default(); - config.engine.state_dir = data_dir.clone(); - - let tasks = TaskManager::new(); - let executor = tasks.executor(); - let build_ctx = BuilderContext { - config: &config, - data_dir: &data_dir, - executor: &executor, - }; - let components = ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) - .build::(&build_ctx) - .await - .expect("build core components"); - - let calls = Arc::new(AtomicUsize::new(0)); - let add_on = CountingAddOn(calls.clone()); - let add_on_refs: Vec<&dyn RuntimeAddOn> = vec![&add_on]; - let runtime = AssembledRuntime { - components, - extensions: Vec::new(), - add_ons: &add_on_refs, - wasm: None, - manifest: None, - clocks: None, - }; - let ctx = LaunchContext { - tasks, - config: &config, - }; - - let err = match runtime.launch(ctx).await { - Ok(_) => panic!("no modules configured; launch must bail"), - Err(err) => err, - }; - assert!(err.to_string().contains("no modules to run"), "{err}"); - assert_eq!( - calls.load(Ordering::SeqCst), - 1, - "add-on installed once, before the boot that bails", - ); - } - - /// Full builder-path launch against the pre-built example module: the - /// handle exposes the shared log pipeline and the trigger-to-wait - /// handshake stops the run. Skips when the module fixture is not built - /// (`just build-module`). - #[tokio::test] - async fn e2e_builder_launch_exposes_logs_and_stops_on_shutdown() { - let wasm = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("crates dir") - .parent() - .expect("nexum root") - .parent() - .expect("workspace root") - .join("target/wasm32-wasip2/release/example.wasm"); - if !wasm.exists() { - eprintln!( - "SKIP: {} not found - run `just build-module` to enable E2E tests", - wasm.display() - ); - return; - } - let manifest = wasm - .ancestors() - .nth(3) - .expect("repo root") - .join("modules/example/module.toml"); - - let dir = tempfile::tempdir().expect("tempdir"); - let mut config = EngineConfig::default(); - config.engine.state_dir = dir.path().join("state"); - - let mut handle = RuntimeBuilder::new(&config) - .with_types::() - .with_module_source(Some(wasm), Some(manifest)) - .with_components(ComponentsBuilder::new( - ProviderPoolBuilder, - LocalStoreBuilder, - (), - )) - .with_add_ons(&[]) - .launch() - .await - .expect("launch the example module"); - - // The handle carries the run/log read side of the launched pipeline. - let logs = handle.logs().clone(); - let _ = logs.list_runs("example"); - - handle.shutdown(); - handle.wait().await.expect("clean shutdown"); - } - - fn handle_over(tasks: TaskManager, event_loop: TaskHandle) -> RuntimeHandle { - RuntimeHandle { - event_loop, - tasks, - logs: test_logs(), - _add_ons: Vec::new(), - } - } - - fn test_logs() -> LogPipeline { - LogPipeline::in_memory(EngineConfig::default().limits.logs()) - } - - /// A cleanly completing event loop resolves `wait` to `Ok`. - #[tokio::test] - async fn runtime_handle_wait_is_ok_on_clean_completion() { - let tasks = TaskManager::new(); - let event_loop = tasks.executor().spawn(async { TaskExit::ReceiverGone }); - handle_over(tasks, event_loop) - .wait() - .await - .expect("clean completion resolves Ok"); - } - - /// Firing the shutdown trigger drives the event-loop task to completion - /// and `wait` returns once the graceful guard releases. - #[tokio::test] - async fn runtime_handle_shutdown_trigger_drives_wait_to_return() { - let tasks = TaskManager::new(); - let event_loop = tasks.executor().spawn_graceful(|graceful| async move { - drop(graceful.await); - TaskExit::ReceiverGone - }); - let mut handle = handle_over(tasks, event_loop); - handle.shutdown(); - handle.wait().await.expect("wait returns after the trigger"); - } - - /// An event-loop task that stops abnormally (here: aborted, the same - /// join outcome a panic produces) surfaces the wrapped error from - /// `wait` instead of masking it as a clean stop. - #[tokio::test] - async fn runtime_handle_wait_is_err_on_abnormal_stop() { - let tasks = TaskManager::new(); - let event_loop = tasks.executor().spawn(async { - std::future::pending::<()>().await; - TaskExit::ReceiverGone - }); - event_loop.abort(); - let err = handle_over(tasks, event_loop) - .wait() - .await - .expect_err("aborted task surfaces an error"); - assert!(err.to_string().contains("terminated abnormally"), "{err}"); - } - - /// dropping the handle without `wait` fires the shutdown signal, - /// so the detached event loop winds down and drains rather than leaking. - #[tokio::test] - async fn dropping_handle_without_wait_drains_the_event_loop() { - let tasks = TaskManager::new(); - let drained = Arc::new(AtomicUsize::new(0)); - let seen = drained.clone(); - let event_loop = tasks.executor().spawn_graceful(move |graceful| async move { - let guard = graceful.await; - seen.fetch_add(1, Ordering::SeqCst); - drop(guard); - TaskExit::ReceiverGone - }); - let handle = handle_over(tasks, event_loop); - - drop(handle); - - for _ in 0..200 { - if drained.load(Ordering::SeqCst) == 1 { - return; - } - tokio::time::sleep(Duration::from_millis(5)).await; - } - panic!("event loop did not drain after the handle was dropped"); - } -} diff --git a/nexum/crates/nexum-runtime/src/engine_config.rs b/nexum/crates/nexum-runtime/src/engine_config.rs deleted file mode 100644 index a9ac368f..00000000 --- a/nexum/crates/nexum-runtime/src/engine_config.rs +++ /dev/null @@ -1,1495 +0,0 @@ -//! Engine-side runtime configuration. -//! -//! Distinct from `module.toml` (module manifest): this file describes -//! the *engine*'s I/O wiring - chain RPC endpoints and the on-disk -//! location of the `local-store` database. Both are required for the -//! 0.2 reference engine to do anything other than print stubs. -//! -//! Lookup order: -//! -//! 1. `--engine-config ` CLI flag (future), or third positional -//! argument today; -//! 2. `engine.toml` in the current working directory; -//! 3. defaults - no chains configured, `state_dir = ./data`. -//! -//! A missing config is OK for the example module (it only logs); for -//! the chain-backed capabilities it surfaces as a `fault.unsupported` -//! so guests learn early. - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use alloy_chains::Chain; -use serde::Deserialize; -use strum::IntoStaticStr; -use thiserror::Error; -use tracing::{info, warn}; - -use crate::runtime::dispatch_rate::{ - DEFAULT_DISPATCH_BURST, DEFAULT_DISPATCH_REFILL_PER_SEC, DispatchRatePolicy, -}; -use crate::runtime::poison_policy::{POISON_MAX_FAILURES, POISON_WINDOW, PoisonPolicy}; - -/// Default per-caller submission budget within [`DEFAULT_QUOTA_WINDOW`]. -pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; -/// Default sliding window the per-caller submission budget is counted over. -pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); -/// Default cap on receipts under status watch at once. -pub const DEFAULT_WATCH_MAX_ENTRIES: usize = 1024; -/// Default lifetime of one status watch before it is evicted unreported. -pub const DEFAULT_WATCH_EXPIRY: Duration = Duration::from_secs(86_400); - -/// Per-caller submission quota toward installed providers. Both a -/// forwarded submission and a charged decode failure consume one unit; -/// the window slides so a caller's budget refills as old charges age out. -/// Resolved from `[limits.quota]`; the extension service that meters -/// callers consumes it. -#[derive(Debug, Clone, Copy)] -pub struct SubmitQuota { - /// Maximum charges a single caller may accrue within `window`. - pub max_charges: u32, - /// Sliding window the charges are counted across. - pub window: Duration, -} - -impl SubmitQuota { - /// Pair a budget with the window it is counted over. - pub const fn new(max_charges: u32, window: Duration) -> Self { - Self { - max_charges, - window, - } - } -} - -impl Default for SubmitQuota { - fn default() -> Self { - Self::new(DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW) - } -} - -/// Bounds on a provider status-watch set. The cap bounds the per-cadence -/// poll fan-out; the expiry evicts a watch whose provider has gone silent -/// for a whole window. Resolved from `[limits.watch]`. -#[derive(Debug, Clone, Copy)] -pub struct WatchLimit { - /// Maximum receipts under status watch at once. - pub max_entries: usize, - /// How long a watch survives without a successful poll before it is - /// evicted unreported. - pub expiry: Duration, -} - -impl WatchLimit { - /// Pair a cap with the per-entry expiry. - pub const fn new(max_entries: usize, expiry: Duration) -> Self { - Self { - max_entries, - expiry, - } - } -} - -impl Default for WatchLimit { - fn default() -> Self { - Self::new(DEFAULT_WATCH_MAX_ENTRIES, DEFAULT_WATCH_EXPIRY) - } -} - -/// Errors surfaced by [`load_or_default`]. -/// -/// Library-side modules must not propagate `anyhow::Error`; the rust -/// idiomatic rubric reserves `anyhow` for `main.rs` and -/// `supervisor.rs` top-level dispatch. The variants carry the -/// upstream error via `#[from]` so the caller in `main.rs` (which -/// uses `anyhow`) gets a free conversion through `?`. -/// -/// `IntoStaticStr` exposes the snake_case variant name for metric -/// labels and structured-log `error_kind` fields. -#[derive(Debug, Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum EngineConfigError { - /// Failed to read the config file from disk. - #[error("read engine config: {0}")] - Io(#[from] std::io::Error), - /// Config file was unparseable as TOML. - #[error("parse engine config: {0}")] - Toml(#[from] toml::de::Error), - /// `${VAR}` env-var substitution failed (missing, malformed, or unclosed). - #[error("engine config env-var substitution failed: {0}")] - Substitute(#[from] EnvVarError), -} - -/// Engine-side configuration loaded from `engine.toml`. -#[derive(Debug, Default, Deserialize)] -pub struct EngineConfig { - #[serde(default)] - pub engine: EngineSection, - /// Per-module wasmtime resource limits. Applies uniformly to every - /// module; per-module overrides land in 0.3. - #[serde(default)] - pub limits: ModuleLimits, - /// Per-chain RPC URLs keyed by EIP-155 chain id. Numeric TOML keys - /// (`[chains.11155111]`) stay canonical; named keys - /// (`[chains.sepolia]`) also parse, since the key string is handed - /// to `Chain`'s `FromStr`. `Chain` is not `Ord`, so this is a - /// `HashMap`; call sites that need deterministic output sort by - /// `Chain::id()`. - #[serde(default)] - pub chains: HashMap, - /// Opaque `[extensions.]` tables. The engine never - /// interprets these; each extension parses its own table at the - /// composition root. - #[serde(default)] - pub extensions: HashMap, - /// `[remote_store]`: the Swarm backend behind - /// `nexum:host/remote-store`. Absent leaves the interface - /// unconfigured; every call then reports `unsupported`. - #[serde(default)] - pub remote_store: Option, - /// Modules the supervisor should boot. Each entry resolves a - /// `(component.wasm, module.toml)` pair on the local filesystem - /// for 0.2 - content-addressed resolution (Swarm / OCI / - /// `[[content.sources]]`) lands in 0.3 per - /// `docs/03-module-discovery.md`. - #[serde(default)] - pub modules: Vec, - /// Provider components the supervisor should boot alongside the - /// modules. Each entry resolves a `(component.wasm, module.toml)` pair - /// like a module, but the operator scopes its transport here rather - /// than in the provider's own manifest: the installer of a provider, - /// not its author, decides which hosts and messaging topics it may - /// reach. - #[serde(default)] - pub adapters: Vec, -} - -/// One `[[modules]]` table from `engine.toml`. -/// -/// Both fields are filesystem paths in 0.2. `manifest` defaults to -/// `module.toml` next to `path` if omitted, matching the bundle layout -/// in `docs/02-modules-events-packaging.md`. -#[derive(Debug, Deserialize)] -pub struct ModuleEntry { - /// Path to the compiled `.wasm` component. - pub path: std::path::PathBuf, - /// Path to the module's `module.toml`. Defaults to `/module.toml`. - #[serde(default)] - pub manifest: Option, -} - -/// One `[[adapters]]` table from `engine.toml`. -/// -/// `path` and `manifest` mirror [`ModuleEntry`]; `manifest` defaults to a -/// sibling `module.toml`. The two scope fields are the operator's grant of -/// the adapter's transport: `http_allow` is the outbound HTTP host -/// allowlist the adapter's wasi:http gate enforces, and `messaging_topics` -/// scopes the messaging content topics it may publish to. Both default -/// empty; an empty `http_allow` denies every outbound request, and an -/// empty `messaging_topics` leaves messaging unscoped for parity with the -/// module default (the messaging backend itself is deferred). -#[derive(Debug, Deserialize)] -pub struct AdapterEntry { - /// Path to the compiled `.wasm` adapter component. - pub path: std::path::PathBuf, - /// Path to the adapter's `module.toml`. Defaults to `/module.toml`. - #[serde(default)] - pub manifest: Option, - /// Outbound HTTP host allowlist granted to this adapter. Each entry is - /// either an exact hostname or a `*.suffix` wildcard, matched the same - /// way as a module's `[capabilities.http].allow`. - #[serde(default)] - pub http_allow: Vec, - /// Messaging content topics this adapter may reach. - #[serde(default)] - pub messaging_topics: Vec, -} - -#[derive(Debug, Deserialize)] -pub struct EngineSection { - #[serde(default = "default_state_dir")] - pub state_dir: PathBuf, - /// `tracing_subscriber::EnvFilter`-compatible directive. Defaults to - /// `info` when absent; `RUST_LOG` overrides at process start. - #[serde(default = "default_log_level")] - pub log_level: String, - /// Prometheus metrics exporter wiring. Absent table = - /// disabled (the engine still installs the recorder so call sites - /// stay live but no HTTP listener binds). - #[serde(default)] - pub metrics: MetricsSection, - /// Concurrency for the chain-log poller's per-block `eth_getLogs` - /// during backfill; higher catches up faster at more node load. - /// `0` is treated as `1` by alloy. - #[serde(default = "default_log_backfill_concurrency")] - pub log_backfill_concurrency: usize, -} - -impl Default for EngineSection { - fn default() -> Self { - Self { - state_dir: default_state_dir(), - log_level: default_log_level(), - metrics: MetricsSection::default(), - log_backfill_concurrency: default_log_backfill_concurrency(), - } - } -} - -fn default_log_backfill_concurrency() -> usize { - 16 -} - -/// `[engine.metrics]` config. When `enabled = true` the engine starts -/// a Prometheus HTTP exporter on `bind_addr` and serves `/metrics`. -/// -/// Default: disabled. Operators opt in explicitly so the M3 / M4 -/// runbook smoke runs do not bind a port unintentionally. -#[derive(Debug, Deserialize)] -pub struct MetricsSection { - #[serde(default)] - pub enabled: bool, - /// IPv4 / IPv6 socket address to bind. Default `127.0.0.1:9100`. - #[serde(default = "default_metrics_bind")] - pub bind_addr: String, -} - -impl Default for MetricsSection { - fn default() -> Self { - Self { - enabled: false, - bind_addr: default_metrics_bind(), - } - } -} - -fn default_metrics_bind() -> String { - "127.0.0.1:9100".to_owned() -} - -#[derive(Debug, Deserialize)] -pub struct ChainConfig { - /// JSON-RPC endpoint. `ws://` and `wss://` engage alloy's pubsub - /// transport (required for `eth_subscribe`); `http://` and `https://` - /// engage the HTTP transport (request/response only). - pub rpc_url: String, - /// Per-request timeout for `chain::request` JSON-RPC calls, in - /// seconds. Does not apply to `eth_subscribe` streams or the log - /// poller (both long-lived by design). Default: 30 s. `0` is - /// rejected at boot - every call would time out immediately. - #[serde(default = "default_chain_request_timeout_secs")] - pub request_timeout_secs: u64, -} - -fn default_chain_request_timeout_secs() -> u64 { - 30 -} - -/// The `[remote_store]` table: a Bee node servicing the Swarm -/// remote-store. -#[derive(Deserialize)] -pub struct RemoteStoreSection { - /// Bee HTTP API base URL. - pub api: String, - /// Postage batch id (32-byte hex) stamping uploads and feed - /// updates. Absent leaves the write paths unsupported. - #[serde(default)] - pub postage_batch: Option, - /// Feed-signing private key (32-byte hex); `${VAR}` substitution - /// applies before parse. Absent leaves `write-feed` unsupported. - #[serde(default)] - pub feed_key: Option, -} - -// Manual impl: the feed key must never reach logs. -impl std::fmt::Debug for RemoteStoreSection { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("RemoteStoreSection") - .field("api", &self.api) - .field("postage_batch", &self.postage_batch) - .field("feed_key", &self.feed_key.as_ref().map(|_| "")) - .finish() - } -} - -/// Default fuel budget per `on_event` invocation (~1 billion WASM -/// instructions). -const DEFAULT_FUEL_PER_EVENT: u64 = 1_000_000_000; - -/// Default per-dispatch wall-clock deadline: the coarse backstop for a -/// dispatch parked in an unmetered host call. -const DEFAULT_EVENT_DEADLINE: Duration = Duration::from_secs(120); - -/// Floor for the resolved dispatch deadline. -const MIN_EVENT_DEADLINE: Duration = Duration::from_secs(1); - -/// Default linear-memory cap per module store (64 MiB). -const DEFAULT_MEMORY_LIMIT: usize = 64 * 1024 * 1024; - -/// Default per-module local-store byte quota (50 MiB). -const DEFAULT_STATE_BYTES: u64 = 50 * 1024 * 1024; - -/// Default ceiling on the guest-settable connect timeout. A TCP + TLS -/// connect that has not completed in 10 s is dead; anything longer just -/// parks a host task. -const DEFAULT_HTTP_CONNECT_TIMEOUT_MAX: Duration = Duration::from_secs(10); - -/// Default ceiling on the guest-settable first-byte timeout. Generous -/// enough for slow API endpoints without letting one request hold a -/// connection for minutes. -const DEFAULT_HTTP_FIRST_BYTE_TIMEOUT_MAX: Duration = Duration::from_secs(30); - -/// Default ceiling on the guest-settable between-bytes timeout. -const DEFAULT_HTTP_BETWEEN_BYTES_TIMEOUT_MAX: Duration = Duration::from_secs(30); - -/// Default total deadline on one outgoing exchange, connect through -/// body streaming. Event-driven modules should never hold a request -/// across minutes; the per-phase timeouts above cannot bound a server -/// that trickles bytes forever, this does. -const DEFAULT_HTTP_TOTAL_DEADLINE: Duration = Duration::from_secs(60); - -/// Default cap on one incoming response body (16 MiB): a quarter of the -/// default module memory, so a single response cannot dominate the -/// guest heap that has to buffer it. -const DEFAULT_HTTP_RESPONSE_BODY_MAX: u64 = 16 * 1024 * 1024; - -/// Default cap on one chain JSON-RPC response body (1 MiB). Large enough -/// for typical read responses (receipts, log batches, contract state), -/// while preventing a misbehaving or adversarial node from filling the -/// guest heap via a single large reply. -const DEFAULT_CHAIN_RESPONSE_MAX_BYTES: usize = 1024 * 1024; - -/// Ceiling for the `[limits.http]` millisecond knobs (24 h). -const HTTP_LIMIT_MS_MAX: u64 = 86_400_000; - -/// Default per-run log ring budget (256 KiB). Large enough to hold a -/// substantial tail of a run's output for post-mortem, small enough that -/// memory stays bounded at roughly `bytes_per_run * runs_retained * -/// modules`. Each record is charged its message bytes plus a fixed -/// per-record overhead, so a flood of empty lines cannot outgrow the -/// budget. The per-run ceiling is really `max(bytes_per_run, -/// MAX_LINE_BYTES)`: the ring never evicts its sole record, and the stdio -/// writer force-flushes an unterminated line at 1 MiB, so a newline-less -/// flood transiently holds one record up to that size (evicted as soon as -/// a newer record arrives). -const DEFAULT_LOG_BYTES_PER_RUN: usize = 256 * 1024; - -/// Default number of past runs retained per module (16). A crash-looping -/// module restarts repeatedly; keeping the last several runs gives -/// history for diagnosis without unbounded growth. -const DEFAULT_LOG_RUNS_RETAINED: usize = 16; - -/// Default cadence for provider status polling (5 s). Fast enough that a -/// settling submission is observed within a block time or two, slow -/// enough that per-receipt provider calls stay negligible. -const DEFAULT_STATUS_POLL_INTERVAL: Duration = Duration::from_secs(5); - -/// Saturate an operator-supplied millisecond knob into [1 ms, 24 h]: -/// zero would fail every request instantly, and huge values overflow -/// timer arithmetic. -fn clamp_http_ms(ms: u64) -> Duration { - Duration::from_millis(ms.clamp(1, HTTP_LIMIT_MS_MAX)) -} - -/// Per-module wasmtime resource limits. Every field is optional; -/// omitted values resolve to built-in defaults. -/// -/// ```toml -/// [limits] -/// fuel_per_event = 1_000_000_000 -/// event_deadline_secs = 120 -/// memory_bytes = 67_108_864 -/// state_bytes = 52_428_800 -/// -/// [limits.http] -/// connect_timeout_max_ms = 10_000 -/// first_byte_timeout_max_ms = 30_000 -/// between_bytes_timeout_max_ms = 30_000 -/// total_deadline_ms = 60_000 -/// response_body_max_bytes = 16_777_216 -/// -/// [limits.chain] -/// response_body_max_bytes = 1_048_576 -/// -/// [limits.logs] -/// bytes_per_run = 262_144 -/// runs_retained = 16 -/// -/// [limits.poison] -/// max_failures = 5 -/// window_secs = 600 -/// -/// [limits.dispatch] -/// burst = 256 -/// refill_per_sec = 128 -/// ``` -#[derive(Debug, Default, Deserialize)] -pub struct ModuleLimits { - /// Fuel budget granted per `on_event` invocation. - pub fuel_per_event: Option, - /// Wall-clock deadline (s) for a dispatch, covering host-call time fuel cannot meter. - pub event_deadline_secs: Option, - /// Linear-memory cap in bytes per module store. - pub memory_bytes: Option, - /// Local-store on-disk byte quota (prefix + key + value + per-entry - /// overhead) per module. - pub state_bytes: Option, - /// Outbound wasi:http limits. - #[serde(default)] - pub http: HttpLimitsSection, - /// Chain JSON-RPC response size limits. - #[serde(default)] - pub chain: ChainLimitsSection, - /// Per-run log retention limits. - #[serde(default)] - pub logs: LogLimitsSection, - /// Poison-pill quarantine thresholds. - #[serde(default)] - pub poison: PoisonLimitsSection, - /// Per-caller provider submission quota. - #[serde(default)] - pub quota: QuotaLimitsSection, - /// Provider status polling cadence. - #[serde(default)] - pub status_poll: StatusPollSection, - /// Status-watch set bounds. - #[serde(default)] - pub watch: WatchLimitsSection, - /// Per-module dispatch rate-limit thresholds. - #[serde(default)] - pub dispatch: DispatchLimitsSection, -} - -impl ModuleLimits { - /// Resolved fuel budget (override or default). - pub fn fuel(&self) -> u64 { - self.fuel_per_event.unwrap_or(DEFAULT_FUEL_PER_EVENT) - } - - /// Resolved memory cap (override or default). - pub fn memory(&self) -> usize { - self.memory_bytes.unwrap_or(DEFAULT_MEMORY_LIMIT) - } - - /// Resolved chain response size cap (override or default). A - /// degenerate `0` saturates to 1 byte, matching the `logs` / - /// `poison` sections' zero handling, so resolution never yields a - /// cap that rejects even an empty body. - pub fn chain_response_max_bytes(&self) -> usize { - self.chain - .response_body_max_bytes - .map(|b| (b.max(1)) as usize) - .unwrap_or(DEFAULT_CHAIN_RESPONSE_MAX_BYTES) - } - - /// Resolved local-store byte quota (override or default). - pub fn state_bytes(&self) -> u64 { - self.state_bytes.unwrap_or(DEFAULT_STATE_BYTES) - } - - /// Resolved per-dispatch wall-clock deadline; an override saturates - /// up to a 1 s floor. - pub fn event_deadline(&self) -> Duration { - self.event_deadline_secs - .map(|secs| Duration::from_secs(secs).max(MIN_EVENT_DEADLINE)) - .unwrap_or(DEFAULT_EVENT_DEADLINE) - } - - /// Resolved outbound HTTP limits (overrides or defaults). - pub fn http(&self) -> OutboundHttpLimits { - OutboundHttpLimits { - connect_timeout_max: self - .http - .connect_timeout_max_ms - .map(clamp_http_ms) - .unwrap_or(DEFAULT_HTTP_CONNECT_TIMEOUT_MAX), - first_byte_timeout_max: self - .http - .first_byte_timeout_max_ms - .map(clamp_http_ms) - .unwrap_or(DEFAULT_HTTP_FIRST_BYTE_TIMEOUT_MAX), - between_bytes_timeout_max: self - .http - .between_bytes_timeout_max_ms - .map(clamp_http_ms) - .unwrap_or(DEFAULT_HTTP_BETWEEN_BYTES_TIMEOUT_MAX), - total_deadline: self - .http - .total_deadline_ms - .map(clamp_http_ms) - .unwrap_or(DEFAULT_HTTP_TOTAL_DEADLINE), - response_body_max_bytes: self - .http - .response_body_max_bytes - .unwrap_or(DEFAULT_HTTP_RESPONSE_BODY_MAX), - } - } - - /// Resolved log retention limits (overrides or defaults). Degenerate - /// zeroes saturate up to 1 so at least the newest record and run stay - /// retained; resolution never fails. - pub fn logs(&self) -> LogRetentionLimits { - LogRetentionLimits { - bytes_per_run: self - .logs - .bytes_per_run - .map(|b| b.max(1)) - .unwrap_or(DEFAULT_LOG_BYTES_PER_RUN), - runs_retained: self - .logs - .runs_retained - .map(|r| r.max(1)) - .unwrap_or(DEFAULT_LOG_RUNS_RETAINED), - } - } - - /// Resolved poison-pill thresholds (overrides or production - /// defaults). Degenerate zeroes saturate up to 1: a zero - /// `max_failures` would quarantine on the first trap, and a zero - /// `window` would prune every recorded failure before the check. - pub fn poison(&self) -> PoisonPolicy { - PoisonPolicy::new( - self.poison - .max_failures - .map(|n| n.max(1)) - .unwrap_or(POISON_MAX_FAILURES), - self.poison - .window_secs - .map(|s| Duration::from_secs(s.max(1))) - .unwrap_or(POISON_WINDOW), - ) - } - - /// Resolved dispatch rate policy; a zero `burst` or `refill_per_sec` - /// saturates up to 1. - pub fn dispatch_rate(&self) -> DispatchRatePolicy { - DispatchRatePolicy::new( - self.dispatch - .burst - .map(|b| b.max(1)) - .unwrap_or(DEFAULT_DISPATCH_BURST), - self.dispatch - .refill_per_sec - .map(|r| r.max(1)) - .unwrap_or(DEFAULT_DISPATCH_REFILL_PER_SEC), - ) - } - - /// Resolved status-poll cadence (override or default). A zero interval - /// saturates up to 1 ms so a misconfigured cadence busy-loops a poll - /// task instead of dividing by zero timer arithmetic. - pub fn status_poll_interval(&self) -> Duration { - self.status_poll - .interval_ms - .map(|ms| Duration::from_millis(ms.max(1))) - .unwrap_or(DEFAULT_STATUS_POLL_INTERVAL) - } - - /// Resolved per-caller submission quota (overrides or defaults). A zero - /// `max_charges` is saturated up to 1 by the consuming service, so a - /// misconfigured budget still admits one submission rather than - /// bricking every provider. - pub fn quota(&self) -> SubmitQuota { - SubmitQuota::new( - self.quota.max_charges.unwrap_or(DEFAULT_QUOTA_MAX_CHARGES), - self.quota - .window_secs - .map(|s| Duration::from_secs(s.max(1))) - .unwrap_or(DEFAULT_QUOTA_WINDOW), - ) - } - - /// Resolved status-watch bounds (overrides or defaults). A zero - /// `max_entries` saturates up to 1 and a zero `expiry_secs` up to 1 s, - /// so a misconfigured bound still watches one receipt briefly rather - /// than nothing at all. - pub fn watch(&self) -> WatchLimit { - WatchLimit::new( - self.watch - .max_entries - .map(|n| n.max(1)) - .unwrap_or(DEFAULT_WATCH_MAX_ENTRIES), - self.watch - .expiry_secs - .map(|s| Duration::from_secs(s.max(1))) - .unwrap_or(DEFAULT_WATCH_EXPIRY), - ) - } -} - -/// `[limits.http]` outbound wasi:http limits. Every field is optional; -/// omitted values resolve to built-in defaults, and millisecond values -/// saturate into [1 ms, 24 h]; degenerate values are clamped at resolve time. -/// -/// The three `*_timeout_max_ms` fields are ceilings on the matching -/// guest-settable `request-options` timeouts, not the timeouts -/// themselves: a guest value above the ceiling is clamped down, and an -/// unset guest value inherits the ceiling. -#[derive(Debug, Default, Deserialize)] -pub struct HttpLimitsSection { - /// Ceiling on the guest-settable connect timeout, in milliseconds. - pub connect_timeout_max_ms: Option, - /// Ceiling on the guest-settable first-byte timeout, in milliseconds. - pub first_byte_timeout_max_ms: Option, - /// Ceiling on the guest-settable between-bytes timeout, in milliseconds. - pub between_bytes_timeout_max_ms: Option, - /// Total deadline on one outgoing exchange (connect through body - /// streaming), in milliseconds. - pub total_deadline_ms: Option, - /// Cap on one incoming response body, in bytes. - pub response_body_max_bytes: Option, -} - -/// `[limits.chain]` chain JSON-RPC response size limit. Optional; -/// omitted values resolve to the built-in 1 MiB default. -/// -/// ```toml -/// [limits.chain] -/// response_body_max_bytes = 1_048_576 -/// ``` -#[derive(Debug, Default, Deserialize)] -pub struct ChainLimitsSection { - /// Cap on one chain JSON-RPC response body, in bytes. Named for - /// symmetry with `[limits.http].response_body_max_bytes`. - pub response_body_max_bytes: Option, -} - -/// Resolved outbound HTTP limits the wasi:http gate enforces per -/// request. Built by [`ModuleLimits::http`]. -#[derive(Debug, Clone, Copy)] -pub struct OutboundHttpLimits { - /// Ceiling on the guest-settable connect timeout. - pub connect_timeout_max: Duration, - /// Ceiling on the guest-settable first-byte timeout. - pub first_byte_timeout_max: Duration, - /// Ceiling on the guest-settable between-bytes timeout. - pub between_bytes_timeout_max: Duration, - /// Total deadline on one exchange, connect through body streaming. - pub total_deadline: Duration, - /// Cap on one incoming response body. - pub response_body_max_bytes: u64, -} - -/// `[limits.logs]` per-run log retention knobs. Both optional; omitted -/// values resolve to built-in defaults and degenerate zeroes saturate up -/// to 1 at resolve time. -/// -/// Captured-line levels are fixed, not configurable: guest stdout is -/// recorded at info, stderr at warn, and a supervisor-synthesized panic -/// record at error. A guest panic's stderr copy therefore records at -/// warn while its host-interface and supervisor copies carry error. -#[derive(Debug, Default, Deserialize)] -pub struct LogLimitsSection { - /// Byte budget for one run's in-memory ring. - pub bytes_per_run: Option, - /// Number of past runs retained per module. - pub runs_retained: Option, -} - -/// `[limits.poison]` quarantine thresholds. Both optional; omitted -/// values resolve to the production defaults and degenerate zeroes -/// saturate up to 1 at resolve time via [`ModuleLimits::poison`]. -/// -/// A module that reaches `max_failures` traps within a sliding -/// `window_secs` is quarantined: the check fires at the threshold, not one -/// past it. The supervisor then stops dispatching to the module until an -/// operator-driven engine restart clears the state. -#[derive(Debug, Default, Deserialize)] -pub struct PoisonLimitsSection { - /// Maximum traps within the window before a module is poisoned. - pub max_failures: Option, - /// Sliding window the traps are counted across, in seconds. - pub window_secs: Option, -} - -/// `[limits.quota]` per-caller provider submission budget. Both optional; -/// omitted values resolve to the defaults via [`ModuleLimits::quota`]. -/// -/// A caller (a strategy module, keyed by its namespace) may accrue at most -/// `max_charges` submissions within a sliding `window_secs`; a decode failure -/// charged back to the caller counts the same, so a module feeding garbage -/// bodies exhausts its own budget rather than the provider's fuel. -#[derive(Debug, Default, Deserialize)] -pub struct QuotaLimitsSection { - /// Maximum submissions (plus charged decode failures) per caller in the - /// window. - pub max_charges: Option, - /// Sliding window the charges are counted across, in seconds. - pub window_secs: Option, -} - -/// `[limits.status_poll]` provider status polling cadence. Optional; an -/// omitted value resolves to the built-in default and a degenerate zero -/// saturates up to 1 ms via [`ModuleLimits::status_poll_interval`]. -/// -/// The cadence is how often the consuming service polls each installed -/// provider's `status` export for the receipts it watches; only observed -/// transitions fan out as events. -#[derive(Debug, Default, Deserialize)] -pub struct StatusPollSection { - /// Milliseconds between status poll sweeps. - pub interval_ms: Option, -} - -/// `[limits.watch]` status-watch set bounds. Both optional; omitted -/// values resolve to the defaults via [`ModuleLimits::watch`] and -/// degenerate zeroes saturate up to a usable minimum. -/// -/// The consuming service watches each accepted receipt until a terminal -/// status: the cap bounds the per-cadence poll fan-out, and the expiry -/// evicts a watch whose provider never reports one. At the cap a new -/// watch is refused and logged; live watches are never dropped. -#[derive(Debug, Default, Deserialize)] -pub struct WatchLimitsSection { - /// Maximum receipts under status watch at once. - pub max_entries: Option, - /// Seconds one watch stays live before it is evicted unreported. - pub expiry_secs: Option, -} - -/// `[limits.dispatch]` per-module dispatch rate-limit knobs. Both -/// optional; omitted values resolve to the production defaults, and a -/// degenerate zero saturates up to 1 via [`ModuleLimits::dispatch_rate`]. -#[derive(Debug, Default, Deserialize)] -pub struct DispatchLimitsSection { - /// Burst allowance: the token-bucket capacity. - pub burst: Option, - /// Sustained dispatch ceiling: tokens replenished per second. - pub refill_per_sec: Option, -} - -/// Resolved log retention limits the in-memory store enforces. Built by -/// [`ModuleLimits::logs`]. -#[derive(Debug, Clone, Copy)] -pub struct LogRetentionLimits { - /// Byte budget for one run's ring; the oldest records evict first, - /// but the newest record is never evicted to nothing. - pub bytes_per_run: usize, - /// Runs retained per module; the oldest run evicts first. - pub runs_retained: usize, -} - -fn default_state_dir() -> PathBuf { - PathBuf::from("./data") -} - -fn default_log_level() -> String { - "info".to_owned() -} - -/// Read an engine config from disk, returning defaults if the file is -/// missing. Parse errors propagate via [`EngineConfigError`]. -pub fn load_or_default(path: Option<&Path>) -> Result { - let path = match path { - Some(p) => p.to_path_buf(), - None => PathBuf::from("engine.toml"), - }; - - if !path.exists() { - warn!( - path = %path.display(), - "engine.toml not found - running with defaults (no chain RPC endpoints; \ - chain-backed host calls will return Unsupported)" - ); - return Ok(EngineConfig::default()); - } - - let raw = std::fs::read_to_string(&path)?; - // Operators reference RPC URLs (which carry API keys) via - // `${VAR_NAME}` placeholders so the committed `engine.toml` / - // `engine.docker.toml` stays secret-free. The substitution runs - // before TOML parse so a missing var fails fast with the exact - // variable name, not a downstream "invalid URI" several layers - // deep. - let substituted = substitute_env_vars(&raw)?; - let cfg: EngineConfig = toml::from_str(&substituted)?; - info!( - path = %path.display(), - chains = cfg.chains.len(), - state_dir = %cfg.engine.state_dir.display(), - "engine config loaded", - ); - Ok(cfg) -} - -/// Replace every `${VAR_NAME}` token in `raw` with the value of the -/// corresponding environment variable. Returns an error naming any -/// missing variable so the operator sees the exact fix. -/// -/// Recognised variable names: `[A-Z_][A-Z0-9_]*` (matches shell env -/// var conventions). Anything else inside `${...}` is rejected so a -/// typo doesn't silently pass through. -/// -/// Note: substitution runs over the whole TOML text, including -/// comments. This is fine in practice - comments are stripped during -/// the subsequent `toml::from_str` parse, and the only realistic -/// `${VAR}` payload is in string values anyway. -fn substitute_env_vars(raw: &str) -> Result { - let mut out = String::with_capacity(raw.len()); - let bytes = raw.as_bytes(); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' { - // Find the closing `}`. - let start = i + 2; - let Some(end_offset) = raw[start..].find('}') else { - return Err(EnvVarError::Unclosed { offset: i }); - }; - let end = start + end_offset; - let name = &raw[start..end]; - if !is_valid_env_name(name) { - return Err(EnvVarError::InvalidName { - name: name.to_owned(), - }); - } - match std::env::var(name) { - Ok(val) => out.push_str(&val), - Err(_) => { - return Err(EnvVarError::Missing { - name: name.to_owned(), - }); - } - } - i = end + 1; - } else { - // Push one UTF-8 char (find the next char boundary). - let ch = raw[i..] - .chars() - .next() - .expect("byte index is on char boundary"); - out.push(ch); - i += ch.len_utf8(); - } - } - Ok(out) -} - -fn is_valid_env_name(s: &str) -> bool { - let mut chars = s.chars(); - let Some(first) = chars.next() else { - return false; - }; - if !(first.is_ascii_uppercase() || first == '_') { - return false; - } - chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') -} - -/// `IntoStaticStr` exposes the snake_case variant name for the -/// `tracing::error!` / `metrics::counter!` call sites in `main.rs` -/// when an `engine.toml` substitution fails at boot, matching the -/// pattern used on every other engine-side error enum. -#[derive(Debug, thiserror::Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum EnvVarError { - #[error( - "environment variable `{name}` referenced via ${{{name}}} in engine.toml but not set. \ - Export it before launching the engine (e.g. via a `.env` file consumed by `docker compose`)." - )] - Missing { name: String }, - #[error( - "invalid env var name `{name}` inside ${{...}} in engine.toml - names must match \ - [A-Z_][A-Z0-9_]*. Typo, or did you mean `${{{name_upper}}}`?", - name_upper = name.to_uppercase() - )] - InvalidName { name: String }, - #[error( - "unclosed `${{` at byte offset {offset} in engine.toml - every `${{` needs a matching `}}`." - )] - Unclosed { offset: usize }, -} - -/// Blank the credential-bearing parts of a URL (userinfo, query, fragment, and -/// long API-key path segments) so it is safe to log. Parsing with [`url::Url`] -/// rather than string-splitting is what makes bare query flags (`?token`) and -/// fragments redact; an unparseable url yields a placeholder. Shared by every -/// call site that logs an RPC url. -pub fn redact_url(url: &str) -> String { - let Ok(mut parsed) = url::Url::parse(url) else { - return "".to_owned(); - }; - if !parsed.username().is_empty() { - let _ = parsed.set_username("REDACTED"); - } - if parsed.password().is_some() { - let _ = parsed.set_password(Some("REDACTED")); - } - // Key-in-path shape (Alchemy/Infura): a >20-char segment with no '.'/':' is - // an API key. Collect owned first - can't hold the read + write borrows. - let redacted: Option> = parsed.path_segments().map(|segs| { - segs.map(|seg| { - if seg.len() > 20 && !seg.contains('.') && !seg.contains(':') { - "KEY".to_owned() - } else { - seg.to_owned() - } - }) - .collect() - }); - if let Some(segments) = redacted - && let Ok(mut pm) = parsed.path_segments_mut() - { - pm.clear(); - for seg in &segments { - pm.push(seg); - } - } - if parsed.query().is_some() { - parsed.set_query(Some("REDACTED")); - } - if parsed.fragment().is_some() { - parsed.set_fragment(Some("REDACTED")); - } - parsed.to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn named_chain_key_round_trips_to_the_chain() { - // A named TOML key must deserialize to the same `Chain` the - // numeric id would, because `toml` forwards the key string to - // `Chain`'s `FromStr`. - let cfg: EngineConfig = toml::from_str( - r#" -[chains.sepolia] -rpc_url = "wss://example.test/sepolia" -"#, - ) - .expect("named chain key parses"); - assert!( - cfg.chains.contains_key(&Chain::sepolia()), - "the [chains.sepolia] table keys on the Sepolia chain", - ); - assert_eq!( - cfg.chains - .get(&Chain::sepolia()) - .expect("sepolia entry") - .rpc_url, - "wss://example.test/sepolia", - ); - } - - #[test] - fn remote_store_section_parses_and_redacts_the_feed_key() { - let cfg: EngineConfig = toml::from_str( - r#" -[remote_store] -api = "http://localhost:1633" -postage_batch = "aa" -feed_key = "bb" -"#, - ) - .expect("remote_store table parses"); - let section = cfg.remote_store.expect("section present"); - assert_eq!(section.api, "http://localhost:1633"); - assert_eq!(section.postage_batch.as_deref(), Some("aa")); - assert_eq!(section.feed_key.as_deref(), Some("bb")); - let debug = format!("{section:?}"); - assert!(debug.contains(""), "{debug}"); - assert!(!debug.contains("bb"), "{debug}"); - - let absent: EngineConfig = toml::from_str("").expect("empty config parses"); - assert!(absent.remote_store.is_none()); - } - - #[test] - fn invalid_chain_key_surfaces_a_toml_error() { - // A key that is neither a numeric id nor a known chain name must - // fail the parse (a `Toml` error variant), not silently drop. - let err = toml::from_str::( - r#" -[chains.bogus] -rpc_url = "wss://example.test/x" -"#, - ) - .expect_err("bogus chain key must not parse"); - assert!(!err.to_string().is_empty()); - } - - #[test] - fn http_limits_default_when_absent() { - let http = ModuleLimits::default().http(); - assert_eq!(http.connect_timeout_max, Duration::from_secs(10)); - assert_eq!(http.first_byte_timeout_max, Duration::from_secs(30)); - assert_eq!(http.between_bytes_timeout_max, Duration::from_secs(30)); - assert_eq!(http.total_deadline, Duration::from_secs(60)); - assert_eq!(http.response_body_max_bytes, 16 * 1024 * 1024); - } - - #[test] - fn http_limits_parse_with_partial_overrides() { - let cfg: EngineConfig = toml::from_str( - r#" -[limits] -fuel_per_event = 7 - -[limits.http] -connect_timeout_max_ms = 5_000 -total_deadline_ms = 90_000 -response_body_max_bytes = 1_024 -"#, - ) - .expect("limits.http parses"); - assert_eq!(cfg.limits.fuel(), 7); - let http = cfg.limits.http(); - assert_eq!(http.connect_timeout_max, Duration::from_millis(5_000)); - assert_eq!(http.total_deadline, Duration::from_millis(90_000)); - assert_eq!(http.response_body_max_bytes, 1_024); - // Unset fields keep the built-in defaults. - assert_eq!(http.first_byte_timeout_max, Duration::from_secs(30)); - assert_eq!(http.between_bytes_timeout_max, Duration::from_secs(30)); - } - - #[test] - fn chain_limits_default_when_absent() { - assert_eq!( - ModuleLimits::default().chain_response_max_bytes(), - 1024 * 1024, - ); - } - - #[test] - fn chain_limits_parse_with_override() { - let cfg: EngineConfig = toml::from_str( - r#" -[limits.chain] -response_body_max_bytes = 2_048 -"#, - ) - .expect("limits.chain parses"); - assert_eq!(cfg.limits.chain_response_max_bytes(), 2_048); - } - - #[test] - fn chain_limits_saturate_degenerate_zero() { - let cfg: EngineConfig = toml::from_str( - r#" -[limits.chain] -response_body_max_bytes = 0 -"#, - ) - .expect("limits.chain parses"); - assert_eq!( - cfg.limits.chain_response_max_bytes(), - 1, - "zero saturates to 1 so resolution never rejects an empty body", - ); - } - - #[test] - fn http_limits_saturate_degenerate_millisecond_values() { - // Zero would fail every request instantly; u64::MAX would - // overflow timer arithmetic at request time. Both saturate. - let limits = ModuleLimits { - http: HttpLimitsSection { - connect_timeout_max_ms: Some(0), - total_deadline_ms: Some(u64::MAX), - ..Default::default() - }, - ..Default::default() - }; - let http = limits.http(); - assert_eq!(http.connect_timeout_max, Duration::from_millis(1)); - assert_eq!(http.total_deadline, Duration::from_millis(86_400_000)); - } - - #[test] - fn http_limits_saturate_zero_from_toml() { - let cfg: EngineConfig = toml::from_str( - r#" -[limits.http] -total_deadline_ms = 0 -"#, - ) - .expect("limits.http parses"); - assert_eq!(cfg.limits.http().total_deadline, Duration::from_millis(1)); - } - - #[test] - fn log_limits_default_when_absent() { - let logs = ModuleLimits::default().logs(); - assert_eq!(logs.bytes_per_run, 256 * 1024); - assert_eq!(logs.runs_retained, 16); - } - - #[test] - fn log_limits_parse_with_overrides() { - let cfg: EngineConfig = toml::from_str( - r#" -[limits.logs] -bytes_per_run = 4_096 -runs_retained = 3 -"#, - ) - .expect("limits.logs parses"); - let logs = cfg.limits.logs(); - assert_eq!(logs.bytes_per_run, 4_096); - assert_eq!(logs.runs_retained, 3); - } - - #[test] - fn log_limits_saturate_zero_up_to_one() { - // Zero would retain nothing; the saturating resolve keeps at - // least the newest record and run. - let cfg: EngineConfig = toml::from_str( - r#" -[limits.logs] -bytes_per_run = 0 -runs_retained = 0 -"#, - ) - .expect("limits.logs parses"); - let logs = cfg.limits.logs(); - assert_eq!(logs.bytes_per_run, 1); - assert_eq!(logs.runs_retained, 1); - } - - #[test] - fn poison_limits_default_when_absent() { - let poison = ModuleLimits::default().poison(); - assert_eq!(poison.max_failures, POISON_MAX_FAILURES); - assert_eq!(poison.window, POISON_WINDOW); - } - - #[test] - fn poison_limits_parse_with_overrides() { - let cfg: EngineConfig = toml::from_str( - r#" -[limits.poison] -max_failures = 3 -window_secs = 60 -"#, - ) - .expect("limits.poison parses"); - let poison = cfg.limits.poison(); - assert_eq!(poison.max_failures, 3); - assert_eq!(poison.window, Duration::from_secs(60)); - } - - #[test] - fn poison_limits_saturate_zero_up_to_one() { - // Zero max_failures would quarantine on the first trap; a zero - // window would prune every failure before the check. Both - // saturate to a usable minimum. - let cfg: EngineConfig = toml::from_str( - r#" -[limits.poison] -max_failures = 0 -window_secs = 0 -"#, - ) - .expect("limits.poison parses"); - let poison = cfg.limits.poison(); - assert_eq!(poison.max_failures, 1); - assert_eq!(poison.window, Duration::from_secs(1)); - } - - #[test] - fn adapters_parse_with_scoped_transport_grants() { - let cfg: EngineConfig = toml::from_str( - r#" -[[adapters]] -path = "providers/acme/acme_provider.wasm" -http_allow = ["api.acme.example", "*.acme.example"] -messaging_topics = ["/nexum/1/acme-orders/proto"] - -[[adapters]] -path = "adapters/bare/bare.wasm" -manifest = "adapters/bare/module.toml" -"#, - ) - .expect("adapters parse"); - assert_eq!(cfg.adapters.len(), 2); - let first = &cfg.adapters[0]; - assert_eq!( - first.path, - PathBuf::from("providers/acme/acme_provider.wasm") - ); - assert!(first.manifest.is_none(), "manifest defaults to sibling"); - assert_eq!(first.http_allow, vec!["api.acme.example", "*.acme.example"]); - assert_eq!(first.messaging_topics, vec!["/nexum/1/acme-orders/proto"]); - let second = &cfg.adapters[1]; - assert_eq!( - second.manifest.as_deref(), - Some(Path::new("adapters/bare/module.toml")) - ); - assert!( - second.http_allow.is_empty() && second.messaging_topics.is_empty(), - "unset scope grants default empty", - ); - } - - #[test] - fn adapters_default_empty_when_absent() { - let cfg = EngineConfig::default(); - assert!(cfg.adapters.is_empty()); - } - - #[test] - fn dispatch_rate_default_when_absent() { - let policy = ModuleLimits::default().dispatch_rate(); - assert_eq!(policy.capacity, DEFAULT_DISPATCH_BURST); - assert_eq!(policy.refill_per_sec, DEFAULT_DISPATCH_REFILL_PER_SEC); - } - - #[test] - fn dispatch_rate_parse_with_overrides() { - let cfg: EngineConfig = toml::from_str( - r#" -[limits.dispatch] -burst = 8 -refill_per_sec = 4 -"#, - ) - .expect("limits.dispatch parses"); - let policy = cfg.limits.dispatch_rate(); - assert_eq!(policy.capacity, 8); - assert_eq!(policy.refill_per_sec, 4); - } - - #[test] - fn dispatch_rate_saturates_zero_up_to_one() { - // A zero burst or refill would wedge the bucket; saturate to a minimum. - let cfg: EngineConfig = toml::from_str( - r#" -[limits.dispatch] -burst = 0 -refill_per_sec = 0 -"#, - ) - .expect("limits.dispatch parses"); - let policy = cfg.limits.dispatch_rate(); - assert_eq!(policy.capacity, 1); - assert_eq!(policy.refill_per_sec, 1); - } - - #[test] - fn watch_limits_default_when_absent() { - let watch = ModuleLimits::default().watch(); - assert_eq!(watch.max_entries, DEFAULT_WATCH_MAX_ENTRIES); - assert_eq!(watch.expiry, DEFAULT_WATCH_EXPIRY); - } - - #[test] - fn watch_limits_parse_with_overrides() { - let cfg: EngineConfig = toml::from_str( - r#" -[limits.watch] -max_entries = 32 -expiry_secs = 900 -"#, - ) - .expect("limits.watch parses"); - let watch = cfg.limits.watch(); - assert_eq!(watch.max_entries, 32); - assert_eq!(watch.expiry, Duration::from_secs(900)); - } - - #[test] - fn watch_limits_saturate_zero_up_to_one() { - // A zero cap would refuse every watch; a zero expiry would evict - // each watch before its first poll. Both saturate. - let cfg: EngineConfig = toml::from_str( - r#" -[limits.watch] -max_entries = 0 -expiry_secs = 0 -"#, - ) - .expect("limits.watch parses"); - let watch = cfg.limits.watch(); - assert_eq!(watch.max_entries, 1); - assert_eq!(watch.expiry, Duration::from_secs(1)); - } - - #[test] - fn extensions_tables_parse_opaquely() { - let cfg: EngineConfig = toml::from_str( - r#" -[extensions.example] -key = "value" -"#, - ) - .expect("extensions table parses"); - let section = cfg.extensions.get("example").expect("example table"); - assert_eq!(section.get("key").and_then(|v| v.as_str()), Some("value")); - } - - #[test] - fn redact_replaces_long_path_segments() { - let redacted = - redact_url("https://lb.drpc.live/sepolia/AnOfyGnZ_0nWpS-OOwQzqAnFj_Naa0sR8ZxkVjewFaCJ"); - assert!( - redacted.contains("KEY"), - "long segment redacted: {redacted}" - ); - assert!( - !redacted.contains("AnOfyGnZ"), - "the key must be gone: {redacted}", - ); - } - - #[test] - fn redact_keeps_short_segments_intact() { - // Hostnames + "v2" path bits must not be redacted. - let redacted = redact_url("https://eth-mainnet.g.alchemy.com/v2/abc"); - assert!(redacted.contains("eth-mainnet.g.alchemy.com")); - assert!(redacted.contains("v2")); - } - - #[test] - fn redact_strips_userinfo_credentials() { - // url renders userinfo as REDACTED:REDACTED@ when both parts are - // present; assert the secret is gone rather than an exact string. - let redacted = redact_url("https://user:pass@rpc.example.com/path"); - assert!(!redacted.contains("user:pass"), "userinfo gone: {redacted}"); - assert!(!redacted.contains("pass"), "password gone: {redacted}"); - assert!( - redacted.contains("rpc.example.com"), - "host kept: {redacted}" - ); - assert!(redacted.contains("REDACTED")); - } - - #[test] - fn redact_strips_query_param_values() { - let redacted = redact_url("https://rpc.example.com/v1?key=supersecret"); - assert!( - !redacted.contains("supersecret"), - "query secret gone: {redacted}" - ); - assert!(redacted.contains("rpc.example.com")); - } - - #[test] - fn redact_strips_bare_query_flag() { - // A bare `?token` flag (no `=`) is the whole query string; blanking - // the query removes it. This is the gap string heuristics missed. - let redacted = redact_url("https://rpc.example.com/v1?myapitoken"); - assert!( - !redacted.contains("myapitoken"), - "bare flag gone: {redacted}" - ); - assert!(redacted.contains("rpc.example.com")); - } - - #[test] - fn redact_strips_fragment() { - // OAuth-style bearer tokens can ride in the fragment. - let redacted = redact_url("https://rpc.example.com/v1#bearertoken"); - assert!( - !redacted.contains("bearertoken"), - "fragment gone: {redacted}" - ); - assert!(redacted.contains("rpc.example.com")); - } - - #[test] - fn redact_at_in_path_is_not_treated_as_userinfo() { - // An `@` inside a path segment must not be parsed as userinfo; the - // host stays intact. - let redacted = redact_url("https://rpc.example.com/foo@bar/baz"); - assert!( - redacted.contains("rpc.example.com"), - "host kept: {redacted}" - ); - } - - #[test] - fn redact_leaves_clean_wss_url_intact() { - // A url with no secret survives materially unchanged. - let redacted = redact_url("wss://rpc.example.com/v1"); - assert!(redacted.contains("rpc.example.com")); - assert!(redacted.contains("v1")); - assert!(!redacted.contains("REDACTED")); - assert!(!redacted.contains("KEY")); - } - - #[test] - fn redact_returns_placeholder_for_unparseable_url() { - assert_eq!(redact_url("not a url"), ""); - } - - // ----------------- env var substitution ----------------------- - // - // These tests stash + restore process env vars under unique names - // so parallel `cargo test` runs don't trip on each other. - - fn with_env(name: &str, value: &str, body: F) { - let prev = std::env::var(name).ok(); - // SAFETY: tests are single-threaded within one test fn; setting - // an env var here is fine since the unique-name convention - // avoids cross-test races. - unsafe { std::env::set_var(name, value) }; - body(); - match prev { - Some(v) => unsafe { std::env::set_var(name, v) }, - None => unsafe { std::env::remove_var(name) }, - } - } - - #[test] - fn substitute_replaces_known_variable() { - with_env("NEXUM_TEST_RPC", "wss://example.test/abc", || { - let raw = r#"rpc_url = "${NEXUM_TEST_RPC}""#; - let out = substitute_env_vars(raw).unwrap(); - assert_eq!(out, r#"rpc_url = "wss://example.test/abc""#); - }); - } - - #[test] - fn substitute_errors_on_missing_variable() { - // Variable name must not collide with anything in the operator - // environment. Use a guaranteed-unique prefix. - let err = - substitute_env_vars(r#"x = "${NEXUM_TEST_DEFINITELY_UNSET_VAR_XYZ}""#).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("NEXUM_TEST_DEFINITELY_UNSET_VAR_XYZ")); - assert!(msg.contains("not set")); - } - - #[test] - fn substitute_errors_on_invalid_name() { - let err = substitute_env_vars(r#"x = "${lowercase_name}""#).unwrap_err(); - assert!(matches!(err, EnvVarError::InvalidName { .. })); - } - - #[test] - fn substitute_errors_on_unclosed_brace() { - let err = substitute_env_vars(r#"x = "${UNCLOSED"#).unwrap_err(); - assert!(matches!(err, EnvVarError::Unclosed { .. })); - } - - #[test] - fn substitute_passes_text_with_no_placeholders_through() { - let raw = "no placeholders here\nrpc_url = \"wss://x\""; - assert_eq!(substitute_env_vars(raw).unwrap(), raw); - } - - #[test] - fn substitute_handles_multiple_placeholders_in_one_line() { - with_env("NEXUM_TEST_A", "alpha", || { - with_env("NEXUM_TEST_B", "beta", || { - let raw = "k = \"${NEXUM_TEST_A}-${NEXUM_TEST_B}\""; - let out = substitute_env_vars(raw).unwrap(); - assert_eq!(out, "k = \"alpha-beta\""); - }); - }); - } - - #[test] - fn substitute_preserves_utf8_around_placeholder() { - // The hand-rolled byte loop must respect multi-byte UTF-8. - with_env("NEXUM_TEST_U", "X", || { - let raw = "# 河 ${NEXUM_TEST_U} ⚙️\n"; - let out = substitute_env_vars(raw).unwrap(); - assert_eq!(out, "# 河 X ⚙️\n"); - }); - } -} diff --git a/nexum/crates/nexum-runtime/src/host/actor.rs b/nexum/crates/nexum-runtime/src/host/actor.rs deleted file mode 100644 index c10f8e0a..00000000 --- a/nexum/crates/nexum-runtime/src/host/actor.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! The supervised host-actor primitive: one component instance the host -//! holds and others call. The store is refuelled before each guest call, -//! a trap is projected onto a typed fault instead of unwinding into the -//! caller, and each instance sits behind an [`ActorSlot`] async mutex held -//! across the guest await, so one store never runs two guest calls at once. - -use std::sync::{Arc, Mutex, MutexGuard}; -use std::time::Instant; - -use tokio::sync::Mutex as AsyncMutex; -use wasmtime::Store; - -use super::component::RuntimeTypes; -use super::state::HostState; - -/// One supervised actor behind its serialising mutex. A wasmtime `Store` -/// is not `Sync`; concurrent callers queue here. -pub type ActorSlot = Arc>; - -/// Shared liveness of one supervised component. The store marks it dead on -/// a trap, recording when, so the supervisor's restart sweep can count the -/// backoff from the death rather than from the sweep that observed it. -/// Cloning shares the flag. Starts alive. -#[derive(Clone, Debug, Default)] -pub struct Liveness(Arc>>); - -impl Liveness { - /// Whether the component is currently callable. - pub fn is_alive(&self) -> bool { - self.lock().is_none() - } - - /// When the component died, while it is dead. - pub fn dead_since(&self) -> Option { - *self.lock() - } - - /// Mark the component dead: its store trapped and is unusable. Keeps - /// the first death instant when already dead. - pub fn mark_dead(&self) { - let mut died_at = self.lock(); - if died_at.is_none() { - *died_at = Some(Instant::now()); - } - } - - /// Mark the component alive again after a restart. - pub fn mark_alive(&self) { - *self.lock() = None; - } - - /// The flag, recovered from a poisoned lock: the state is a bare - /// `Option`, valid under any interleaving. - fn lock(&self) -> MutexGuard<'_, Option> { - self.0 - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } -} - -/// A guest call failed outside the component's typed error space. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum ActorFault { - /// The pre-call refuel failed; the guest was never entered. - #[error("refuel failed: {0}")] - Refuel(wasmtime::Error), - /// The guest trapped. Carries the root cause only; the wasm frame - /// list stays out of the caller-facing message. - #[error("trapped: {}", .0.root_cause())] - Trap(wasmtime::Error), -} - -/// A supervised component store: refuelled before each guest call so every -/// invocation starts from a full budget, with traps projected onto -/// [`ActorFault`] and recorded on the shared [`Liveness`]. -pub struct SupervisedStore { - store: Store>, - fuel_per_call: u64, - liveness: Liveness, -} - -impl SupervisedStore { - /// Supervise an instantiated store with a per-call fuel budget, - /// reporting traps on `liveness`. - pub fn new(store: Store>, fuel_per_call: u64, liveness: Liveness) -> Self { - Self { - store, - fuel_per_call, - liveness, - } - } - - /// Refuel, then run one guest call against the store. A trap marks the - /// shared liveness dead: the store is poisoned until reinstantiated. - pub async fn call( - &mut self, - call: impl AsyncFnOnce(&mut Store>) -> wasmtime::Result, - ) -> Result { - self.store - .set_fuel(self.fuel_per_call) - .map_err(ActorFault::Refuel)?; - call(&mut self.store).await.map_err(|trap| { - self.liveness.mark_dead(); - ActorFault::Trap(trap) - }) - } -} diff --git a/nexum/crates/nexum-runtime/src/host/component/builder.rs b/nexum/crates/nexum-runtime/src/host/component/builder.rs deleted file mode 100644 index 84b4f321..00000000 --- a/nexum/crates/nexum-runtime/src/host/component/builder.rs +++ /dev/null @@ -1,293 +0,0 @@ -//! Per-component builders: one seam for turning the loaded config plus a -//! resolved data directory into a runtime backend. -//! -//! Each core backend is wrapped as a [`ComponentBuilder`], and -//! [`ComponentsBuilder`] assembles the core seams (plus the lattice `Ext` -//! payload and the log pipeline) into a [`Components`] bundle. The -//! composition root names the concrete builders once; boot drives them -//! through this trait. - -use std::future::Future; -use std::path::Path; - -use nexum_tasks::TaskExecutor; - -use crate::host::component::{Components, RuntimeTypes}; -use crate::host::local_store_redb::LocalStore; -use crate::host::logs::LogPipeline; -use crate::host::provider_pool::ProviderPool; -use crate::host::remote_store_bee::RemoteStore; - -/// Shared inputs every component builder reads: the loaded engine config, -/// the resolved data directory backends open their files under, and the -/// executor blocking opens run on. -pub struct BuilderContext<'a> { - /// The loaded engine config. - pub config: &'a crate::engine_config::EngineConfig, - /// Directory backends root their on-disk state at. - pub data_dir: &'a Path, - /// Runs blocking open work off the async executor. - pub executor: &'a TaskExecutor, -} - -/// Builds one runtime backend from the shared [`BuilderContext`]. The -/// `impl Future + Send` form lets a builder connect over the network -/// (the chain provider does) while staying usable from a spawned task. -pub trait ComponentBuilder { - /// The backend this builder produces. - type Output; - - /// Open the backend, consuming the builder. - fn build( - self, - ctx: &BuilderContext<'_>, - ) -> impl Future> + Send; -} - -/// Builds the chain [`ProviderPool`] from `[chains]`. -pub struct ProviderPoolBuilder; - -impl ComponentBuilder for ProviderPoolBuilder { - type Output = ProviderPool; - - async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result { - ProviderPool::from_config(ctx.config) - .await - .map_err(Into::into) - } -} - -/// Builds the [`LocalStore`] at `data_dir/local-store.redb`, creating the -/// data directory if it does not exist. -pub struct LocalStoreBuilder; - -impl ComponentBuilder for LocalStoreBuilder { - type Output = LocalStore; - - async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result { - // create_dir_all and LocalStore::open (which fsyncs on create) are - // blocking syscalls; keep them off the async executor. - let data_dir = ctx.data_dir.to_path_buf(); - ctx.executor - .spawn_blocking(move || { - std::fs::create_dir_all(&data_dir).map_err(|e| { - anyhow::anyhow!("create data directory {}: {e}", data_dir.display()) - })?; - let path = data_dir.join("local-store.redb"); - LocalStore::open(&path) - .map_err(|e| anyhow::anyhow!("open local-store at {}: {e}", path.display())) - }) - .join() - .await - .ok_or_else(|| anyhow::anyhow!("local-store open task ended abnormally"))? - } -} - -/// Builds the [`RemoteStore`] from `[remote_store]`; an absent table -/// yields a disabled handle. -pub struct RemoteStoreBuilder; - -impl ComponentBuilder for RemoteStoreBuilder { - type Output = RemoteStore; - - async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result { - RemoteStore::from_config(ctx.config.remote_store.as_ref()).map_err(Into::into) - } -} - -/// Builds the default [`LogPipeline`]: the byte-bounded in-memory backend -/// sized from `[limits.logs]`. -pub struct LogPipelineBuilder; - -impl ComponentBuilder for LogPipelineBuilder { - type Output = LogPipeline; - - async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result { - Ok(LogPipeline::in_memory(ctx.config.limits.logs())) - } -} - -/// Names the component slot whose build failed. The leaf cause stays an -/// `anyhow::Error` because the backends fail for heterogeneous reasons -/// (I/O for the store, network for the chain). -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum BuildError { - /// The chain backend builder failed. - #[error("build the chain backend: {0}")] - Chain(anyhow::Error), - /// The store backend builder failed. - #[error("build the store backend: {0}")] - Store(anyhow::Error), - /// The extension payload builder failed. - #[error("build the extension payload: {0}")] - Ext(anyhow::Error), - /// The log pipeline builder failed. - #[error("build the log pipeline: {0}")] - Logs(anyhow::Error), - /// The remote-store builder failed. - #[error("build the remote-store backend: {0}")] - Remote(anyhow::Error), -} - -/// The empty extension payload: a no-op builder for a core-only lattice -/// (`Ext = ()`). -impl ComponentBuilder for () { - type Output = (); - - async fn build(self, _ctx: &BuilderContext<'_>) -> anyhow::Result<()> { - Ok(()) - } -} - -/// Assembles the core backend builders, the lattice `Ext` builder, and the -/// log pipeline builder into a [`Components`] bundle. The logs slot defaults -/// to [`LogPipelineBuilder`] and the remote slot to [`RemoteStoreBuilder`]; -/// the embedder retains the read handle by cloning [`Components::logs`] -/// after the build. -pub struct ComponentsBuilder { - /// Builds the chain backend ([`RuntimeTypes::Chain`]). - pub chain: C, - /// Builds the store backend ([`RuntimeTypes::Store`]). - pub store: S, - /// Builds the extension payload ([`RuntimeTypes::Ext`]). - pub ext: E, - /// Builds the shared [`LogPipeline`]. - pub logs: L, - /// Builds the shared [`RemoteStore`]. - pub remote: R, -} - -impl ComponentsBuilder { - /// Create a new [`ComponentsBuilder`] with the default log pipeline - /// and remote-store builders. - pub fn new(chain: C, store: S, ext: E) -> Self { - Self { - chain, - store, - ext, - logs: LogPipelineBuilder, - remote: RemoteStoreBuilder, - } - } -} - -impl ComponentsBuilder { - /// Replace the log pipeline builder. - pub fn with_logs(self, logs: L2) -> ComponentsBuilder { - ComponentsBuilder { - chain: self.chain, - store: self.store, - ext: self.ext, - logs, - remote: self.remote, - } - } - - /// Replace the remote-store builder. - pub fn with_remote(self, remote: R2) -> ComponentsBuilder { - ComponentsBuilder { - chain: self.chain, - store: self.store, - ext: self.ext, - logs: self.logs, - remote, - } - } - - /// Drive each builder against `ctx` and bundle the backends. The - /// builder outputs must match the lattice seams: chain to - /// [`RuntimeTypes::Chain`], store to [`RuntimeTypes::Store`], ext to - /// [`RuntimeTypes::Ext`]; logs always yields a [`LogPipeline`] and - /// remote a [`RemoteStore`]. A failing sub-build returns the - /// [`BuildError`] variant naming that slot. - pub async fn build(self, ctx: &BuilderContext<'_>) -> Result, BuildError> - where - T: RuntimeTypes, - C: ComponentBuilder, - S: ComponentBuilder, - E: ComponentBuilder, - L: ComponentBuilder, - R: ComponentBuilder, - { - let chain = self.chain.build(ctx).await.map_err(BuildError::Chain)?; - let store = self.store.build(ctx).await.map_err(BuildError::Store)?; - let ext = self.ext.build(ctx).await.map_err(BuildError::Ext)?; - let logs = self.logs.build(ctx).await.map_err(BuildError::Logs)?; - let remote = self.remote.build(ctx).await.map_err(BuildError::Remote)?; - Ok(Components { - chain, - store, - ext, - logs, - remote, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::engine_config::EngineConfig; - use crate::preset::CoreRuntime; - - /// Drives the core component builders end-to-end against a real (empty) - /// config and a fresh data directory: chain pool, redb store, and the - /// log pipeline are opened at runtime, not just typechecked. Proves the - /// store builder creates the data directory and the assembly bundles a - /// live pipeline. - #[tokio::test] - async fn components_builder_opens_the_core_backends() { - let dir = tempfile::tempdir().expect("tempdir"); - let data_dir = dir.path().join("nested-state"); - let config = EngineConfig::default(); - let tasks = nexum_tasks::TaskManager::new(); - let executor = tasks.executor(); - let ctx = BuilderContext { - config: &config, - data_dir: &data_dir, - executor: &executor, - }; - - let components = ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) - .build::(&ctx) - .await - .expect("build core components"); - - // The store builder created the data directory eagerly. - assert!(data_dir.is_dir(), "data directory created by the build"); - assert!( - data_dir.join("local-store.redb").is_file(), - "redb store opened under the data directory", - ); - // The bundle carries a live in-memory log pipeline. - let _ = &components.logs; - } - - /// `with_logs` substitutes the log pipeline builder: the bundle carries - /// the exact pipeline the custom builder yields. - #[tokio::test] - async fn with_logs_substitutes_the_pipeline() { - let dir = tempfile::tempdir().expect("tempdir"); - let config = EngineConfig::default(); - let tasks = nexum_tasks::TaskManager::new(); - let executor = tasks.executor(); - let ctx = BuilderContext { - config: &config, - data_dir: dir.path(), - executor: &executor, - }; - - let custom = LogPipeline::in_memory(config.limits.logs()); - let components = ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) - .with_logs(crate::test_utils::Prebuilt(custom.clone())) - .build::(&ctx) - .await - .expect("build with a custom log pipeline"); - - assert!( - std::sync::Arc::ptr_eq(&components.logs.router(), &custom.router()), - "bundle carries the substituted pipeline", - ); - } -} diff --git a/nexum/crates/nexum-runtime/src/host/component/chain.rs b/nexum/crates/nexum-runtime/src/host/component/chain.rs deleted file mode 100644 index 687abf3a..00000000 --- a/nexum/crates/nexum-runtime/src/host/component/chain.rs +++ /dev/null @@ -1,184 +0,0 @@ -//! Chain backend seam: raw JSON-RPC dispatch plus block/chain-log -//! subscriptions, mirroring the inherent `ProviderPool` API. - -use std::future::Future; - -use alloy_chains::Chain; -use alloy_rpc_types_eth::Filter; -use strum::{EnumString, IntoStaticStr}; - -use crate::host::provider_pool::{BlockStream, CanonicalLogStream, ProviderError, ProviderPool}; - -/// The permitted JSON-RPC read surface as a closed type. Methods that -/// sign or mutate node state have no variant, so a guest-supplied -/// signing method (for example `eth_sign` or `eth_sendTransaction`) -/// cannot be represented and never reaches the provider. This is the -/// structural ceiling; an operator allowlist narrows within it and -/// never widens it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr)] -#[non_exhaustive] -pub enum ChainMethod { - #[strum(serialize = "eth_blockNumber")] - EthBlockNumber, - #[strum(serialize = "eth_call")] - EthCall, - #[strum(serialize = "eth_chainId")] - EthChainId, - #[strum(serialize = "eth_estimateGas")] - EthEstimateGas, - #[strum(serialize = "eth_feeHistory")] - EthFeeHistory, - #[strum(serialize = "eth_gasPrice")] - EthGasPrice, - #[strum(serialize = "eth_maxPriorityFeePerGas")] - EthMaxPriorityFeePerGas, - #[strum(serialize = "eth_getBalance")] - EthGetBalance, - #[strum(serialize = "eth_getBlockByHash")] - EthGetBlockByHash, - #[strum(serialize = "eth_getBlockByNumber")] - EthGetBlockByNumber, - #[strum(serialize = "eth_getBlockReceipts")] - EthGetBlockReceipts, - #[strum(serialize = "eth_getCode")] - EthGetCode, - #[strum(serialize = "eth_getLogs")] - EthGetLogs, - #[strum(serialize = "eth_getProof")] - EthGetProof, - #[strum(serialize = "eth_getStorageAt")] - EthGetStorageAt, - #[strum(serialize = "eth_getTransactionByHash")] - EthGetTransactionByHash, - #[strum(serialize = "eth_getTransactionCount")] - EthGetTransactionCount, - #[strum(serialize = "eth_getTransactionReceipt")] - EthGetTransactionReceipt, - #[strum(serialize = "net_version")] - NetVersion, -} - -impl ChainMethod { - /// The wire method name forwarded to the provider. `&'static` - /// because the permitted set is closed, so the name drops straight - /// into alloy's `Cow<'static, str>` method slot without allocating. - pub fn as_str(self) -> &'static str { - self.into() - } -} - -/// Async chain backend. Methods mirror [`ProviderPool`] one-to-one; -/// the `impl Future + Send` form bakes in the Send bound generic -/// consumers need across `.await` in tokio tasks (not dyn-compatible). -pub trait ChainProvider { - /// Open a `newHeads` block subscription on `chain`. - fn subscribe_blocks( - &self, - chain: Chain, - ) -> impl Future> + Send; - - /// Current head block number (`eth_blockNumber`), used as the - /// canonical log poller's start block. - fn block_number(&self, chain: Chain) - -> impl Future> + Send; - - /// Open a canonical (reorg-aware) `eth_getLogs` log poller on - /// `chain` from `start_block`. - fn watch_chain_logs( - &self, - chain: Chain, - filter: Filter, - start_block: u64, - ) -> Result; - - /// Raw JSON-RPC dispatch. `method` is a permitted read-surface - /// method; `params_json` is the JSON params array. - fn request( - &self, - chain: Chain, - method: ChainMethod, - params_json: String, - ) -> impl Future> + Send; -} - -impl ChainProvider for ProviderPool { - fn subscribe_blocks( - &self, - chain: Chain, - ) -> impl Future> + Send { - ProviderPool::subscribe_blocks(self, chain) - } - - fn block_number( - &self, - chain: Chain, - ) -> impl Future> + Send { - ProviderPool::block_number(self, chain) - } - - fn watch_chain_logs( - &self, - chain: Chain, - filter: Filter, - start_block: u64, - ) -> Result { - ProviderPool::watch_chain_logs(self, chain, filter, start_block) - } - - fn request( - &self, - chain: Chain, - method: ChainMethod, - params_json: String, - ) -> impl Future> + Send { - ProviderPool::request(self, chain, method, params_json) - } -} - -#[cfg(test)] -mod tests { - use super::ChainMethod; - - #[test] - fn read_surface_methods_parse() { - for m in [ - "eth_call", - "eth_blockNumber", - "eth_getBalance", - "eth_getLogs", - "eth_getTransactionReceipt", - "net_version", - ] { - assert!(ChainMethod::try_from(m).is_ok(), "{m} should parse"); - } - } - - #[test] - fn signing_and_mutating_methods_have_no_variant() { - for m in [ - "eth_sign", - "eth_signTransaction", - "eth_sendTransaction", - "eth_sendRawTransaction", - "eth_accounts", - "personal_sign", - "personal_unlockAccount", - "admin_peers", - "debug_traceCall", - "miner_start", - "eth_notAMethod", - "", - ] { - assert!(ChainMethod::try_from(m).is_err(), "{m} must be rejected"); - } - } - - #[test] - fn as_str_round_trips_the_wire_name() { - assert_eq!(ChainMethod::EthCall.as_str(), "eth_call"); - assert_eq!( - ChainMethod::try_from(ChainMethod::EthGetBalance.as_str()).unwrap(), - ChainMethod::EthGetBalance, - ); - } -} diff --git a/nexum/crates/nexum-runtime/src/host/component/mod.rs b/nexum/crates/nexum-runtime/src/host/component/mod.rs deleted file mode 100644 index 013b208a..00000000 --- a/nexum/crates/nexum-runtime/src/host/component/mod.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Backend component traits: the seam between the WIT host impls and -//! the concrete capability backends. Implemented here for the existing -//! pools; the runtime-generic `HostState` consumes them via generic -//! bounds (the async traits are not dyn-compatible by design). The -//! [`RuntimeTypes`] lattice ties the seams into one parameter. - -mod builder; -mod chain; -mod runtime_types; -mod state; - -pub use builder::{ - BuildError, BuilderContext, ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, - LogPipelineBuilder, ProviderPoolBuilder, RemoteStoreBuilder, -}; -pub use chain::{ChainMethod, ChainProvider}; -pub use runtime_types::{Handle, RuntimeTypes}; -pub use state::{StateHandle, StateStore}; - -/// Owned bundle of the shared backends the supervisor threads into -/// every module store. All members are cheap Arc-backed clones. -pub struct Components { - pub chain: T::Chain, - pub store: T::Store, - /// Extension backends (the lattice `Ext` payload), threaded into - /// `HostState.ext` and reached by extensions through `ExtState`. - pub ext: T::Ext, - /// Shared log pipeline: capture points route through its router, and - /// the embedder reads runs and logs back off the same handle. - pub logs: crate::host::logs::LogPipeline, - /// Shared `remote-store` handle over the configured Bee node; - /// disabled when `[remote_store]` is absent. - pub remote: crate::host::remote_store_bee::RemoteStore, -} - -impl Clone for Components { - fn clone(&self) -> Self { - Self { - chain: self.chain.clone(), - store: self.store.clone(), - ext: self.ext.clone(), - logs: self.logs.clone(), - remote: self.remote.clone(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::host::local_store_redb::{LocalStore, ModuleStore}; - use crate::host::provider_pool::ProviderPool; - - /// Core-only lattice (no extension payload) so the trait bounds are - /// exercised without depending on any domain extension crate. - #[derive(Clone, Copy, Default)] - struct CoreTypes; - - impl crate::sealed::SealedRuntimeTypes for CoreTypes {} - - impl RuntimeTypes for CoreTypes { - type Chain = ProviderPool; - type Store = LocalStore; - type Ext = (); - } - - fn chain() {} - fn store() {} - fn handle() {} - fn lattice() {} - - #[test] - fn concrete_backends_satisfy_the_traits() { - chain::(); - store::(); - handle::(); - lattice::(); - } - - #[tokio::test] - async fn chain_provider_trait_delegates_to_the_pool() { - use alloy_chains::Chain; - let pool = ProviderPool::empty(); - let err = ChainProvider::request( - &pool, - Chain::from_id(1), - ChainMethod::EthBlockNumber, - "[]".into(), - ) - .await - .unwrap_err(); - assert!(matches!( - err, - crate::host::provider_pool::ProviderError::UnknownChain(c) if c == Chain::from_id(1) - )); - } -} diff --git a/nexum/crates/nexum-runtime/src/host/component/runtime_types.rs b/nexum/crates/nexum-runtime/src/host/component/runtime_types.rs deleted file mode 100644 index 33741499..00000000 --- a/nexum/crates/nexum-runtime/src/host/component/runtime_types.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! The RuntimeTypes lattice: one trait naming the core backend seams plus -//! the pluggable extension slot, so every generic signature takes a single -//! parameter. -//! -//! Time, randomness, and outbound HTTP are deliberately not members: all -//! are WASI concerns serviced per store (WasiCtxBuilder for clocks and -//! randomness, wasi:http behind the allowlist gate), not host backends. -//! Domain backends are not core seams: they live behind -//! the [`RuntimeTypes::Ext`] slot and are wired in as extensions. - -use crate::host::component::{ChainProvider, StateStore}; - -/// Names the core backend seams a runtime assembly provides, plus the -/// extension slot ([`Ext`](RuntimeTypes::Ext)) that carries any non-core -/// backend an extension needs. -/// -/// Sealed: a lattice opts in by also implementing the sealing marker. -pub trait RuntimeTypes: crate::sealed::SealedRuntimeTypes + 'static { - /// JSON-RPC dispatch and subscriptions. - type Chain: ChainProvider + Clone + Send + Sync + 'static; - /// Process-wide store vending per-module handles. - type Store: StateStore + Clone + Send + Sync + 'static; - /// Extension state slot. Backends that are not core capabilities live - /// here; an extension reaches its payload through the `ExtState` - /// accessor without naming the concrete lattice. `()` for an assembly - /// with no extensions. - type Ext: Clone + Send + Sync + 'static; -} - -/// Per-module store handle of a lattice's Store member. -pub type Handle = <::Store as StateStore>::Handle; diff --git a/nexum/crates/nexum-runtime/src/host/component/state.rs b/nexum/crates/nexum-runtime/src/host/component/state.rs deleted file mode 100644 index 635ca0b1..00000000 --- a/nexum/crates/nexum-runtime/src/host/component/state.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Local-store seam: process-wide store vending per-module namespaced -//! handles, mirroring `LocalStore::module` and the `ModuleStore` API. - -// StorageError embeds redb error types; same allowance as -// local_store_redb.rs. -#![allow(clippy::result_large_err)] - -use crate::host::local_store_redb::{LocalStore, ModuleStore, StorageError}; - -/// Process-wide state store that vends per-module handles. -pub trait StateStore { - /// Per-module namespaced handle type. - type Handle: StateHandle; - - /// Return a handle scoped to `namespace`. - fn module(&self, namespace: &str) -> Result; -} - -/// Per-module key-value handle; mirrors the inherent `ModuleStore` API. -pub trait StateHandle { - /// Cap this handle at `quota_bytes` (key + value bytes); writes past it - /// are rejected with [`StorageError::QuotaExceeded`]. - fn with_quota(self, quota_bytes: u64) -> Self; - /// Fetch a value; `Ok(None)` when absent. - fn get(&self, key: &str) -> Result>, StorageError>; - /// Insert or overwrite. - fn set(&self, key: &str, value: &[u8]) -> Result<(), StorageError>; - /// Delete; idempotent. - fn delete(&self, key: &str) -> Result<(), StorageError>; - /// Enumerate module-visible keys starting with `prefix`. - fn list_keys(&self, prefix: &str) -> Result, StorageError>; - /// Whether `key` exists. Default fetches the value; a backend - /// overrides when it can answer without. - fn contains(&self, key: &str) -> Result { - Ok(self.get(key)?.is_some()) - } - /// Value byte length, `Ok(None)` when absent. Default fetches the - /// value; on some backends this may be a scan. - fn len(&self, key: &str) -> Result, StorageError> { - Ok(self.get(key)?.map(|v| v.len() as u64)) - } - /// Number of keys starting with `prefix`. Default materialises the - /// key list; on some backends this may be a scan. - fn count(&self, prefix: &str) -> Result { - Ok(self.list_keys(prefix)?.len() as u64) - } -} - -impl StateStore for LocalStore { - type Handle = ModuleStore; - - fn module(&self, namespace: &str) -> Result { - LocalStore::module(self, namespace) - } -} - -impl StateHandle for ModuleStore { - fn with_quota(self, quota_bytes: u64) -> Self { - ModuleStore::with_quota(self, quota_bytes) - } - - fn get(&self, key: &str) -> Result>, StorageError> { - ModuleStore::get(self, key) - } - - fn set(&self, key: &str, value: &[u8]) -> Result<(), StorageError> { - ModuleStore::set(self, key, value) - } - - fn delete(&self, key: &str) -> Result<(), StorageError> { - ModuleStore::delete(self, key) - } - - fn list_keys(&self, prefix: &str) -> Result, StorageError> { - ModuleStore::list_keys(self, prefix) - } - - fn contains(&self, key: &str) -> Result { - ModuleStore::contains(self, key) - } - - fn len(&self, key: &str) -> Result, StorageError> { - ModuleStore::len(self, key) - } - - fn count(&self, prefix: &str) -> Result { - ModuleStore::count(self, prefix) - } -} diff --git a/nexum/crates/nexum-runtime/src/host/error.rs b/nexum/crates/nexum-runtime/src/host/error.rs deleted file mode 100644 index 9ebd3c89..00000000 --- a/nexum/crates/nexum-runtime/src/host/error.rs +++ /dev/null @@ -1,176 +0,0 @@ -//! Small constructors and From conversions that build the WIT error -//! shapes: the chain interface's `chain-error` and the per-interface -//! `Fault` the store interfaces report. `fault_label` / `fault_message` -//! project a reported `Fault` into stable metric and log fields. - -use crate::bindings::nexum::host::chain::{ChainError, RpcError}; -use crate::bindings::nexum::host::types::{Fault, RateLimit}; -use crate::host::local_store_redb::StorageError; -use crate::host::provider_pool::ProviderError; -use crate::host::remote_store_bee::RemoteStoreError; - -/// `Denied` chain fault for a request the host policy refused to -/// forward, such as a method outside the permitted read surface. -pub(crate) fn chain_denied(detail: impl Into) -> ChainError { - ChainError::Fault(Fault::Denied(detail.into())) -} - -/// Stable snake_case label for a [`Fault`], used as a metric label and -/// structured-log `kind` field. Emitted from the single-source -/// `nexum_world::fault_labels` vocabulary the SDK `HostFault::label` -/// mirrors. -pub fn fault_label(fault: &Fault) -> &'static str { - use nexum_world::fault_labels as labels; - match fault { - Fault::Unsupported(_) => labels::UNSUPPORTED, - Fault::Unavailable(_) => labels::UNAVAILABLE, - Fault::Denied(_) => labels::DENIED, - Fault::RateLimited(_) => labels::RATE_LIMITED, - Fault::Timeout => labels::TIMEOUT, - Fault::InvalidInput(_) => labels::INVALID_INPUT, - Fault::Internal(_) => labels::INTERNAL, - } -} - -/// Human-readable detail carried by a [`Fault`], for the log `message` -/// field. The bindgen `Display` is the `{0:?}` debug form, so operator -/// logs render through this instead. The payload-bearing cases carry -/// their own detail; a rate limit keeps its `retry-after-ms` hint; -/// `timeout` renders a fixed phrase. -pub fn fault_message(fault: &Fault) -> std::borrow::Cow<'_, str> { - match fault { - Fault::Unsupported(m) - | Fault::Unavailable(m) - | Fault::Denied(m) - | Fault::InvalidInput(m) - | Fault::Internal(m) => std::borrow::Cow::Borrowed(m), - Fault::RateLimited(rl) => match rl.retry_after_ms { - Some(ms) => std::borrow::Cow::Owned(format!("rate limited, retry after {ms} ms")), - None => std::borrow::Cow::Borrowed("rate limited"), - }, - Fault::Timeout => std::borrow::Cow::Borrowed("timeout"), - } -} - -/// Project a [`ProviderError`] into the chain `chain-error`. -/// -/// A structured JSON-RPC `ErrorResp` (the node returned a `code`, -/// typically `-32000` for an `eth_call` revert) becomes a -/// [`ChainError::Rpc`] carrying that code and any decoded revert bytes, -/// so an SDK revert classifier can dispatch the revert -/// envelopes. Everything else - transport failures, an unknown chain, -/// bad params - becomes a shared [`Fault`]. -impl From for ChainError { - fn from(err: ProviderError) -> Self { - match err { - ProviderError::UnknownChain(id) => ChainError::Fault(Fault::Unsupported(format!( - "chain {id} has no engine.toml RPC entry" - ))), - ProviderError::Connect { chain, source } => ChainError::Fault(Fault::Unavailable( - format!("connect chain {chain}: {source}"), - )), - ProviderError::ConnectUrl { chain, source } => ChainError::Fault(Fault::Unavailable( - format!("connect chain {chain}: invalid URL: {source}"), - )), - ProviderError::InvalidParams { source, .. } => { - ChainError::Fault(Fault::InvalidInput(source.to_string())) - } - // The configured per-request timeout elapsed. The dedicated - // timeout fault lets a guest tell a slow node apart from a - // revert or an unreachable endpoint. - ProviderError::Timeout { .. } => ChainError::Fault(Fault::Timeout), - // Boot-time misconfiguration: never reaches a guest (the - // engine aborts at startup), but the match must stay total. - ProviderError::ZeroTimeout { .. } => { - ChainError::Fault(Fault::Internal("request_timeout_secs must not be 0".into())) - } - // A structured JSON-RPC error response: `code` is `Some`. - ProviderError::Rpc { - code: Some(code), - data, - ref source, - .. - } => ChainError::Rpc(RpcError { - // Preserve the node-reported JSON-RPC code. A code outside - // `i32` is a JSON-RPC spec violation, clamped to `-32603` - // Internal error. - code: i32::try_from(code).unwrap_or(-32603), - message: source.to_string(), - data, - }), - // Lets a guest tell "the node reverted" apart from "the node - // was unreachable / timed out". - ProviderError::Rpc { source, .. } => ChainError::Fault(transport_fault(&source)), - } - } -} - -/// Classify a transport-level RPC failure into a [`Fault`]. HTTP 429 -/// maps to `rate-limited`, 503 / a dropped backend to `unavailable`, -/// and a timed-out request to `timeout`; anything else defaults to -/// `unavailable`. -fn transport_fault(source: &alloy_transport::TransportError) -> Fault { - use alloy_transport::TransportErrorKind; - if let Some(kind) = source.as_transport_err() { - match kind { - TransportErrorKind::HttpError(http) if http.status == 429 => { - return Fault::RateLimited(RateLimit { - retry_after_ms: None, - }); - } - TransportErrorKind::HttpError(http) if http.status == 503 => { - return Fault::Unavailable(source.to_string()); - } - TransportErrorKind::BackendGone | TransportErrorKind::PubsubUnavailable => { - return Fault::Unavailable(source.to_string()); - } - _ => {} - } - } - let msg = source.to_string(); - let lower = msg.to_ascii_lowercase(); - if lower.contains("timed out") || lower.contains("timeout") { - Fault::Timeout - } else { - Fault::Unavailable(msg) - } -} - -/// The `local-store` interface is the failure domain, so the fault omits -/// the redundant subsystem tag. A quota breach is a policy `denied`; -/// anything else is an `internal` backend failure. -impl From for Fault { - fn from(err: StorageError) -> Self { - match err { - StorageError::QuotaExceeded { .. } => Fault::Denied(err.to_string()), - _ => Fault::Internal(err.to_string()), - } - } -} - -/// The `remote-store` interface is the failure domain. Missing -/// configuration is `unsupported` so a guest can probe-then-skip; a Bee -/// API failure classifies by HTTP status, and a lookup miss is -/// `unavailable` because Swarm retrievability is transient. -impl From for Fault { - fn from(err: RemoteStoreError) -> Self { - match &err { - RemoteStoreError::NotConfigured - | RemoteStoreError::NoPostageBatch - | RemoteStoreError::NoFeedKey => Fault::Unsupported(err.to_string()), - RemoteStoreError::Input { .. } => Fault::InvalidInput(err.to_string()), - RemoteStoreError::NotFound(_) => Fault::Unavailable(err.to_string()), - RemoteStoreError::MalformedFeed(_) => Fault::Internal(err.to_string()), - RemoteStoreError::Api(api) => match api { - bee::Error::Response { status: 429, .. } => Fault::RateLimited(RateLimit { - retry_after_ms: None, - }), - bee::Error::Response { - status: 402 | 403, .. - } => Fault::Denied(err.to_string()), - bee::Error::Transport(t) if t.is_timeout() => Fault::Timeout, - _ => Fault::Unavailable(err.to_string()), - }, - } - } -} diff --git a/nexum/crates/nexum-runtime/src/host/extension.rs b/nexum/crates/nexum-runtime/src/host/extension.rs deleted file mode 100644 index 9d097fb0..00000000 --- a/nexum/crates/nexum-runtime/src/host/extension.rs +++ /dev/null @@ -1,367 +0,0 @@ -//! The extension seam: what one extension contributes to the host - a -//! namespace, a capability namespace, a linker hook, an optional host -//! service, an optional provider kind, optional event sources, and -//! optional install predicates over the manifest sections it claims. -//! Assembled at the composition root and threaded into every module -//! linker. - -use std::any::Any; -use std::collections::{BTreeMap, BTreeSet}; -use std::pin::Pin; -use std::sync::Arc; - -use async_trait::async_trait; -use futures::Stream; -use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; -use wasmtime::Store; -use wasmtime::component::{Component, Linker}; - -use crate::bindings::nexum::host::types::Event; -use crate::engine_config::EngineConfig; -use crate::host::actor::Liveness; -use crate::host::component::RuntimeTypes; -use crate::host::state::HostState; -use crate::manifest::{ExtensionSections, NamespaceCaps}; - -/// One runtime extension. A module that imports an extension interface -/// boots only if the linker entry AND the capability namespace are both -/// registered before instantiation. -pub trait Extension: Send + Sync + 'static { - /// Namespace this extension owns; keys its service in [`HostServices`]. - fn namespace(&self) -> &'static str; - - /// Capability namespace merged into enforcement so a module importing - /// the extension's interfaces still validates. - fn capabilities(&self) -> NamespaceCaps; - - /// Adds the extension's imports to a worker linker. Runs after the - /// core interfaces and before instantiation. Takes only `&mut Linker`, - /// so the seam stays compatible with a future per-extension router - /// that serializes access to the non-`Sync` wasmtime `Store`. - fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; - - /// Host service this extension owns, published under its namespace on - /// [`HostServices`]. - fn service(&self) -> Option> { - None - } - - /// Provider kind this extension installs. - fn provider(&self) -> Option>> { - None - } - - /// Manifest section names this extension claims. A non-core section - /// no wired extension claims is refused at boot. - fn manifest_sections(&self) -> &'static [&'static str] { - &[] - } - - /// Admit one provider at install, over its opaque manifest sections. - /// Runs before compilation; an `Err` refuses the install fail-fast. - fn admit_provider(&self, provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { - let _ = (provider, sections); - Ok(()) - } - - /// Admit one worker at install, over its own and the loaded - /// providers' opaque manifest sections. Runs before compilation; an - /// `Err` refuses the install fail-fast. - fn admit_worker( - &self, - worker: &str, - sections: &ExtensionSections, - providers: &[ProviderManifest], - ) -> anyhow::Result<()> { - let _ = (worker, sections, providers); - Ok(()) - } - - /// Manifest subscription kinds this extension's event sources emit. - /// A `[[subscription]]` entry of any other non-core kind is refused - /// at boot. - fn subscriptions(&self) -> &'static [&'static str] { - &[] - } - - /// Open the extension's event sources once the engine is booted. The - /// event loop merges the returned streams and dispatches each item to - /// the modules its kind and attributes admit. - fn events(&self, sources: &mut EventSources<'_>) -> anyhow::Result> { - let _ = sources; - Ok(Vec::new()) - } -} - -/// One extension-observed event: dispatched to every module holding a -/// `[[subscription]]` of `kind` whose filters all match `attrs`. -pub struct ExtensionEvent { - /// Manifest subscription kind that routes this event. - pub kind: &'static str, - /// Routing attributes a subscription's filters match against. - pub attrs: Vec<(&'static str, String)>, - /// The host event delivered to each matching module. - pub event: Event, -} - -/// A stream of extension events the event loop merges and drives. -pub type ExtensionEventStream = Pin + Send>>; - -/// Ambient launch inputs for [`Extension::events`]: the loaded config, the -/// booted service map, the subscription kinds at least one module declares, -/// and the spawn surface for source tasks. -pub struct EventSources<'a> { - /// The loaded engine config. - pub config: &'a EngineConfig, - /// Extension-owned services, as booted. - pub services: &'a HostServices, - /// Extension subscription kinds declared by at least one module. - pub subscribed: &'a BTreeSet, - executor: &'a TaskExecutor, - tasks: &'a mut TaskSet, -} - -impl<'a> EventSources<'a> { - /// Bundle the launch inputs for one [`Extension::events`] pass. - pub fn new( - config: &'a EngineConfig, - services: &'a HostServices, - subscribed: &'a BTreeSet, - executor: &'a TaskExecutor, - tasks: &'a mut TaskSet, - ) -> Self { - Self { - config, - services, - subscribed, - executor, - tasks, - } - } - - /// Spawn one event-source task through the engine's executor. The task - /// must end when its stream's receiver drops; the engine drains it on - /// shutdown. - pub fn spawn(&mut self, task: impl Future + Send + 'static) { - self.tasks.push(self.executor.spawn(async move { - task.await; - TaskExit::ReceiverGone - })); - } -} - -/// A type-erased host service an extension owns. Held per namespace on -/// `HostState::services` and downcast at the call site. Kept synchronous -/// so it stays `dyn`-compatible. -pub trait HostService: Any + Send + Sync + 'static {} - -/// A provider component kind: the host holds an instance behind the owning -/// extension's serialized service; others call it. `async_trait` carries -/// the one cold `dyn` boot path until `async_fn_in_dyn_trait` stabilizes. -#[async_trait] -pub trait ProviderKind: Send + Sync + 'static { - /// Manifest kind this provider answers for. - fn kind(&self) -> &'static str; - - /// Adds the provider's imports to a provider linker. - fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; - - /// Instantiate one provider and install it behind the owning service. - /// [`Installed::Dead`] reports a failed guest `init`; an `Err` is a - /// boot error. - async fn install( - &self, - instance: ProviderInstance<'_, T>, - service: &Arc, - ) -> anyhow::Result; -} - -/// One provider instance ready to install: the compiled component, the -/// linker the kind's [`ProviderKind::link`] populated, the supervised -/// store, the manifest `[config]` and extension sections, and the -/// per-call fuel budget. -pub struct ProviderInstance<'a, T: RuntimeTypes> { - /// Compiled provider component. - pub component: &'a Component, - /// Linker carrying the kind's imports plus the WASI base. - pub linker: &'a Linker>, - /// Store the instance runs in; the kind takes ownership. - pub store: Store>, - /// Manifest `[config]` handed to the guest `init`. - pub config: Vec<(String, String)>, - /// The provider's extension-owned manifest sections, so a kind can - /// hold the instance to its manifest claims at install. - pub sections: &'a ExtensionSections, - /// Fuel budget applied before each routed guest call. - pub fuel_per_call: u64, - /// Shared liveness the installed instance reports traps on and the - /// supervisor's restart sweep reads. - pub liveness: Liveness, -} - -/// One loaded provider as [`Extension::admit_worker`] sees it: its -/// namespace, registered kind, and opaque manifest sections. Manifest -/// data only, so the predicate is static and liveness-independent. -#[derive(Clone, Debug)] -pub struct ProviderManifest { - /// The provider's namespace: its manifest name. - pub name: String, - /// Registered kind spelling. - pub kind: &'static str, - /// The provider's extension-owned manifest sections. - pub sections: ExtensionSections, -} - -/// Outcome of one provider install. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Installed { - /// `init` succeeded; the instance is installed and routable. - Live, - /// `init` returned a fault; the instance is loaded but not routable. - Dead, -} - -/// Downcast a type-erased service to `S`. `None` when the type differs. -pub fn downcast_service(service: &Arc) -> Option> { - let service = Arc::clone(service); - let erased: Arc = service; - erased.downcast().ok() -} - -/// Immutable per-namespace service map: each extension's [`HostService`] -/// under its [`Extension::namespace`], built once at boot and shared by -/// every module store. -#[derive(Clone, Default)] -pub struct HostServices(Arc>>); - -impl std::fmt::Debug for HostServices { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_set().entries(self.0.keys()).finish() - } -} - -impl HostServices { - /// Collect each extension's service under its namespace. Refuses a - /// duplicate namespace. - pub fn from_extensions( - extensions: &[Arc>], - ) -> anyhow::Result { - let mut map = BTreeMap::new(); - for ext in extensions { - let Some(service) = ext.service() else { - continue; - }; - let namespace = ext.namespace(); - if map.insert(namespace, service).is_some() { - anyhow::bail!("duplicate extension service namespace {namespace}"); - } - } - Ok(Self(Arc::new(map))) - } - - /// The service under `namespace`, downcast to its concrete type. - /// `None` when the namespace is absent or the type does not match. - pub fn get(&self, namespace: &str) -> Option> { - downcast_service(self.0.get(namespace)?) - } - - /// The raw type-erased service under `namespace`. - pub fn raw(&self, namespace: &str) -> Option<&Arc> { - self.0.get(namespace) - } - - /// Publish `service` under `namespace`, refusing a duplicate. The boot - /// path seeds a service no extension registers yet. - pub fn with_service( - self, - namespace: &'static str, - service: Arc, - ) -> anyhow::Result { - let mut map = Arc::unwrap_or_clone(self.0); - if map.insert(namespace, service).is_some() { - anyhow::bail!("duplicate extension service namespace {namespace}"); - } - Ok(Self(Arc::new(map))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::supervisor::TestTypes; - - struct Registry(u64); - impl HostService for Registry {} - - struct Clockwork; - impl HostService for Clockwork {} - - struct ServiceExt { - namespace: &'static str, - service: Option>, - } - - impl Extension for ServiceExt { - fn namespace(&self) -> &'static str { - self.namespace - } - fn capabilities(&self) -> NamespaceCaps { - NamespaceCaps { - prefix: "test:ext/", - ifaces: &[], - } - } - fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { - Ok(()) - } - fn service(&self) -> Option> { - self.service.as_ref().map(Arc::clone) - } - } - - fn ext( - namespace: &'static str, - service: Arc, - ) -> Arc> { - Arc::new(ServiceExt { - namespace, - service: Some(service), - }) - } - - /// A registered service comes back under its namespace, downcast to - /// its concrete type; a wrong type or an absent namespace is `None`. - #[test] - fn get_downcasts_by_namespace() { - let services = - HostServices::from_extensions(&[ext("acme", Arc::new(Registry(7)))]).expect("build"); - - let registry = services.get::("acme").expect("registered"); - assert_eq!(registry.0, 7); - assert!(services.get::("acme").is_none()); - assert!(services.get::("absent").is_none()); - assert!(services.raw("acme").is_some()); - } - - /// A serviceless extension contributes nothing to the map. - #[test] - fn serviceless_extension_is_absent() { - let serviceless: Arc> = Arc::new(ServiceExt { - namespace: "quiet", - service: None, - }); - let services = HostServices::from_extensions(&[serviceless]).expect("build"); - assert!(services.raw("quiet").is_none()); - } - - /// Two services under one namespace refuse to build. - #[test] - fn duplicate_namespace_is_refused() { - let err = HostServices::from_extensions(&[ - ext("acme", Arc::new(Registry(1))), - ext("acme", Arc::new(Clockwork)), - ]) - .expect_err("duplicate namespace"); - assert!(err.to_string().contains("acme"), "{err}"); - } -} diff --git a/nexum/crates/nexum-runtime/src/host/http.rs b/nexum/crates/nexum-runtime/src/host/http.rs deleted file mode 100644 index 2624457e..00000000 --- a/nexum/crates/nexum-runtime/src/host/http.rs +++ /dev/null @@ -1,766 +0,0 @@ -//! wasi:http outgoing gate: every guest request funnels through -//! [`HttpGate::send_request`], which enforces the per-module -//! `[capabilities.http].allow` list, clamps the guest-settable timeouts -//! to the engine's `[limits.http]` maxima, and bounds the exchange with -//! a total deadline plus a response-body cap before handing the request -//! to the backend. The host does not follow redirects, so each hop is a -//! fresh guest request that re-enters this gate. - -use std::future::Future; -use std::pin::Pin; -use std::task::{Context, Poll}; - -use bytes::Bytes; -use http_body::{Body, Frame, SizeHint}; -use http_body_util::BodyExt; -use tracing::warn; -use wasmtime_wasi_http::p2::bindings::http::types::ErrorCode; -use wasmtime_wasi_http::p2::body::{HyperIncomingBody, HyperOutgoingBody}; -use wasmtime_wasi_http::p2::types::{HostFutureIncomingResponse, OutgoingRequestConfig}; -use wasmtime_wasi_http::p2::{ - HttpResult, WasiHttpCtxView, WasiHttpHooks, WasiHttpView, default_send_request_handler, -}; - -use super::component::RuntimeTypes; -use super::state::HostState; -use crate::engine_config::OutboundHttpLimits; -use crate::manifest::host_allowed; - -/// Per-module outbound HTTP policy: the manifest allowlist, the -/// engine's outbound limits, and the module name for log attribution. -pub struct HttpGate { - module: String, - allowlist: Vec, - limits: OutboundHttpLimits, -} - -impl HttpGate { - /// Gate for `module` with its `[capabilities.http].allow` entries - /// and the engine's `[limits.http]` outbound limits. - pub fn new( - module: impl Into, - allowlist: Vec, - limits: OutboundHttpLimits, - ) -> Self { - Self { - module: module.into(), - allowlist, - limits, - } - } -} - -impl WasiHttpHooks for HttpGate { - fn send_request( - &mut self, - request: http::Request, - config: OutgoingRequestConfig, - ) -> HttpResult { - if let Err(code) = admit(request.uri(), &self.allowlist) { - // Log the host only: paths and query strings are - // guest-supplied and may carry credentials. - warn!( - module = %self.module, - host = request.uri().host().unwrap_or(""), - "[http] outbound request denied by allowlist", - ); - return Err(code.into()); - } - Ok(send_with_limits( - request, - clamp(config, &self.limits), - self.limits, - )) - } -} - -/// Clamp the guest-settable timeouts to the engine maxima. Guest values -/// above a maximum are lowered, never rejected. The linked handler -/// substitutes its own fixed default for unset request-options before -/// this hook runs, so an unset timeout also clamps down: each maximum -/// doubles as the effective default. -fn clamp(mut config: OutgoingRequestConfig, limits: &OutboundHttpLimits) -> OutgoingRequestConfig { - config.connect_timeout = config.connect_timeout.min(limits.connect_timeout_max); - config.first_byte_timeout = config.first_byte_timeout.min(limits.first_byte_timeout_max); - config.between_bytes_timeout = config - .between_bytes_timeout - .min(limits.between_bytes_timeout_max); - config -} - -/// Dispatch through the default backend, bounded by the engine's total -/// deadline and response-body cap. The `timeout_at` covers connect, -/// TLS, request write, and response headers; the same deadline instant -/// is armed inside the [`CappedBody`] wrapping the response body, so a -/// consuming guest gets `ConnectionReadTimeout` mid-body. The deadline -/// is unconditional: the connection driver is raced against it in its -/// own task and aborted when it fires, so a guest that parks the -/// response without ever reading the body cannot hold the socket past -/// the deadline. -fn send_with_limits( - request: http::Request, - config: OutgoingRequestConfig, - limits: OutboundHttpLimits, -) -> HostFutureIncomingResponse { - let handle = wasmtime_wasi::runtime::spawn(async move { - let deadline = tokio::time::Instant::now() + limits.total_deadline; - let sent = - tokio::time::timeout_at(deadline, default_send_request_handler(request, config)).await; - let result = match sent { - Ok(Ok(mut incoming)) => { - // Dropping the inner worker handle aborts the hyper - // connection driver, closing the socket at the - // deadline regardless of guest polling. A guest drop - // of the response still cascades: it drops this - // wrapper handle, which aborts the race, which drops - // the worker. - incoming.worker = incoming.worker.map(|worker| { - wasmtime_wasi::runtime::spawn(async move { - let _ = tokio::time::timeout_at(deadline, worker).await; - }) - }); - incoming.resp = incoming.resp.map(|body| { - CappedBody::new(body, limits.response_body_max_bytes, deadline).boxed_unsync() - }); - Ok(incoming) - } - Ok(Err(code)) => Err(code), - Err(_) => Err(ErrorCode::ConnectionTimeout), - }; - Ok(result) - }); - HostFutureIncomingResponse::pending(handle) -} - -/// Response-body wrapper enforcing the size cap and the total deadline -/// while the guest streams the body. -/// -/// Exceeding the cap yields `HttpResponseBodySize(cap)`; the deadline -/// firing mid-body yields `ConnectionReadTimeout`, the code the backend -/// uses for its own read-phase timeouts. -struct CappedBody { - inner: HyperIncomingBody, - /// Bytes still admissible under the cap. - remaining: u64, - /// Configured cap, echoed in the error payload. - cap: u64, - /// Sleep armed at the request's total deadline. - deadline: Pin>, -} - -impl CappedBody { - fn new(inner: HyperIncomingBody, cap: u64, deadline: tokio::time::Instant) -> Self { - Self { - inner, - remaining: cap, - cap, - deadline: Box::pin(tokio::time::sleep_until(deadline)), - } - } -} - -impl Body for CappedBody { - type Data = Bytes; - type Error = ErrorCode; - - fn poll_frame( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll, ErrorCode>>> { - let me = Pin::into_inner(self); - if let Poll::Ready(()) = me.deadline.as_mut().poll(cx) { - return Poll::Ready(Some(Err(ErrorCode::ConnectionReadTimeout))); - } - match Pin::new(&mut me.inner).poll_frame(cx) { - Poll::Ready(Some(Ok(frame))) => { - if let Some(data) = frame.data_ref() { - let len = data.len() as u64; - if len > me.remaining { - return Poll::Ready(Some(Err(ErrorCode::HttpResponseBodySize(Some( - me.cap, - ))))); - } - me.remaining -= len; - } - Poll::Ready(Some(Ok(frame))) - } - other => other, - } - } - - fn is_end_stream(&self) -> bool { - self.inner.is_end_stream() - } - - fn size_hint(&self) -> SizeHint { - self.inner.size_hint() - } -} - -/// Allowlist decision for one outgoing request URI. -/// -/// Matching is host-only: ports and scheme are ignored (the handler -/// admits only http/https before this point), and comparison is -/// case-insensitive with exact or `*.suffix` wildcard semantics per -/// [`host_allowed`]. IPv6 literals keep their brackets, so allowlist -/// entries use the bracketed form. -/// -/// The check is name-based and precedes resolution: the connection -/// re-resolves the name, so there is no IP pinning and no defence -/// against DNS rebinding or names resolving to internal addresses. -/// The operator vouches for the names they allowlist. -fn admit(uri: &http::Uri, allowlist: &[String]) -> Result<(), ErrorCode> { - let Some(host) = uri.host() else { - return Err(ErrorCode::HttpRequestUriInvalid); - }; - if host_allowed(host, allowlist) { - Ok(()) - } else { - Err(ErrorCode::HttpRequestDenied) - } -} - -impl WasiHttpView for HostState { - fn http(&mut self) -> WasiHttpCtxView<'_> { - WasiHttpCtxView { - ctx: &mut self.http_ctx, - table: &mut self.table, - hooks: &mut self.http_gate, - } - } -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use http_body_util::{Empty, Full}; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use wasmtime_wasi_http::p2::types::IncomingResponse; - - use super::*; - - fn uri(s: &str) -> http::Uri { - s.parse().expect("test URI parses") - } - - fn allow(entries: &[&str]) -> Vec { - entries.iter().map(|s| s.to_string()).collect() - } - - /// Generous limits so a test trips only the one it tightens. - fn limits() -> OutboundHttpLimits { - OutboundHttpLimits { - connect_timeout_max: Duration::from_secs(10), - first_byte_timeout_max: Duration::from_secs(10), - between_bytes_timeout_max: Duration::from_secs(10), - total_deadline: Duration::from_secs(10), - response_body_max_bytes: 1 << 20, - } - } - - fn denied(u: &str, entries: &[&str]) -> bool { - matches!( - admit(&uri(u), &allow(entries)), - Err(ErrorCode::HttpRequestDenied) - ) - } - - #[test] - fn exact_host_passes() { - assert!( - admit( - &uri("https://api.acme.example/v1/x"), - &allow(&["api.acme.example"]) - ) - .is_ok() - ); - assert!( - admit( - &uri("http://api.acme.example/"), - &allow(&["api.acme.example"]) - ) - .is_ok() - ); - } - - #[test] - fn off_list_host_is_denied() { - assert!(denied("https://evil.example/", &["api.acme.example"])); - assert!(denied( - "https://api.acme.example.evil.example/", - &["api.acme.example"] - )); - } - - #[test] - fn empty_allowlist_denies_everything() { - assert!(denied("https://api.acme.example/", &[])); - assert!(denied("http://127.0.0.1/", &[])); - } - - #[test] - fn matching_is_case_insensitive() { - assert!( - admit( - &uri("https://API.ACME.EXAMPLE/"), - &allow(&["api.acme.example"]) - ) - .is_ok() - ); - assert!( - admit( - &uri("https://api.acme.example/"), - &allow(&["API.ACME.EXAMPLE"]) - ) - .is_ok() - ); - } - - #[test] - fn wildcard_matches_subdomains_but_not_the_suffix_itself() { - let list = allow(&["*.discord.com"]); - assert!(admit(&uri("https://gateway.discord.com/"), &list).is_ok()); - assert!(admit(&uri("https://a.b.discord.com/"), &list).is_ok()); - assert!(denied("https://discord.com/", &["*.discord.com"])); - assert!(denied("https://notdiscord.com/", &["*.discord.com"])); - } - - #[test] - fn exact_entry_does_not_match_subdomains() { - assert!(denied( - "https://sub.api.acme.example/", - &["api.acme.example"] - )); - } - - #[test] - fn ipv4_literal_matches_only_when_listed() { - assert!(admit(&uri("http://127.0.0.1/x"), &allow(&["127.0.0.1"])).is_ok()); - assert!(denied("http://127.0.0.2/x", &["127.0.0.1"])); - // A listed name never admits an IP literal for that name. - assert!(denied("http://93.184.216.34/", &["example.com"])); - } - - #[test] - fn ipv6_literal_uses_bracketed_form() { - assert!(admit(&uri("http://[::1]:8080/x"), &allow(&["[::1]"])).is_ok()); - assert!(denied("http://[::1]/x", &["::1"])); - assert!(denied("http://[2001:db8::1]/", &["[::1]"])); - } - - #[test] - fn ports_do_not_affect_matching() { - let list = allow(&["api.acme.example"]); - assert!(admit(&uri("https://api.acme.example:8443/v1"), &list).is_ok()); - assert!(admit(&uri("http://api.acme.example:80/v1"), &list).is_ok()); - assert!(denied("https://evil.example:443/", &["api.acme.example"])); - // A port spelled in the allowlist entry never matches: entries - // are hosts, not authorities. - assert!(denied( - "https://api.acme.example:8443/", - &["api.acme.example:8443"] - )); - } - - // ----------------- SSRF-style bypass regressions (#57) --------- - // - // `http::Uri` resolves the authority per RFC 3986 before `admit` - // ever sees a host string, so these are regression guards on the - // parser's behaviour, not on `admit` itself. Each case names the - // trick and asserts the real target host - never the attacker's - // decoy - is what `host_allowed` sees. - - #[test] - fn userinfo_prefix_does_not_leak_a_different_host_into_the_allowlist() { - // `http://allowed.com@evil.com/` - "allowed.com" is userinfo, - // "evil.com" is the host. A parser that mistook the text before - // `@` for the host would wrongly admit this against an - // `allowed.com` allowlist entry. - assert!(denied("http://allowed.com@evil.com/", &["allowed.com"])); - assert_eq!(uri("http://allowed.com@evil.com/").host(), Some("evil.com")); - } - - #[test] - fn userinfo_matching_an_allowlist_entry_grants_nothing() { - // `http://evil.com@allowed.com/` - the real host is - // "allowed.com" and is correctly admitted; "evil.com" sitting in - // userinfo must never itself satisfy an allowlist entry. - assert!( - admit( - &uri("http://evil.com@allowed.com/"), - &allow(&["allowed.com"]) - ) - .is_ok() - ); - assert!(denied("http://evil.com@allowed.com/", &["evil.com"])); - } - - #[test] - fn backslash_in_the_authority_fails_to_parse_rather_than_bypassing() { - // Backslash-as-slash confusion is a known SSRF trick against - // parsers that normalise `\` to `/`. `http::Uri` does neither: - // a backslash anywhere in the authority is rejected at parse - // time. Checked against both entry points a backslash-bearing - // authority could reach this gate through: the full-URI parser - // (what this module's `uri()` test helper uses) and - // `http::uri::Authority`, the type `wasmtime-wasi-http` builds - // directly from the guest's `authority` string - // (`Uri::builder().authority(...)`) - the seam a wasm guest - // actually exercises. Both reject identically, so a request - // built from one of these strings never reaches `admit`. - for bad in [ - "evil.com\\allowed.com", - "evil.com\\@allowed.com", - "allowed.com\\.evil.com", - ] { - assert!( - http::uri::Authority::try_from(bad).is_err(), - "expected Authority::try_from to reject {bad:?}" - ); - assert!( - format!("http://{bad}/").parse::().is_err(), - "expected a full-URI parse error for {bad:?}" - ); - } - } - - #[test] - fn numeric_ip_encodings_never_normalise_to_the_dotted_form_an_allowlist_names() { - // `host_allowed` is an exact/wildcard string match with no IP - // normalisation (see `admit`'s doc comment). Decimal, octal, and - // hex encodings of 127.0.0.1 are valid `http::Uri` hosts but are - // different strings from "127.0.0.1", so none of them satisfy an - // allowlist entry naming the dotted-quad form - locking in that - // a future refactor doesn't "helpfully" start normalising these - // and turn a same-string match into an equivalent-address match. - for evil in [ - "2130706433", - "0177.0.0.1", - "0x7f.0.0.1", - "[::ffff:127.0.0.1]", - ] { - assert!( - denied(&format!("http://{evil}/"), &["127.0.0.1"]), - "{evil:?} must not satisfy a 127.0.0.1 allowlist entry" - ); - } - } - - #[test] - fn fragment_and_query_after_the_host_do_not_influence_the_host_check() { - // Historical bug (see issue #57): a naive host-extractor could - // be fooled by a `/`-bearing query string or fragment appended - // after the real host. `http::Uri::host` is unaffected by - // either - the decoy text never becomes part of the host. - assert!( - admit( - &uri("http://allowed.com#@evil.com/"), - &allow(&["allowed.com"]) - ) - .is_ok() - ); - assert!( - admit( - &uri("http://allowed.com?@evil.com/"), - &allow(&["allowed.com"]) - ) - .is_ok() - ); - assert_eq!( - uri("http://allowed.com#@evil.com/").host(), - Some("allowed.com") - ); - assert_eq!( - uri("http://allowed.com?@evil.com/").host(), - Some("allowed.com") - ); - } - - #[test] - fn both_schemes_are_gated_identically() { - for scheme in ["http", "https"] { - assert!( - admit( - &uri(&format!("{scheme}://api.acme.example/")), - &allow(&["api.acme.example"]) - ) - .is_ok() - ); - assert!(denied( - &format!("{scheme}://evil.example/"), - &["api.acme.example"] - )); - } - } - - #[test] - fn uri_without_authority_is_invalid_not_denied() { - assert!(matches!( - admit(&uri("/relative/path"), &allow(&["api.acme.example"])), - Err(ErrorCode::HttpRequestUriInvalid) - )); - } - - fn request(u: &str) -> http::Request { - let body = Empty::::new() - .map_err(|_| unreachable!("infallible body error")) - .boxed_unsync(); - http::Request::builder() - .method(http::Method::GET) - .uri(u) - .body(body) - .expect("test request builds") - } - - fn config() -> OutgoingRequestConfig { - OutgoingRequestConfig { - use_tls: false, - connect_timeout: Duration::from_secs(1), - first_byte_timeout: Duration::from_secs(1), - between_bytes_timeout: Duration::from_secs(1), - } - } - - #[tokio::test] - async fn send_request_denies_off_list_host_with_http_request_denied() { - let mut gate = HttpGate::new("test-module", allow(&["api.acme.example"]), limits()); - let Err(err) = gate.send_request(request("http://evil.example/x"), config()) else { - panic!("off-list host must be denied"); - }; - assert!(matches!( - err.downcast_ref(), - Some(ErrorCode::HttpRequestDenied) - )); - } - - #[tokio::test] - async fn send_request_admits_listed_host() { - // Nothing listens on 127.0.0.1:1; admission only hands the - // request to the backend, so the returned future is pending. - let mut gate = HttpGate::new("test-module", allow(&["127.0.0.1"]), limits()); - assert!( - gate.send_request(request("http://127.0.0.1:1/x"), config()) - .is_ok() - ); - } - - // ----------------- timeout clamping ---------------------------- - - fn config_with(timeout: Duration) -> OutgoingRequestConfig { - OutgoingRequestConfig { - use_tls: false, - connect_timeout: timeout, - first_byte_timeout: timeout, - between_bytes_timeout: timeout, - } - } - - #[test] - fn clamp_lowers_each_timeout_above_its_maximum() { - // 600 s is also what the linked handler substitutes for unset - // request-options, so this doubles as the unset case: unset - // resolves to the engine maximum. - let clamped = clamp(config_with(Duration::from_secs(600)), &limits()); - assert_eq!(clamped.connect_timeout, Duration::from_secs(10)); - assert_eq!(clamped.first_byte_timeout, Duration::from_secs(10)); - assert_eq!(clamped.between_bytes_timeout, Duration::from_secs(10)); - } - - #[test] - fn clamp_keeps_timeouts_below_the_maximum() { - let clamped = clamp(config_with(Duration::from_secs(1)), &limits()); - assert_eq!(clamped.connect_timeout, Duration::from_secs(1)); - assert_eq!(clamped.first_byte_timeout, Duration::from_secs(1)); - assert_eq!(clamped.between_bytes_timeout, Duration::from_secs(1)); - } - - #[test] - fn clamp_keeps_timeouts_at_the_maximum() { - let clamped = clamp(config_with(Duration::from_secs(10)), &limits()); - assert_eq!(clamped.connect_timeout, Duration::from_secs(10)); - assert_eq!(clamped.first_byte_timeout, Duration::from_secs(10)); - assert_eq!(clamped.between_bytes_timeout, Duration::from_secs(10)); - } - - #[test] - fn clamp_applies_each_maximum_independently() { - let mut l = limits(); - l.first_byte_timeout_max = Duration::from_millis(50); - let clamped = clamp(config_with(Duration::from_secs(5)), &l); - assert_eq!(clamped.connect_timeout, Duration::from_secs(5)); - assert_eq!(clamped.first_byte_timeout, Duration::from_millis(50)); - assert_eq!(clamped.between_bytes_timeout, Duration::from_secs(5)); - } - - // ----------------- deadline + body cap ------------------------- - - /// A detached executor for test-server tasks. - fn test_executor() -> nexum_tasks::TaskExecutor { - nexum_tasks::TaskManager::new().executor() - } - - /// One-connection loopback server: reads the request, writes - /// `response`, then either closes or holds the socket open so the - /// client sees a stall instead of EOF. Panic-free: any IO failure - /// just ends the task and the client side times out. - async fn spawn_server(response: Vec, hold_open: bool) -> std::net::SocketAddr { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind loopback listener"); - let addr = listener.local_addr().expect("listener has a local addr"); - test_executor().spawn(async move { - let Ok((mut sock, _)) = listener.accept().await else { - return; - }; - let mut buf = [0u8; 1024]; - let _ = sock.read(&mut buf).await; - let _ = sock.write_all(&response).await; - let _ = sock.flush().await; - if hold_open { - tokio::time::sleep(Duration::from_secs(30)).await; - } - }); - addr - } - - async fn resolve(pending: HostFutureIncomingResponse) -> Result { - match pending { - HostFutureIncomingResponse::Pending(handle) => { - handle.await.expect("send task never traps") - } - _ => panic!("send_request returns a pending response"), - } - } - - async fn send_to( - addr: std::net::SocketAddr, - limits: OutboundHttpLimits, - ) -> Result { - let mut gate = HttpGate::new("test-module", allow(&["127.0.0.1"]), limits); - let pending = gate - .send_request(request(&format!("http://{addr}/x")), config_10s()) - .expect("listed host admitted"); - resolve(pending).await - } - - fn config_10s() -> OutgoingRequestConfig { - config_with(Duration::from_secs(10)) - } - - #[tokio::test] - async fn request_under_all_limits_succeeds() { - let addr = spawn_server( - b"HTTP/1.1 200 OK\r\ncontent-length: 5\r\n\r\nhello".to_vec(), - false, - ) - .await; - let incoming = send_to(addr, limits()).await.expect("response arrives"); - assert_eq!(incoming.resp.status(), 200); - let body = incoming - .resp - .into_body() - .collect() - .await - .expect("body is under the cap"); - assert_eq!(body.to_bytes().as_ref(), b"hello"); - } - - #[tokio::test] - async fn total_deadline_fires_on_a_stalled_server() { - // Accepts, never responds; every per-phase maximum is 10 s, so - // only the total deadline can end the wait. - let addr = spawn_server(Vec::new(), true).await; - let mut l = limits(); - l.total_deadline = Duration::from_millis(250); - let err = send_to(addr, l).await.expect_err("deadline fires"); - assert!(matches!(err, ErrorCode::ConnectionTimeout)); - } - - #[tokio::test] - async fn total_deadline_fires_while_the_body_stalls() { - // Headers plus 16 of 100000 promised body bytes, then a stall: - // the deadline covers body streaming via the CappedBody wrapper. - let mut response = b"HTTP/1.1 200 OK\r\ncontent-length: 100000\r\n\r\n".to_vec(); - response.extend_from_slice(&[b'x'; 16]); - let addr = spawn_server(response, true).await; - let mut l = limits(); - l.total_deadline = Duration::from_millis(300); - let incoming = send_to(addr, l).await.expect("headers arrive in time"); - let err = incoming - .resp - .into_body() - .collect() - .await - .expect_err("deadline fires mid-body"); - assert!(matches!(err, ErrorCode::ConnectionReadTimeout)); - } - - #[tokio::test] - async fn deadline_tears_down_a_parked_unread_response() { - // The guest obtains the response and never polls the body, so - // the body-side deadline never runs; the raced connection - // driver alone must close the socket, observable server-side - // as EOF on a blocking read. - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind loopback listener"); - let addr = listener.local_addr().expect("listener has a local addr"); - let (tx, rx) = tokio::sync::oneshot::channel::<()>(); - test_executor().spawn(async move { - let Ok((mut sock, _)) = listener.accept().await else { - return; - }; - let mut buf = [0u8; 1024]; - let _ = sock.read(&mut buf).await; - let _ = sock - .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 100000\r\n\r\n") - .await; - let _ = sock.flush().await; - loop { - match sock.read(&mut buf).await { - Ok(0) | Err(_) => break, - Ok(_) => {} - } - } - let _ = tx.send(()); - }); - let mut l = limits(); - l.total_deadline = Duration::from_millis(300); - let parked = send_to(addr, l).await.expect("headers arrive in time"); - let closed = tokio::time::timeout(Duration::from_secs(5), rx).await; - assert!( - closed.is_ok(), - "server must see the close at the deadline while the response is parked" - ); - drop(parked); - } - - #[tokio::test] - async fn oversized_response_body_fails_with_the_cap_in_the_error() { - let mut response = b"HTTP/1.1 200 OK\r\ncontent-length: 4096\r\n\r\n".to_vec(); - response.extend_from_slice(&[b'x'; 4096]); - let addr = spawn_server(response, false).await; - let mut l = limits(); - l.response_body_max_bytes = 1024; - let incoming = send_to(addr, l).await.expect("headers arrive"); - let err = incoming - .resp - .into_body() - .collect() - .await - .expect_err("body exceeds the cap"); - assert!(matches!(err, ErrorCode::HttpResponseBodySize(Some(1024)))); - } - - #[tokio::test] - async fn body_at_exactly_the_cap_passes() { - let inner: HyperIncomingBody = Full::new(Bytes::from(vec![b'a'; 64])) - .map_err(|_| unreachable!("infallible body error")) - .boxed_unsync(); - let deadline = tokio::time::Instant::now() + Duration::from_secs(5); - let body = CappedBody::new(inner, 64, deadline); - let collected = body.collect().await.expect("exact-cap body passes"); - assert_eq!(collected.to_bytes().len(), 64); - } -} diff --git a/nexum/crates/nexum-runtime/src/host/impls/chain.rs b/nexum/crates/nexum-runtime/src/host/impls/chain.rs deleted file mode 100644 index f9afad2a..00000000 --- a/nexum/crates/nexum-runtime/src/host/impls/chain.rs +++ /dev/null @@ -1,380 +0,0 @@ -//! `nexum:host/chain`: raw JSON-RPC dispatch over alloy. - -use std::time::Instant; - -use alloy_chains::Chain; - -use crate::bindings::nexum; -use crate::bindings::nexum::host::chain::ChainError; -use crate::host::component::{ChainMethod, ChainProvider, RuntimeTypes}; -use crate::host::error::chain_denied; -use crate::host::state::HostState; - -/// Resolve a guest method string into the permitted read surface. -/// -/// Signing-adjacent and mutating methods have no [`ChainMethod`] -/// variant, so they are rejected here structurally rather than by an -/// ad-hoc name check; the result is a `Denied` chain fault. Every entry -/// of a batch request routes through this same resolver. -fn resolve_method(method: &str) -> Result { - ChainMethod::try_from(method).map_err(|_| { - chain_denied(format!( - "method `{method}` is not in the permitted read-only surface" - )) - }) -} - -/// Return an error if `body` exceeds `cap` bytes. The check is applied -/// host-side before the response is copied into the guest, so an -/// oversized node response cannot saturate the guest heap. -fn check_response_cap( - body: &str, - cap: usize, - chain_id: u64, - method: &str, -) -> Result<(), ChainError> { - if body.len() > cap { - tracing::warn!( - chain_id, - method, - body_bytes = body.len(), - cap_bytes = cap, - "chain response exceeds size cap - rejecting before guest copy" - ); - metrics::counter!( - "shepherd_chain_response_capped_total", - "chain_id" => chain_id.to_string(), - "method" => method.to_owned(), - ) - .increment(1); - return Err(ChainError::Fault( - crate::bindings::nexum::host::types::Fault::InvalidInput(format!( - "chain response ({} bytes) exceeds the configured cap ({} bytes)", - body.len(), - cap, - )), - )); - } - Ok(()) -} - -impl nexum::host::chain::Host for HostState { - async fn request( - &mut self, - chain_id: u64, - method: String, - params: String, - ) -> Result { - let start = Instant::now(); - let chain = Chain::from_id(chain_id); - let method = match resolve_method(&method) { - Ok(method) => method, - Err(err) => { - tracing::warn!( - chain_id, - %method, - "chain::request rejected: method is not in the permitted read surface" - ); - metrics::counter!( - "shepherd_chain_request_total", - "chain_id" => chain_id.to_string(), - "method" => "", - "outcome" => "err", - ) - .increment(1); - return Err(err); - } - }; - let name = method.as_str(); - tracing::debug!(chain_id, method = name, "chain::request"); - let result = self - .chain - .request(chain, method, params) - .await - .map_err(ChainError::from) - .and_then(|body| { - check_response_cap(&body, self.chain_response_max_bytes, chain_id, name)?; - Ok(body) - }); - tracing::trace!(elapsed_ms = ?start.elapsed(), "chain::request done"); - let outcome = if result.is_ok() { "ok" } else { "err" }; - metrics::counter!( - "shepherd_chain_request_total", - "chain_id" => chain_id.to_string(), - "method" => name, - "outcome" => outcome, - ) - .increment(1); - result - } - - /// Dispatch a batch of requests, one `RpcResult` per entry in order. - /// - /// The outer `ChainError` is reserved for a failure that stops the - /// host producing any results at all; this host has no such path, so - /// it always returns `Ok`. A per-entry failure (a denied - /// method, a node revert, a transport fault) surfaces as that entry's - /// `RpcResult::Err`. This impl folds each entry independently, so a - /// failure leaves its neighbours intact; a different host could instead - /// short-circuit the batch, so SDK consumers match on each entry, not - /// on the batch call. - async fn request_batch( - &mut self, - chain_id: u64, - requests: Vec, - ) -> Result, ChainError> { - let start = Instant::now(); - // Each entry is dispatched sequentially and gets its own full - // per-chain timeout, so the worst-case blocking time for a batch - // is N x request_timeout_secs. - tracing::debug!(chain_id, count = requests.len(), "chain::request-batch"); - let cap = self.chain_response_max_bytes; - let mut out = Vec::with_capacity(requests.len()); - // The per-entry cap (inside `request`) bounds each body; this - // running total bounds the aggregate `Vec` lowered into - // guest memory in one go, so a wide batch of individually-legal - // bodies cannot saturate the guest heap either - the exact failure - // the guidance in #154 (block-range chunking via request-batch) - // would otherwise re-introduce. - let mut total_bytes: usize = 0; - for req in requests { - let method = req.method.clone(); - match nexum::host::chain::Host::request(self, chain_id, req.method, req.params).await { - Ok(s) => { - total_bytes = total_bytes.saturating_add(s.len()); - if total_bytes > cap { - tracing::warn!( - chain_id, - method = %method, - total_bytes, - cap_bytes = cap, - "chain batch aggregate exceeds size cap - rejecting entry before guest copy" - ); - metrics::counter!( - "shepherd_chain_response_capped_total", - "chain_id" => chain_id.to_string(), - "method" => method, - ) - .increment(1); - out.push(nexum::host::chain::RpcResult::Err(ChainError::Fault( - crate::bindings::nexum::host::types::Fault::InvalidInput(format!( - "batch aggregate ({total_bytes} bytes) exceeds the configured \ - cap ({cap} bytes)", - )), - ))); - } else { - out.push(nexum::host::chain::RpcResult::Ok(s)); - } - } - Err(e) => out.push(nexum::host::chain::RpcResult::Err(e)), - } - } - tracing::trace!(elapsed_ms = ?start.elapsed(), "chain::request-batch done"); - Ok(out) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use crate::bindings::nexum::host::types::Fault; - use crate::host::provider_pool::ProviderError; - use alloy_transport::TransportErrorKind; - - /// Helper: build a synthetic transport-level [`TransportError`]. - /// Transport-level errors carry no structured JSON-RPC `ErrorResp`, - /// so they project to a [`ChainError::Fault`] rather than a - /// [`ChainError::Rpc`]. - fn transport_err(msg: &str) -> alloy_transport::TransportError { - TransportErrorKind::custom_str(msg) - } - - #[test] - fn rpc_error_with_revert_data_is_forwarded() { - // The node returned a structured `ErrorResp` for an `eth_call` - // revert: `code = -32000`, `data` already hex-decoded to the - // abi-encoded revert body. The projection forwards both into - // `ChainError::Rpc` so the SDK can classify the outcome via - // `decode_revert`. - let revert = vec![0xab, 0xc1, 0x23]; - let chain_err = ChainError::from(ProviderError::Rpc { - method: "eth_call".into(), - code: Some(-32000), - data: Some(revert.clone()), - source: transport_err("execution reverted"), - }); - - let ChainError::Rpc(rpc) = chain_err else { - panic!("expected ChainError::Rpc, got {chain_err:?}"); - }; - assert_eq!(rpc.code, -32000); - assert_eq!(rpc.data.as_deref(), Some(revert.as_slice())); - } - - #[test] - fn transport_failure_projects_to_unavailable_fault() { - // A transport-level failure (no `ErrorResp`) with no timeout - // marker in the message defaults to an `unavailable` fault. - let chain_err = ChainError::from(ProviderError::Rpc { - method: "eth_call".into(), - code: None, - data: None, - source: transport_err("websocket disconnected"), - }); - assert!(matches!( - chain_err, - ChainError::Fault(Fault::Unavailable(_)) - )); - } - - #[test] - fn timed_out_request_projects_to_timeout_fault() { - let chain_err = ChainError::from(ProviderError::Rpc { - method: "eth_call".into(), - code: None, - data: None, - source: transport_err("request timed out after 30s"), - }); - assert!(matches!(chain_err, ChainError::Fault(Fault::Timeout))); - } - - #[test] - fn backend_gone_projects_to_unavailable_fault() { - let chain_err = ChainError::from(ProviderError::Rpc { - method: "eth_call".into(), - code: None, - data: None, - source: TransportErrorKind::backend_gone(), - }); - assert!(matches!( - chain_err, - ChainError::Fault(Fault::Unavailable(_)) - )); - } - - #[test] - fn out_of_range_rpc_code_saturates_to_internal_fallback() { - // JSON-RPC codes are conventionally `-32768..-32000`, but the - // alloy `ErrorPayload.code` field is `i64`. Defensive: an - // out-of-`i32` code should not poison the projection - clamp - // to `-32603` so the guest sees a sane code. - let chain_err = ChainError::from(ProviderError::Rpc { - method: "eth_call".into(), - code: Some(i64::from(i32::MAX) + 1), - data: None, - source: transport_err("weird code"), - }); - let ChainError::Rpc(rpc) = chain_err else { - panic!("expected ChainError::Rpc, got {chain_err:?}"); - }; - assert_eq!(rpc.code, -32603); - } - - #[test] - fn unknown_chain_is_unsupported_fault() { - // Use an id with no `NamedChain` mapping so `Chain`'s `Display` - // prints the number and the message assertion stays meaningful. - let chain_err = ChainError::from(ProviderError::UnknownChain(Chain::from_id(424242))); - let ChainError::Fault(Fault::Unsupported(msg)) = chain_err else { - panic!("expected Unsupported fault, got {chain_err:?}"); - }; - assert!(msg.contains("424242")); - } - - #[test] - fn timeout_maps_to_timeout_fault() { - // A configured-timeout failure surfaces as the dedicated - // `timeout` fault, distinct from a revert (`Rpc`) or an - // unreachable node (`unavailable`). - let chain_err = ChainError::from(ProviderError::Timeout { - method: "eth_call".into(), - }); - assert!(matches!(chain_err, ChainError::Fault(Fault::Timeout))); - } - - #[test] - fn invalid_params_maps_to_invalid_input_fault() { - // `serde_json::from_str::<()>("not json")` is the cheapest - // way to produce a real `serde_json::Error` for tests. - let source = serde_json::from_str::("not json") - .expect_err("`not json` is not valid JSON"); - let chain_err = ChainError::from(ProviderError::InvalidParams { - method: "eth_call".into(), - source, - }); - assert!(matches!( - chain_err, - ChainError::Fault(Fault::InvalidInput(_)) - )); - } - - #[test] - fn permitted_methods_resolve() { - for m in ["eth_call", "eth_blockNumber", "eth_getBalance"] { - assert!(resolve_method(m).is_ok(), "{m} should resolve"); - } - } - - #[test] - fn signing_methods_are_denied() { - // The signing-adjacent surface must map to a `Denied` fault, - // not reach the provider. - for m in [ - "eth_sign", - "eth_sendTransaction", - "eth_accounts", - "personal_sign", - "eth_sendRawTransaction", - ] { - let err = resolve_method(m).expect_err(m); - assert!( - matches!(err, ChainError::Fault(Fault::Denied(_))), - "{m} must be a Denied fault, got {err:?}" - ); - } - } - - #[test] - fn unknown_method_is_denied() { - let err = resolve_method("eth_totallyFakeMethod").expect_err("unknown method"); - assert!(matches!(err, ChainError::Fault(Fault::Denied(_)))); - } - - #[test] - fn batch_entries_are_classified_independently() { - // `request_batch` routes every entry through `resolve_method`, - // so one denied entry neither aborts nor taints the permitted - // entries around it. - let batch = ["eth_call", "eth_sign", "eth_getBalance"]; - let resolved: Vec<_> = batch.iter().map(|m| resolve_method(m)).collect(); - assert!(resolved[0].is_ok()); - assert!(matches!( - resolved[1].as_ref().expect_err("eth_sign"), - ChainError::Fault(Fault::Denied(_)), - )); - assert!(resolved[2].is_ok()); - } - - // ── response size cap tests (#154) ── - - #[test] - fn response_at_cap_is_accepted() { - let body = "x".repeat(10); - assert!( - check_response_cap(&body, 10, 1, "eth_call").is_ok(), - "body exactly at cap should pass" - ); - } - - #[test] - fn response_over_cap_returns_invalid_input() { - let body = "x".repeat(11); - let err = - check_response_cap(&body, 10, 1, "eth_call").expect_err("over-cap body should fail"); - assert!( - matches!(err, ChainError::Fault(Fault::InvalidInput(_))), - "expected InvalidInput fault, got {err:?}" - ); - } -} diff --git a/nexum/crates/nexum-runtime/src/host/impls/identity.rs b/nexum/crates/nexum-runtime/src/host/impls/identity.rs deleted file mode 100644 index bbea4d44..00000000 --- a/nexum/crates/nexum-runtime/src/host/impls/identity.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! `nexum:host/identity`: deferred to 0.3 (keystore / KMS backend). -//! `accounts()` returns an empty roster so guests can probe-then-skip; -//! signing returns `unsupported`. - -use crate::bindings::nexum; -use crate::bindings::nexum::host::types::Fault; -use crate::host::component::RuntimeTypes; -use crate::host::state::HostState; - -impl nexum::host::identity::Host for HostState { - async fn accounts(&mut self) -> Result>, Fault> { - Ok(vec![]) - } - - async fn sign(&mut self, _account: Vec, _message: Vec) -> Result, Fault> { - Err(Fault::Unsupported("sign requires a keystore (0.3)".into())) - } - - async fn sign_typed_data( - &mut self, - _account: Vec, - _typed_data: String, - ) -> Result, Fault> { - Err(Fault::Unsupported( - "sign-typed-data requires a keystore (0.3)".into(), - )) - } -} diff --git a/nexum/crates/nexum-runtime/src/host/impls/local_store.rs b/nexum/crates/nexum-runtime/src/host/impls/local_store.rs deleted file mode 100644 index e5abc7c2..00000000 --- a/nexum/crates/nexum-runtime/src/host/impls/local_store.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! `nexum:host/local-store`: redb backend with host-side namespacing. - -use crate::bindings::nexum; -use crate::bindings::nexum::host::types::Fault; -use crate::host::component::{RuntimeTypes, StateHandle}; -use crate::host::state::HostState; - -impl nexum::host::local_store::Host for HostState { - async fn get(&mut self, key: String) -> Result>, Fault> { - self.store.get(&key).map_err(Fault::from) - } - - async fn set(&mut self, key: String, value: Vec) -> Result<(), Fault> { - self.store.set(&key, &value).map_err(Fault::from) - } - - async fn delete(&mut self, key: String) -> Result<(), Fault> { - self.store.delete(&key).map_err(Fault::from) - } - - async fn list_keys(&mut self, prefix: String) -> Result, Fault> { - self.store.list_keys(&prefix).map_err(Fault::from) - } - - async fn contains(&mut self, key: String) -> Result { - self.store.contains(&key).map_err(Fault::from) - } - - async fn len(&mut self, key: String) -> Result, Fault> { - self.store.len(&key).map_err(Fault::from) - } - - async fn count(&mut self, prefix: String) -> Result { - self.store.count(&prefix).map_err(Fault::from) - } -} diff --git a/nexum/crates/nexum-runtime/src/host/impls/logging.rs b/nexum/crates/nexum-runtime/src/host/impls/logging.rs deleted file mode 100644 index 37be16c8..00000000 --- a/nexum/crates/nexum-runtime/src/host/impls/logging.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! `nexum:host/logging`: constructs a `HostInterface` [`LogRecord`] from -//! the guest's `log` call and hands it to the shared router, which tags -//! it with the run and fans it to the tracing consumer and the store. - -use tracing_core::Level; - -use crate::bindings::nexum; -use crate::host::component::RuntimeTypes; -use crate::host::logs::{LogRecord, LogSource}; -use crate::host::state::HostState; - -impl nexum::host::logging::Host for HostState { - async fn log(&mut self, level: nexum::host::logging::Level, message: String) { - // WIT edge: the generated wire enum crosses into the level - // vocabulary here, one of the only two such conversions. - use nexum::host::logging::Level as WireLevel; - let level = match level { - WireLevel::Trace => Level::TRACE, - WireLevel::Debug => Level::DEBUG, - WireLevel::Info => Level::INFO, - WireLevel::Warn => Level::WARN, - WireLevel::Error => Level::ERROR, - }; - self.log_router.record(LogRecord::now( - self.run.clone(), - LogSource::HostInterface, - level, - message, - )); - } -} diff --git a/nexum/crates/nexum-runtime/src/host/impls/messaging.rs b/nexum/crates/nexum-runtime/src/host/impls/messaging.rs deleted file mode 100644 index 4cf45da5..00000000 --- a/nexum/crates/nexum-runtime/src/host/impls/messaging.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! `nexum:host/messaging`: the Waku backend is deferred to 0.3, so -//! `publish` reports `unsupported` and `query` returns empty, the same -//! posture as `identity::accounts`. The per-store topic scope is enforced -//! ahead of that stub: a provider carrying a -//! `[[adapters]].messaging_topics` grant may only publish within it, so -//! the egress boundary is live even though delivery is not. - -use crate::bindings::nexum; -use crate::bindings::nexum::host::types::Fault; -use crate::host::component::RuntimeTypes; -use crate::host::state::HostState; - -/// Whether `topic` falls within `scope`. An empty scope is unscoped and -/// admits every topic (the module default); otherwise a topic is admitted -/// when it equals a scope entry or descends from one read as a path prefix -/// (`/nexum/1/` scopes the whole family beneath it). The prefix boundary is -/// the `/` path separator, so a grant never leaks into a longer sibling -/// segment (`/nexum/1/acme` does not admit `/nexum/1/acme-orders/...`). -fn topic_in_scope(topic: &str, scope: &[String]) -> bool { - if scope.is_empty() { - return true; - } - scope.iter().any(|allowed| { - if topic == allowed { - return true; - } - let prefix = allowed.strip_suffix('/').unwrap_or(allowed); - topic - .strip_prefix(prefix) - .is_some_and(|rest| rest.starts_with('/')) - }) -} - -impl nexum::host::messaging::Host for HostState { - async fn publish(&mut self, content_topic: String, _payload: Vec) -> Result<(), Fault> { - if !topic_in_scope(&content_topic, &self.messaging_topics) { - return Err(Fault::Denied(format!( - "content topic {content_topic:?} outside this component's messaging scope" - ))); - } - Err(Fault::Unsupported("Waku backend deferred to 0.3".into())) - } - - async fn query( - &mut self, - _content_topic: String, - _start_time: Option, - _end_time: Option, - _limit: Option, - ) -> Result, Fault> { - Ok(vec![]) - } -} - -#[cfg(test)] -mod tests { - use super::topic_in_scope; - - #[test] - fn empty_scope_admits_everything() { - assert!(topic_in_scope("/nexum/1/anything/proto", &[])); - } - - #[test] - fn exact_topic_is_admitted() { - let scope = vec!["/nexum/1/acme-orders/proto".to_owned()]; - assert!(topic_in_scope("/nexum/1/acme-orders/proto", &scope)); - assert!(!topic_in_scope("/nexum/1/other/proto", &scope)); - } - - #[test] - fn prefix_scope_admits_the_family_but_not_a_sibling() { - let scope = vec!["/nexum/1/".to_owned()]; - assert!(topic_in_scope("/nexum/1/acme-orders/proto", &scope)); - assert!(topic_in_scope("/nexum/1/twap/proto", &scope)); - // A sibling namespace stays out. - assert!(!topic_in_scope("/nexum/2/acme-orders/proto", &scope)); - } - - #[test] - fn prefix_boundary_is_a_path_segment_not_a_substring() { - // A scope entry without a trailing slash still bounds on the path - // separator, so it cannot leak into a longer sibling segment. - let scope = vec!["/nexum/1/acme".to_owned()]; - assert!(topic_in_scope("/nexum/1/acme", &scope)); - assert!(topic_in_scope("/nexum/1/acme/orders", &scope)); - assert!(!topic_in_scope("/nexum/1/acme-orders/proto", &scope)); - } -} diff --git a/nexum/crates/nexum-runtime/src/host/impls/mod.rs b/nexum/crates/nexum-runtime/src/host/impls/mod.rs deleted file mode 100644 index 2247ed60..00000000 --- a/nexum/crates/nexum-runtime/src/host/impls/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! `Host` trait impls for [`crate::host::state::HostState`], one -//! file per WIT interface. -//! -//! The interfaces themselves (and their generated trait shapes) live -//! in [`crate::bindings`]; this module only contains the dispatch -//! glue between the WIT signature and the corresponding backend in -//! [`crate::host`]. - -mod chain; -mod identity; -mod local_store; -mod logging; -mod messaging; -mod remote_store; -mod types; diff --git a/nexum/crates/nexum-runtime/src/host/impls/remote_store.rs b/nexum/crates/nexum-runtime/src/host/impls/remote_store.rs deleted file mode 100644 index 7fb0f569..00000000 --- a/nexum/crates/nexum-runtime/src/host/impls/remote_store.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! `nexum:host/remote-store`: Swarm backend over a Bee node's HTTP API. - -use crate::bindings::nexum; -use crate::bindings::nexum::host::types::Fault; -use crate::host::component::RuntimeTypes; -use crate::host::state::HostState; - -impl nexum::host::remote_store::Host for HostState { - async fn upload(&mut self, data: Vec) -> Result, Fault> { - self.remote.upload(data).await.map_err(Fault::from) - } - - async fn download(&mut self, reference: Vec) -> Result, Fault> { - self.remote.download(reference).await.map_err(Fault::from) - } - - async fn read_feed( - &mut self, - owner: Vec, - topic: Vec, - ) -> Result>, Fault> { - self.remote - .read_feed(owner, topic) - .await - .map_err(Fault::from) - } - - async fn write_feed(&mut self, topic: Vec, data: Vec) -> Result, Fault> { - self.remote - .write_feed(topic, data) - .await - .map_err(Fault::from) - } -} diff --git a/nexum/crates/nexum-runtime/src/host/impls/types.rs b/nexum/crates/nexum-runtime/src/host/impls/types.rs deleted file mode 100644 index f9516569..00000000 --- a/nexum/crates/nexum-runtime/src/host/impls/types.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! `nexum:host/types` is a type-only interface (no functions). The -//! generated trait is empty; we just provide the marker impl. - -use crate::bindings::nexum; -use crate::host::component::RuntimeTypes; -use crate::host::state::HostState; - -impl nexum::host::types::Host for HostState {} diff --git a/nexum/crates/nexum-runtime/src/host/local_store_redb.rs b/nexum/crates/nexum-runtime/src/host/local_store_redb.rs deleted file mode 100644 index 8e2d95e4..00000000 --- a/nexum/crates/nexum-runtime/src/host/local_store_redb.rs +++ /dev/null @@ -1,312 +0,0 @@ -//! `nexum:host/local-store` backend. -//! -//! Single redb file under `EngineConfig.engine.state_dir`. Each module is -//! namespaced host-side by a fixed 32-byte prefix `keccak256(module_name)` -//! prepended to every key, so modules sharing a key string see disjoint -//! data and cannot forge a key into another's range. keccak256 matches ENS -//! node derivation (ADR-0003). - -#![allow(clippy::result_large_err)] - -use std::collections::HashMap; -use std::path::Path; -use std::sync::{Arc, Mutex}; - -use alloy_primitives::keccak256; -use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; -use thiserror::Error; - -const TABLE: TableDefinition<'static, &[u8], &[u8]> = TableDefinition::new("nexum:local-store"); -#[cfg(test)] -const PREFIX_LEN: usize = 32; - -/// Fixed per-entry redb page/B-tree overhead charged on top of prefix + key -/// + value so the quota bounds on-disk bytes, not logical payload. -const ENTRY_OVERHEAD: u64 = 32; - -/// Process-wide handle to the local-store redb database. Cheap to -/// clone. Use [`LocalStore::module`] to obtain a [`ModuleStore`] -/// handle with a pre-computed namespace prefix. -#[derive(Debug, Clone)] -pub struct LocalStore { - db: Arc, - /// Per-namespace live-byte counter, shared across every handle of a - /// namespace, so [`ModuleStore::set`] is O(1) rather than re-scanning - /// the namespace on each write. Lazily seeded by one range scan. - counters: Arc, u64>>>, -} - -/// Per-module handle carrying the pre-computed 32-byte keccak256 -/// namespace prefix. -#[derive(Debug, Clone)] -pub struct ModuleStore { - db: Arc, - prefix: Vec, - counters: Arc, u64>>>, - /// On-disk byte quota for this namespace, enforced in - /// [`ModuleStore::set`]. `None` is unlimited. - quota_bytes: Option, -} - -impl LocalStore { - /// Open (or create) the redb file at `path`. Initialises the shared - /// table so subsequent read transactions never hit `TableDoesNotExist`. - pub fn open(path: impl AsRef) -> Result { - let db = Database::create(path).map_err(StorageError::Open)?; - { - let txn = db.begin_write().map_err(StorageError::Txn)?; - txn.open_table(TABLE).map_err(StorageError::Table)?; - txn.commit().map_err(StorageError::Commit)?; - } - Ok(Self { - db: Arc::new(db), - counters: Arc::new(Mutex::new(HashMap::new())), - }) - } - - /// Return a [`ModuleStore`] with the keccak256 prefix pre-computed. - /// Rejects the empty string so callers can rely on a non-trivial - /// prefix. - pub fn module(&self, namespace: &str) -> Result { - if namespace.is_empty() { - return Err(StorageError::InvalidNamespace( - "module namespace must not be empty".into(), - )); - } - let prefix = keccak256(namespace.as_bytes()).to_vec(); - Ok(ModuleStore { - db: Arc::clone(&self.db), - prefix, - counters: Arc::clone(&self.counters), - quota_bytes: None, - }) - } -} - -impl ModuleStore { - /// Cap this handle's namespace at `quota_bytes` of on-disk footprint - /// (prefix + key + value + fixed overhead, summed across its keys). - /// Writes past the cap are rejected with [`StorageError::QuotaExceeded`]. - pub fn with_quota(mut self, quota_bytes: u64) -> Self { - self.quota_bytes = Some(quota_bytes); - self - } - - /// Fetch a value for `key`. Returns `Ok(None)` when no entry - /// exists; the module never observes the prefix. - pub fn get(&self, key: &str) -> Result>, StorageError> { - let full = self.build_key(key); - let txn = self.db.begin_read().map_err(StorageError::Txn)?; - let table = txn.open_table(TABLE).map_err(StorageError::Table)?; - let value = table - .get(full.as_slice()) - .map_err(StorageError::Storage)? - .map(|v| v.value().to_vec()); - Ok(value) - } - - /// Whether `key` exists, without copying the value out. - pub fn contains(&self, key: &str) -> Result { - let full = self.build_key(key); - let txn = self.db.begin_read().map_err(StorageError::Txn)?; - let table = txn.open_table(TABLE).map_err(StorageError::Table)?; - Ok(table - .get(full.as_slice()) - .map_err(StorageError::Storage)? - .is_some()) - } - - /// Value byte length for `key`, `Ok(None)` when absent. Reads the - /// entry's length in place; the value bytes are never copied out. - pub fn len(&self, key: &str) -> Result, StorageError> { - let full = self.build_key(key); - let txn = self.db.begin_read().map_err(StorageError::Txn)?; - let table = txn.open_table(TABLE).map_err(StorageError::Table)?; - Ok(table - .get(full.as_slice()) - .map_err(StorageError::Storage)? - .map(|v| v.value().len() as u64)) - } - - /// Number of module-visible keys starting with `prefix`. A bounded - /// B-tree range scan: no key strings are materialised. - pub fn count(&self, prefix: &str) -> Result { - let full_prefix = self.build_key(prefix); - let txn = self.db.begin_read().map_err(StorageError::Txn)?; - let table = txn.open_table(TABLE).map_err(StorageError::Table)?; - let mut count = 0u64; - for entry in table - .range(full_prefix.as_slice()..) - .map_err(StorageError::Storage)? - { - let (k, _v) = entry.map_err(StorageError::Storage)?; - if !k.value().starts_with(&full_prefix) { - break; - } - count += 1; - } - Ok(count) - } - - /// Insert or overwrite. Under a quota, charges on-disk cost (prefix, key, - /// value, overhead) and rejects an over-quota write untouched. The commit - /// is fsync-durable. - pub fn set(&self, key: &str, value: &[u8]) -> Result<(), StorageError> { - let full = self.build_key(key); - let txn = self.db.begin_write().map_err(StorageError::Txn)?; - let mut counters = self.counters.lock().unwrap_or_else(|e| e.into_inner()); - // Track the namespace footprint when a quota applies, or when another - // handle of this namespace already tracks it. Untracked writes skip - // the counter (and its seeding scan) entirely. - let track = self.quota_bytes.is_some() || counters.contains_key(&self.prefix); - let mut projected = 0u64; - { - let mut table = txn.open_table(TABLE).map_err(StorageError::Table)?; - if track { - let entry = self.entry_cost(key.len(), value.len()); - let old = table - .get(full.as_slice()) - .map_err(StorageError::Storage)? - .map(|v| self.entry_cost(key.len(), v.value().len())) - .unwrap_or(0); - let used = match counters.get(&self.prefix) { - Some(&u) => u, - None => self.used_bytes(&table)?, - }; - projected = used.saturating_sub(old) + entry; - if let Some(quota) = self.quota_bytes - && projected > quota - { - // Returning aborts the write transaction: nothing lands. - return Err(StorageError::QuotaExceeded { - needed: projected, - quota, - }); - } - } - table - .insert(full.as_slice(), value) - .map_err(StorageError::Storage)?; - } - txn.commit().map_err(StorageError::Commit)?; - if track { - counters.insert(self.prefix.clone(), projected); - } - Ok(()) - } - - /// On-disk footprint charged for one entry: prefix + key + value + a - /// fixed per-entry overhead. - fn entry_cost(&self, key_len: usize, value_len: usize) -> u64 { - self.prefix.len() as u64 + ENTRY_OVERHEAD + key_len as u64 + value_len as u64 - } - - /// Seed the namespace footprint by summing [`Self::entry_cost`] over its - /// prefix range. Run once per namespace; the counter is then incremental. - fn used_bytes( - &self, - table: &impl ReadableTable<&'static [u8], &'static [u8]>, - ) -> Result { - let prefix = self.prefix.as_slice(); - let mut used = 0u64; - for entry in table.range(prefix..).map_err(StorageError::Storage)? { - let (k, v) = entry.map_err(StorageError::Storage)?; - let kb = k.value(); - if !kb.starts_with(prefix) { - break; - } - used += self.entry_cost(kb.len() - prefix.len(), v.value().len()); - } - Ok(used) - } - - /// Delete. Idempotent: deleting a missing key is a no-op. - pub fn delete(&self, key: &str) -> Result<(), StorageError> { - let full = self.build_key(key); - let txn = self.db.begin_write().map_err(StorageError::Txn)?; - let mut counters = self.counters.lock().unwrap_or_else(|e| e.into_inner()); - let tracked = counters.contains_key(&self.prefix); - let mut released = 0u64; - { - let mut table = txn.open_table(TABLE).map_err(StorageError::Table)?; - if tracked { - released = table - .get(full.as_slice()) - .map_err(StorageError::Storage)? - .map(|v| self.entry_cost(key.len(), v.value().len())) - .unwrap_or(0); - } - table - .remove(full.as_slice()) - .map_err(StorageError::Storage)?; - } - txn.commit().map_err(StorageError::Commit)?; - if tracked && let Some(u) = counters.get_mut(&self.prefix) { - *u = u.saturating_sub(released); - } - Ok(()) - } - - /// Enumerate keys whose raw key (post-prefix) starts with - /// `prefix`. Returns only the module-visible key strings; the - /// host strips the namespace prefix. - pub fn list_keys(&self, prefix: &str) -> Result, StorageError> { - let full_prefix = self.build_key(prefix); - let txn = self.db.begin_read().map_err(StorageError::Txn)?; - let table = txn.open_table(TABLE).map_err(StorageError::Table)?; - let mut out = Vec::new(); - // redb's B-tree iterates keys in sorted order, so a range - // starting at `full_prefix` only touches matching entries (and - // the first key past the prefix range). Breaking on the first - // non-matching key keeps this O(matching entries) instead of - // the O(total DB entries) `table.iter()` would do. - for entry in table - .range(full_prefix.as_slice()..) - .map_err(StorageError::Storage)? - { - let (k, _v) = entry.map_err(StorageError::Storage)?; - let key_bytes = k.value(); - if !key_bytes.starts_with(&full_prefix) { - break; - } - if let Ok(s) = std::str::from_utf8(&key_bytes[self.prefix.len()..]) { - out.push(s.to_owned()); - } - } - Ok(out) - } - - fn build_key(&self, key: &str) -> Vec { - let mut out = self.prefix.clone(); - out.extend_from_slice(key.as_bytes()); - out - } -} - -/// Errors surfaced by [`LocalStore`] and [`ModuleStore`]. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum StorageError { - #[error("open redb: {0}")] - Open(#[source] redb::DatabaseError), - #[error("redb txn: {0}")] - Txn(#[source] redb::TransactionError), - #[error("redb table: {0}")] - Table(#[source] redb::TableError), - #[error("redb storage: {0}")] - Storage(#[source] redb::StorageError), - #[error("redb commit: {0}")] - Commit(#[source] redb::CommitError), - #[error("invalid namespace: {0}")] - InvalidNamespace(String), - #[error("local-store quota exceeded: write needs {needed} B but quota is {quota} B")] - QuotaExceeded { - /// Footprint the write would produce. - needed: u64, - /// The module's byte quota. - quota: u64, - }, -} - -#[cfg(test)] -mod tests; diff --git a/nexum/crates/nexum-runtime/src/host/local_store_redb/tests.rs b/nexum/crates/nexum-runtime/src/host/local_store_redb/tests.rs deleted file mode 100644 index 3e1fd159..00000000 --- a/nexum/crates/nexum-runtime/src/host/local_store_redb/tests.rs +++ /dev/null @@ -1,408 +0,0 @@ -use super::*; - -fn fresh() -> (tempfile::TempDir, LocalStore) { - let dir = tempfile::tempdir().expect("tempdir"); - let store = LocalStore::open(dir.path().join("ls.redb")).expect("open"); - (dir, store) -} - -#[test] -fn set_get_roundtrip() { - let (_dir, store) = fresh(); - let ms = store.module("twap").unwrap(); - ms.set("k", b"v").unwrap(); - assert_eq!(ms.get("k").unwrap().as_deref(), Some(&b"v"[..])); -} - -// A committed write survives dropping every handle and reopening the file: -// each `set` is its own fsync-durable txn, so a shutdown after it returns -// cannot lose it. -#[test] -fn committed_write_survives_reopen() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("ls.redb"); - { - let store = LocalStore::open(&path).expect("open"); - let ms = store.module("twap").unwrap(); - ms.set("cursor", b"42").unwrap(); - ms.delete("stale").unwrap(); - // Drop every handle (the `Arc` flushes on close). - } - let store = LocalStore::open(&path).expect("reopen"); - let ms = store.module("twap").unwrap(); - assert_eq!(ms.get("cursor").unwrap().as_deref(), Some(&b"42"[..])); - assert!(ms.get("stale").unwrap().is_none()); -} - -#[test] -fn namespaces_isolate_modules() { - let (_dir, store) = fresh(); - let a = store.module("a").unwrap(); - let b = store.module("b").unwrap(); - a.set("k", b"from-a").unwrap(); - b.set("k", b"from-b").unwrap(); - assert_eq!(a.get("k").unwrap().as_deref(), Some(&b"from-a"[..])); - assert_eq!(b.get("k").unwrap().as_deref(), Some(&b"from-b"[..])); -} - -#[test] -fn delete_then_get_is_none() { - let (_dir, store) = fresh(); - let ms = store.module("twap").unwrap(); - ms.set("k", b"v").unwrap(); - ms.delete("k").unwrap(); - assert!(ms.get("k").unwrap().is_none()); -} - -#[test] -fn list_keys_strips_namespace_prefix() { - let (_dir, store) = fresh(); - let ms = store.module("twap").unwrap(); - ms.set("posted:1", b"x").unwrap(); - ms.set("posted:2", b"y").unwrap(); - ms.set("other", b"z").unwrap(); - let keys = ms.list_keys("posted:").unwrap(); - assert_eq!(keys.len(), 2); - assert!(keys.iter().all(|k| k.starts_with("posted:"))); -} - -#[test] -fn contains_answers_without_the_value() { - let (_dir, store) = fresh(); - let ms = store.module("twap").unwrap(); - ms.set("k", b"v").unwrap(); - assert!(ms.contains("k").unwrap()); - assert!(!ms.contains("missing").unwrap()); - ms.delete("k").unwrap(); - assert!(!ms.contains("k").unwrap()); -} - -#[test] -fn len_reports_value_bytes_or_none() { - let (_dir, store) = fresh(); - let ms = store.module("twap").unwrap(); - ms.set("empty", b"").unwrap(); - ms.set("k", b"abcde").unwrap(); - assert_eq!(ms.len("empty").unwrap(), Some(0)); - assert_eq!(ms.len("k").unwrap(), Some(5)); - assert_eq!(ms.len("missing").unwrap(), None); -} - -#[test] -fn count_matches_list_keys_and_respects_namespaces() { - let (_dir, store) = fresh(); - let a = store.module("a").unwrap(); - let b = store.module("b").unwrap(); - a.set("posted:1", b"x").unwrap(); - a.set("posted:2", b"y").unwrap(); - a.set("other", b"z").unwrap(); - b.set("posted:9", b"w").unwrap(); - assert_eq!(a.count("posted:").unwrap(), 2); - assert_eq!(a.count("").unwrap(), 3); - assert_eq!(a.count("nope:").unwrap(), 0); - assert_eq!(b.count("posted:").unwrap(), 1); - assert_eq!( - a.count("posted:").unwrap(), - a.list_keys("posted:").unwrap().len() as u64 - ); -} - -#[test] -fn rejects_empty_namespace() { - let (_dir, store) = fresh(); - let err = store.module("").unwrap_err(); - assert!(matches!(err, StorageError::InvalidNamespace(_))); -} - -#[test] -fn prefix_is_fixed_32_bytes() { - let short = store_prefix("a"); - let long = store_prefix(&"a".repeat(300)); - assert_eq!(short.len(), PREFIX_LEN); - assert_eq!(long.len(), PREFIX_LEN); - // Different inputs produce different prefixes. - assert_ne!(short, long); -} - -#[test] -fn prefix_is_deterministic() { - let p1 = store_prefix("twap-monitor"); - let p2 = store_prefix("twap-monitor"); - assert_eq!(p1, p2); -} - -#[test] -fn similar_names_differ() { - // Verify that names that share a common prefix don't collide. - let pa = store_prefix("module-a"); - let pb = store_prefix("module-b"); - assert_ne!(pa, pb); -} - -#[test] -fn module_handles_share_underlying_data() { - let (_dir, store) = fresh(); - let ms1 = store.module("twap").unwrap(); - let ms2 = ms1.clone(); - ms1.set("k", b"v").unwrap(); - assert_eq!(ms2.get("k").unwrap().as_deref(), Some(&b"v"[..])); -} - -/// Helper: compute the prefix a ModuleStore would use for `name`. -fn store_prefix(name: &str) -> Vec { - keccak256(name.as_bytes()).to_vec() -} - -/// On-disk cost the quota charges for one entry: prefix + overhead + key + -/// value. -fn cost(key: &str, val: &[u8]) -> u64 { - (PREFIX_LEN + key.len() + val.len()) as u64 + ENTRY_OVERHEAD -} - -#[test] -fn default_handle_has_no_quota() { - let (_dir, store) = fresh(); - let ms = store.module("m").unwrap(); - // Comfortably larger than any per-module quota; the default is unlimited. - ms.set("k", &vec![0u8; 4096]).unwrap(); - assert_eq!(ms.get("k").unwrap().map(|v| v.len()), Some(4096)); -} - -#[test] -fn quota_rejects_over_budget_write_leaving_store_unchanged() { - let (_dir, store) = fresh(); - // Quota sized so the first entry exactly fits its on-disk cost. - let quota = cost("key", b"fits!"); - let ms = store.module("m").unwrap().with_quota(quota); - ms.set("key", b"fits!").unwrap(); - - // A second, distinct key pushes the footprint past the quota. - let expected = quota + cost("k2", b"nope"); - let err = ms.set("k2", b"nope").unwrap_err(); - match err { - StorageError::QuotaExceeded { needed, quota: q } => { - assert_eq!(needed, expected); - assert_eq!(q, quota); - } - other => panic!("expected QuotaExceeded, got {other:?}"), - } - // The rejected write must not have landed. - assert!(ms.get("k2").unwrap().is_none()); - assert_eq!(ms.get("key").unwrap().as_deref(), Some(&b"fits!"[..])); -} - -#[test] -fn quota_rejects_single_oversize_value() { - let (_dir, store) = fresh(); - let ms = store.module("m").unwrap().with_quota(4); - let err = ms.set("k", b"toolong").unwrap_err(); - assert!(matches!(err, StorageError::QuotaExceeded { .. })); - assert!(ms.get("k").unwrap().is_none()); -} - -#[test] -fn quota_overwrite_releases_previous_bytes() { - let (_dir, store) = fresh(); - // Room for two small entries; a large "k" plus "j" would not fit unless - // the overwrite releases the old value first. - let quota = cost("k", b"bb") + cost("j", b"cc"); - let ms = store.module("m").unwrap().with_quota(quota); - ms.set("k", b"aaaaa").unwrap(); - // Overwriting releases the old bytes first, so a smaller value fits. - ms.set("k", b"bb").unwrap(); - assert_eq!(ms.get("k").unwrap().as_deref(), Some(&b"bb"[..])); - // A fresh key now fits in the freed budget. - ms.set("j", b"cc").unwrap(); - assert_eq!(ms.get("j").unwrap().as_deref(), Some(&b"cc"[..])); -} - -#[test] -fn quota_is_released_by_delete() { - let (_dir, store) = fresh(); - let ms = store.module("m").unwrap().with_quota(cost("key", b"fits!")); - ms.set("key", b"fits!").unwrap(); - assert!(ms.set("k2", b"nope").is_err()); - ms.delete("key").unwrap(); - // With the namespace emptied, the previously rejected write fits. - ms.set("k2", b"nope").unwrap(); - assert_eq!(ms.get("k2").unwrap().as_deref(), Some(&b"nope"[..])); -} - -#[test] -fn quota_counts_across_short_lived_handles_of_one_namespace() { - let (_dir, store) = fresh(); - // Distinct handles for the same namespace share the footprint: a write - // through a second quota handle sees the first handle's bytes. - store - .module("m") - .unwrap() - .with_quota(cost("a", b"1234") + cost("b", b"5678")) - .set("a", b"1234") - .unwrap(); - let err = store - .module("m") - .unwrap() - .with_quota(8) - .set("b", b"5678") - .unwrap_err(); - assert!(matches!(err, StorageError::QuotaExceeded { .. })); -} - -// --------------------------------------------------------------------------- -// Concurrent access tests: real parallelism via the blocking pool. -// --------------------------------------------------------------------------- - -fn blocking_executor() -> nexum_tasks::TaskExecutor { - nexum_tasks::TaskManager::new().executor() -} - -#[tokio::test] -async fn concurrent_writes_from_different_namespaces() { - let (_dir, store) = fresh(); - let executor = blocking_executor(); - - let handles: Vec<_> = (0..8) - .map(|i| { - let s = store.clone(); - executor.spawn_blocking(move || { - let ms = s.module(&format!("ns-{i}")).unwrap(); - for j in 0..100 { - let key = format!("key-{j}"); - let val = format!("val-{i}-{j}").into_bytes(); - ms.set(&key, &val).unwrap(); - } - }) - }) - .collect(); - - for h in handles { - h.join().await.expect("writer task panicked"); - } - - for i in 0..8 { - let ms = store.module(&format!("ns-{i}")).unwrap(); - for j in 0..100 { - let key = format!("key-{j}"); - let expected = format!("val-{i}-{j}").into_bytes(); - assert_eq!(ms.get(&key).unwrap().as_deref(), Some(expected.as_slice()),); - } - } -} - -#[tokio::test] -async fn concurrent_reads_during_writes() { - let (_dir, store) = fresh(); - let ms = store.module("rw").unwrap(); - let executor = blocking_executor(); - - // Pre-populate namespace "rw" with 50 keys. - for j in 0..50 { - ms.set(&format!("k-{j}"), b"old").unwrap(); - } - - let writer_ms = ms.clone(); - let writer = executor.spawn_blocking(move || { - for j in 0..50 { - writer_ms.set(&format!("k-{j}"), b"new").unwrap(); - } - }); - - let readers: Vec<_> = (0..4) - .map(|_| { - let reader_ms = ms.clone(); - executor.spawn_blocking(move || { - for _ in 0..100 { - for j in 0..50 { - let val = reader_ms.get(&format!("k-{j}")).unwrap(); - let val = val.expect("key must exist"); - assert!( - val == b"old" || val == b"new", - "unexpected value: {:?}", - val, - ); - } - } - }) - }) - .collect(); - - writer.join().await.expect("writer panicked"); - for r in readers { - r.join().await.expect("reader panicked"); - } - - // Final state: all keys must be "new". - for j in 0..50 { - assert_eq!( - ms.get(&format!("k-{j}")).unwrap().as_deref(), - Some(&b"new"[..]), - ); - } -} - -#[tokio::test] -async fn list_keys_races_with_delete() { - let (_dir, store) = fresh(); - let ms = store.module("race").unwrap(); - let executor = blocking_executor(); - - // Pre-populate namespace "race" with 100 keys. - for i in 0..100 { - ms.set(&format!("k:{i}"), b"x").unwrap(); - } - - let deleter_ms = ms.clone(); - let deleter = executor.spawn_blocking(move || { - for i in 0..100 { - deleter_ms.delete(&format!("k:{i}")).unwrap(); - } - }); - - let lister_ms = ms.clone(); - let lister = executor.spawn_blocking(move || { - for _ in 0..50 { - let keys = lister_ms.list_keys("k:").unwrap(); - assert!( - keys.len() <= 100, - "list_keys returned more keys than expected: {}", - keys.len(), - ); - } - }); - - deleter.join().await.expect("deleter panicked"); - lister.join().await.expect("lister panicked"); -} - -#[tokio::test] -async fn stress_many_writers_one_namespace() { - let (_dir, store) = fresh(); - let ms = store.module("shared").unwrap(); - let executor = blocking_executor(); - - let handles: Vec<_> = (0..8) - .map(|i| { - let ms = ms.clone(); - executor.spawn_blocking(move || { - for j in 0..100 { - let key = format!("t{i}-k{j}"); - let val = format!("v-{i}-{j}").into_bytes(); - ms.set(&key, &val).unwrap(); - } - }) - }) - .collect(); - - for h in handles { - h.join().await.expect("writer task panicked"); - } - - // Verify all 800 keys are present with correct values. - for i in 0..8 { - for j in 0..100 { - let key = format!("t{i}-k{j}"); - let expected = format!("v-{i}-{j}").into_bytes(); - assert_eq!(ms.get(&key).unwrap().as_deref(), Some(expected.as_slice()),); - } - } -} diff --git a/nexum/crates/nexum-runtime/src/host/logs/mod.rs b/nexum/crates/nexum-runtime/src/host/logs/mod.rs deleted file mode 100644 index b619bf3e..00000000 --- a/nexum/crates/nexum-runtime/src/host/logs/mod.rs +++ /dev/null @@ -1,284 +0,0 @@ -//! Typed module-log pipeline. -//! -//! Three capture points construct [`LogRecord`]s and hand them to one -//! [`LogRouter`]: the `nexum:host/logging` glue (`HostInterface`), the -//! per-store stdout/stderr pipes ([`StdioStream`], `Stdout`/`Stderr`), -//! and the supervisor's death path (`Panic`). The router fans each -//! record to exactly two consumers: a host `tracing` event (so the -//! operator console and OTLP stacks stay live) and the retention store. -//! `tracing` is a consumer of the pipeline, not its transport. -//! -//! [`LogPipeline`] is the shared handle: it rides the host state so -//! every capture point reaches the router, and it exposes the store's -//! read side to the embedding surface for run listing and log paging. -//! -//! One guest panic deliberately yields three records, distinguishable -//! by source: the guest panic hook writes to stderr (`Stderr`, warn) -//! and then reports over the host logging call (`HostInterface`, -//! error), and the supervisor synthesizes a death record (`Panic`, -//! error) once the trap surfaces. The redundancy is kept because the -//! channels survive different failure modes: stderr capture works even -//! if the sink's host call traps, and the supervisor record covers a -//! guest with no hook installed at all. - -mod stdio; -mod store; - -use std::sync::Arc; -use std::time::SystemTime; - -use strum::IntoStaticStr; -use tracing_core::Level; - -pub use stdio::StdioStream; -pub use store::{InMemoryRunLogStore, LogPage, RunLogStore, RunMeta}; - -/// Identity of one module run. Minted at every instantiation; a restart -/// increments `seq`, so each run is a distinct retention key. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct RunId { - /// Module namespace this run belongs to. - pub module: Arc, - /// Monotonic run counter within the module; 0 is the first boot. - pub seq: u64, - /// Wall-clock instant the run was instantiated. - pub started_at: SystemTime, -} - -impl RunId { - /// Mint a run for `module` at sequence `seq`, stamping the current - /// wall-clock instant. - pub fn new(module: impl Into>, seq: u64) -> Self { - Self { - module: module.into(), - seq, - started_at: SystemTime::now(), - } - } -} - -/// Which capture point produced a record. The snake_case name is the -/// `source` field on the host tracing event. -#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum LogSource { - /// The `nexum:host/logging` glue: an explicit guest `log` call. - HostInterface, - /// A line captured from the guest's stdout pipe. - Stdout, - /// A line captured from the guest's stderr pipe. - Stderr, - /// Synthesized by the supervisor when a run dies via trap or exit. - Panic, -} - -/// One captured log line from any capture point. -#[derive(Debug, Clone)] -pub struct LogRecord { - /// Run the line belongs to. - pub run: RunId, - /// Wall-clock capture time. - pub ts: SystemTime, - /// Capture point of origin. - pub source: LogSource, - /// Line severity. - pub level: Level, - /// The line text. - pub message: String, -} - -impl LogRecord { - /// Build a record stamped at the current wall-clock instant. - pub fn now(run: RunId, source: LogSource, level: Level, message: String) -> Self { - Self { - run, - ts: SystemTime::now(), - source, - level, - message, - } - } - - /// Byte cost charged against the per-run retention budget. - fn cost(&self) -> usize { - RECORD_OVERHEAD + self.message.len() - } -} - -/// Fixed per-record charge on top of the message bytes, approximating -/// the host memory a retained record occupies (run key, timestamps, -/// `String` header, ring slot). Without it a flood of empty messages -/// would grow the ring far past the `[limits.logs]` byte budget. -const RECORD_OVERHEAD: usize = 128; - -/// Fans every captured record to a host `tracing` event and the -/// retention store. -pub struct LogRouter { - store: Arc, - /// Woken after each append so a consumer can await new output instead - /// of polling. `notify_waiters` wakes only armed waiters, so a reader - /// must arm before it reads (see [`LogPipeline::appended`]). - appended: Arc, -} - -impl LogRouter { - /// Router writing into `store`. - pub fn new(store: Arc) -> Self { - Self { - store, - appended: Arc::new(tokio::sync::Notify::new()), - } - } - - /// Emit the tracing event, retain the record, then wake append waiters. - pub fn record(&self, record: LogRecord) { - emit_tracing(&record); - self.store.append(record); - self.appended.notify_waiters(); - } - - fn store(&self) -> &Arc { - &self.store - } -} - -/// Emit one record as a host tracing event at its own level, carrying -/// the module, run sequence, and source. `tracing`'s macros require a -/// static level per call site, and `Level` is a set of associated -/// consts rather than a matchable enum, so dispatch through an equality -/// ladder over the five tiers. -fn emit_tracing(record: &LogRecord) { - let module = &*record.run.module; - let run = record.run.seq; - let source: &'static str = record.source.into(); - let message = record.message.as_str(); - if record.level == Level::TRACE { - tracing::trace!(module, run, source, "{message}"); - } else if record.level == Level::DEBUG { - tracing::debug!(module, run, source, "{message}"); - } else if record.level == Level::INFO { - tracing::info!(module, run, source, "{message}"); - } else if record.level == Level::WARN { - tracing::warn!(module, run, source, "{message}"); - } else { - tracing::error!(module, run, source, "{message}"); - } -} - -/// Shared log pipeline threaded into every module store. Cheap to clone -/// (one `Arc`); the write side is [`router`](Self::router) and the read -/// side is [`list_runs`](Self::list_runs) / [`read`](Self::read). -#[derive(Clone)] -pub struct LogPipeline { - router: Arc, -} - -impl LogPipeline { - /// Pipeline over an arbitrary retention backend. - pub fn new(store: Arc) -> Self { - Self { - router: Arc::new(LogRouter::new(store)), - } - } - - /// Pipeline over the default byte-bounded in-memory backend, sized by - /// the resolved `[limits.logs]` knobs. - pub fn in_memory(limits: crate::engine_config::LogRetentionLimits) -> Self { - Self::new(Arc::new(InMemoryRunLogStore::new(limits))) - } - - /// The write handle the capture points route through. - pub fn router(&self) -> Arc { - self.router.clone() - } - - /// A notify woken after each append, for awaiting new output without - /// polling. Arm a `notified()` future (and `enable()` it) before reading - /// so an append between the read and the await is not lost. - pub fn appended(&self) -> Arc { - self.router.appended.clone() - } - - /// Runs recorded for `module`, oldest retained first. - pub fn list_runs(&self, module: &str) -> Vec { - self.router.store().list_runs(module) - } - - /// Page a run's retained records from `cursor` (0 for the start). - pub fn read(&self, run: &RunId, cursor: u64) -> LogPage { - self.router.store().read(run, cursor) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Mutex; - - use super::*; - - /// Store that both counts appends and forwards to an inner ring, so - /// the fan-out test can prove the retention consumer saw the record. - struct CountingStore { - appended: Mutex>, - } - - impl RunLogStore for CountingStore { - fn append(&self, record: LogRecord) { - self.appended.lock().unwrap().push(record); - } - fn list_runs(&self, _module: &str) -> Vec { - Vec::new() - } - fn read(&self, _run: &RunId, _cursor: u64) -> LogPage { - LogPage::default() - } - } - - #[test] - fn router_fans_out_to_the_retention_store() { - let store = Arc::new(CountingStore { - appended: Mutex::new(Vec::new()), - }); - let router = LogRouter::new(store.clone()); - router.record(LogRecord::now( - RunId::new("m", 0), - LogSource::HostInterface, - Level::INFO, - "hello".to_owned(), - )); - let appended = store.appended.lock().unwrap(); - assert_eq!(appended.len(), 1, "retention consumer saw the record"); - assert_eq!(appended[0].message, "hello"); - assert_eq!(appended[0].source, LogSource::HostInterface); - } - - #[test] - fn pipeline_read_side_reaches_the_backend() { - let limits = crate::engine_config::LogRetentionLimits { - bytes_per_run: 1024, - runs_retained: 4, - }; - let pipeline = LogPipeline::in_memory(limits); - let run = RunId::new("m", 0); - pipeline.router().record(LogRecord::now( - run.clone(), - LogSource::Stdout, - Level::INFO, - "line".to_owned(), - )); - let runs = pipeline.list_runs("m"); - assert_eq!(runs.len(), 1); - assert_eq!(runs[0].run.seq, 0); - let page = pipeline.read(&run, 0); - assert_eq!(page.records[0].message, "line"); - } - - #[test] - fn source_names_are_snake_case_for_the_tracing_field() { - let s: &'static str = LogSource::HostInterface.into(); - assert_eq!(s, "host_interface"); - let s: &'static str = LogSource::Panic.into(); - assert_eq!(s, "panic"); - } -} diff --git a/nexum/crates/nexum-runtime/src/host/logs/stdio.rs b/nexum/crates/nexum-runtime/src/host/logs/stdio.rs deleted file mode 100644 index 251e6753..00000000 --- a/nexum/crates/nexum-runtime/src/host/logs/stdio.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! Per-store stdout/stderr capture: a [`StdoutStream`] that line-buffers -//! the guest's byte stream and routes each complete line as a -//! [`LogRecord`]. Installed in place of `inherit_stdio`, so guest output -//! is tagged with its run and source rather than merged onto host stdio. - -use std::io; -use std::pin::Pin; -use std::sync::Arc; -use std::task::{Context, Poll}; - -use tokio::io::AsyncWrite; -use wasmtime_wasi::cli::{IsTerminal, StdoutStream}; - -use tracing_core::Level; - -use super::{LogRecord, LogRouter, LogSource, RunId}; - -/// Upper bound on an in-flight line held without a newline. A guest that -/// floods a stream without ever terminating a line cannot grow host -/// memory without limit: the buffer is force-flushed as one record once -/// it crosses this size. -const MAX_LINE_BYTES: usize = 1 << 20; - -/// Per-store stdout or stderr sink handed to `WasiCtxBuilder`. Each call -/// to [`StdoutStream::async_stream`] yields a fresh line-splitting writer -/// bound to the same run and source. -pub struct StdioStream { - router: Arc, - run: RunId, - source: LogSource, -} - -impl StdioStream { - /// Sink routing `source` lines for `run` through `router`. - pub fn new(router: Arc, run: RunId, source: LogSource) -> Self { - Self { - router, - run, - source, - } - } -} - -impl IsTerminal for StdioStream { - fn is_terminal(&self) -> bool { - false - } -} - -impl StdoutStream for StdioStream { - fn async_stream(&self) -> Box { - Box::new(LineWriter { - router: self.router.clone(), - run: self.run.clone(), - source: self.source, - buf: Vec::new(), - }) - } -} - -/// Line-splitting writer: buffers raw bytes and emits one record per -/// newline. Cutting only at `\n` (never a UTF-8 continuation byte) means -/// a multi-byte code point split across writes is always reassembled in -/// the buffer before the line is decoded. -struct LineWriter { - router: Arc, - run: RunId, - source: LogSource, - buf: Vec, -} - -impl LineWriter { - /// Route every complete line in the buffer, then force-flush an - /// over-long unterminated remainder. - fn drain(&mut self) { - while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') { - let line: Vec = self.buf.drain(..=nl).collect(); - route_line( - &self.router, - &self.run, - self.source, - &line[..line.len() - 1], - ); - } - if self.buf.len() > MAX_LINE_BYTES { - let chunk = std::mem::take(&mut self.buf); - route_line(&self.router, &self.run, self.source, &chunk); - } - } - - /// Emit any buffered partial line. Idempotent: the buffer is taken, - /// so a shutdown flush and the drop guard never double-emit. - fn flush_remainder(&mut self) { - if self.buf.is_empty() { - return; - } - let rest = std::mem::take(&mut self.buf); - route_line(&self.router, &self.run, self.source, &rest); - } -} - -/// Level a captured line carries: stdout is informational, stderr is a -/// warning. Documented alongside the `[limits.logs]` knobs. -fn level_for(source: LogSource) -> Level { - match source { - LogSource::Stderr => Level::WARN, - _ => Level::INFO, - } -} - -/// Decode one line's bytes and route it, dropping a trailing `\r` (so -/// CRLF output is clean) and skipping empties. -fn route_line(router: &LogRouter, run: &RunId, source: LogSource, bytes: &[u8]) { - let bytes = bytes.strip_suffix(b"\r").unwrap_or(bytes); - if bytes.is_empty() { - return; - } - let message = String::from_utf8_lossy(bytes).into_owned(); - router.record(LogRecord::now( - run.clone(), - source, - level_for(source), - message, - )); -} - -impl AsyncWrite for LineWriter { - fn poll_write( - mut self: Pin<&mut Self>, - _cx: &mut Context<'_>, - data: &[u8], - ) -> Poll> { - self.buf.extend_from_slice(data); - self.drain(); - Poll::Ready(Ok(data.len())) - } - - fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - // A flush is not an end-of-line; partial lines stay buffered. - Poll::Ready(Ok(())) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - self.flush_remainder(); - Poll::Ready(Ok(())) - } -} - -impl Drop for LineWriter { - fn drop(&mut self) { - // A store dropped on module death must not lose the final - // unterminated line. - self.flush_remainder(); - } -} - -#[cfg(test)] -mod tests { - use std::sync::Mutex; - - use tokio::io::AsyncWriteExt; - - use super::*; - use crate::host::logs::{LogPipeline, LogRecord, LogSource, RunId, RunLogStore}; - - /// Capturing store that records every appended message so a test can - /// assert the exact line boundaries the writer produced. - #[derive(Default)] - struct CaptureStore { - records: Mutex>, - } - - impl RunLogStore for CaptureStore { - fn append(&self, record: LogRecord) { - self.records.lock().unwrap().push(record); - } - fn list_runs(&self, _module: &str) -> Vec { - Vec::new() - } - fn read(&self, _run: &RunId, _cursor: u64) -> crate::host::logs::LogPage { - crate::host::logs::LogPage::default() - } - } - - fn setup(source: LogSource) -> (LineWriter, Arc) { - let store = Arc::new(CaptureStore::default()); - let pipeline = LogPipeline::new(store.clone()); - let writer = LineWriter { - router: pipeline.router(), - run: RunId::new("m", 0), - source, - buf: Vec::new(), - }; - (writer, store) - } - - fn messages(store: &CaptureStore) -> Vec { - store - .records - .lock() - .unwrap() - .iter() - .map(|r| r.message.clone()) - .collect() - } - - #[tokio::test] - async fn splits_on_newlines() { - let (mut w, store) = setup(LogSource::Stdout); - w.write_all(b"alpha\nbeta\n").await.unwrap(); - assert_eq!(messages(&store), ["alpha", "beta"]); - } - - #[tokio::test] - async fn buffers_a_partial_line_until_the_newline_arrives() { - let (mut w, store) = setup(LogSource::Stdout); - w.write_all(b"partial").await.unwrap(); - assert!(messages(&store).is_empty(), "no newline yet"); - w.write_all(b" line\n").await.unwrap(); - assert_eq!(messages(&store), ["partial line"]); - } - - #[tokio::test] - async fn reassembles_a_utf8_code_point_split_across_writes() { - // The euro sign is three bytes; splitting mid-code-point across - // two writes must not corrupt the decoded line. - let euro = "\u{20ac}".as_bytes(); - let (mut w, store) = setup(LogSource::Stdout); - w.write_all(&euro[..1]).await.unwrap(); - w.write_all(&euro[1..]).await.unwrap(); - w.write_all(b"\n").await.unwrap(); - assert_eq!(messages(&store), ["\u{20ac}"]); - } - - #[tokio::test] - async fn interleaved_writes_accumulate_into_one_line() { - let (mut w, store) = setup(LogSource::Stdout); - for chunk in [&b"a"[..], b"b", b"c", b"\n", b"d", b"e", b"\n"] { - w.write_all(chunk).await.unwrap(); - } - assert_eq!(messages(&store), ["abc", "de"]); - } - - #[tokio::test] - async fn final_unterminated_line_is_flushed_on_drop() { - let (mut w, store) = setup(LogSource::Stdout); - w.write_all(b"no trailing newline").await.unwrap(); - assert!(messages(&store).is_empty(), "buffered, not yet flushed"); - drop(w); - assert_eq!(messages(&store), ["no trailing newline"]); - } - - #[tokio::test] - async fn empty_lines_are_skipped() { - let (mut w, store) = setup(LogSource::Stdout); - w.write_all(b"\n\nkept\n\n").await.unwrap(); - assert_eq!(messages(&store), ["kept"]); - } - - #[tokio::test] - async fn trailing_carriage_return_is_trimmed() { - let (mut w, store) = setup(LogSource::Stdout); - w.write_all(b"crlf\r\n").await.unwrap(); - assert_eq!(messages(&store), ["crlf"]); - } - - #[tokio::test] - async fn stderr_lines_carry_the_warn_level() { - let (mut w, store) = setup(LogSource::Stderr); - w.write_all(b"oops\n").await.unwrap(); - let records = store.records.lock().unwrap(); - assert_eq!(records[0].source, LogSource::Stderr); - assert_eq!(records[0].level, Level::WARN); - } - - #[tokio::test] - async fn over_long_unterminated_line_is_force_flushed() { - let (mut w, store) = setup(LogSource::Stdout); - let flood = vec![b'x'; MAX_LINE_BYTES + 1]; - w.write_all(&flood).await.unwrap(); - // The force-flush bounds host memory without waiting for a newline. - assert_eq!(messages(&store).len(), 1); - assert_eq!(messages(&store)[0].len(), MAX_LINE_BYTES + 1); - } -} diff --git a/nexum/crates/nexum-runtime/src/host/logs/store.rs b/nexum/crates/nexum-runtime/src/host/logs/store.rs deleted file mode 100644 index e953d01e..00000000 --- a/nexum/crates/nexum-runtime/src/host/logs/store.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Retention store for captured log records: the trait the pipeline -//! writes through and reads back, plus the default byte-bounded -//! in-memory backend (one ring per run, a retained-runs cap per module). - -use std::collections::HashMap; -use std::collections::VecDeque; -use std::sync::Arc; -use std::sync::Mutex; -use std::time::SystemTime; - -use super::{LogRecord, RunId}; -use crate::engine_config::LogRetentionLimits; - -/// A page of a run's retained records plus the cursor to resume from. -#[derive(Debug, Clone, Default)] -pub struct LogPage { - /// Records with sequence at or after the requested cursor, oldest - /// first. May be shorter than the caller expects when older records - /// have been evicted from the ring. - pub records: Vec, - /// Cursor to pass on the next [`RunLogStore::read`] to continue after - /// the last returned record. - pub next_cursor: u64, -} - -/// Summary of one run held by the store. -#[derive(Debug, Clone)] -pub struct RunMeta { - /// The run this metadata describes. - pub run: RunId, - /// Total records appended for this run, including any since evicted. - pub appended: u64, - /// Records currently retained in the ring. - pub retained: usize, - /// Bytes currently charged against the per-run budget. - pub retained_bytes: usize, - /// Timestamp of the oldest retained record, if any. - pub first_ts: Option, - /// Timestamp of the newest retained record, if any. - pub last_ts: Option, -} - -/// Retention backend the [`LogRouter`](super::LogRouter) appends to and -/// the embedding surface reads from. Appends are best-effort and -/// infallible: a full ring evicts rather than erroring. -pub trait RunLogStore: Send + Sync { - /// Retain one record, registering its run on first sighting. - fn append(&self, record: LogRecord); - /// Runs recorded for `module`, oldest retained first. - fn list_runs(&self, module: &str) -> Vec; - /// Page a run's retained records from `cursor` (0 for the start). - /// An unknown or evicted run yields an empty page with - /// `next_cursor` 0, silently resetting a poller to the start. - fn read(&self, run: &RunId, cursor: u64) -> LogPage; -} - -/// Byte-bounded ring of the records for a single run. -struct Ring { - /// `(sequence, record)` pairs, oldest at the front. - records: VecDeque<(u64, LogRecord)>, - /// Sum of the retained records' byte costs. - bytes: usize, - /// Total records ever appended; also the next sequence to assign. - appended: u64, - /// Per-run byte budget. - cap: usize, -} - -impl Ring { - fn new(cap: usize) -> Self { - Self { - records: VecDeque::new(), - bytes: 0, - appended: 0, - cap, - } - } - - fn push(&mut self, record: LogRecord) { - let seq = self.appended; - self.appended += 1; - self.bytes += record.cost(); - self.records.push_back((seq, record)); - // Evict oldest until within budget, but never drop the sole - // record: a single line larger than the whole budget is still - // the newest context a reader wants at a crash. - while self.bytes > self.cap && self.records.len() > 1 { - if let Some((_, evicted)) = self.records.pop_front() { - self.bytes -= evicted.cost(); - } - } - } - - fn meta(&self, run: RunId) -> RunMeta { - RunMeta { - run, - appended: self.appended, - retained: self.records.len(), - retained_bytes: self.bytes, - first_ts: self.records.front().map(|(_, r)| r.ts), - last_ts: self.records.back().map(|(_, r)| r.ts), - } - } - - fn page(&self, cursor: u64) -> LogPage { - let records: Vec = self - .records - .iter() - .filter(|(seq, _)| *seq >= cursor) - .map(|(_, r)| r.clone()) - .collect(); - LogPage { - records, - next_cursor: self.appended, - } - } -} - -/// Runs held for one module, capped at `runs_retained` with the oldest -/// evicted first. -struct ModuleRuns { - /// Insertion order, front = oldest; mirrors the ascending run seq. - order: VecDeque, - rings: HashMap, -} - -impl ModuleRuns { - fn new() -> Self { - Self { - order: VecDeque::new(), - rings: HashMap::new(), - } - } -} - -struct Inner { - modules: HashMap, ModuleRuns>, - limits: LogRetentionLimits, -} - -/// Default retention backend: an in-memory ring per run, byte-bounded to -/// `bytes_per_run`, with at most `runs_retained` runs kept per module. -pub struct InMemoryRunLogStore { - inner: Mutex, -} - -impl InMemoryRunLogStore { - /// Backend sized by the resolved `[limits.logs]` knobs. - pub fn new(limits: LogRetentionLimits) -> Self { - Self { - inner: Mutex::new(Inner { - modules: HashMap::new(), - limits, - }), - } - } -} - -impl RunLogStore for InMemoryRunLogStore { - fn append(&self, record: LogRecord) { - let mut inner = self.inner.lock().expect("log store mutex poisoned"); - let limits = inner.limits; - let module = record.run.module.clone(); - let entry = inner.modules.entry(module).or_insert_with(ModuleRuns::new); - if !entry.rings.contains_key(&record.run) { - entry - .rings - .insert(record.run.clone(), Ring::new(limits.bytes_per_run)); - entry.order.push_back(record.run.clone()); - // Evict whole runs beyond the retained cap, oldest first. - while entry.order.len() > limits.runs_retained { - if let Some(old) = entry.order.pop_front() { - entry.rings.remove(&old); - } - } - } - // Defensive: the new run is pushed to the tail and eviction only - // pops the front, so it always survives, but guard the lookup - // rather than unwrap. - if let Some(ring) = entry.rings.get_mut(&record.run) { - ring.push(record); - } - } - - fn list_runs(&self, module: &str) -> Vec { - let inner = self.inner.lock().expect("log store mutex poisoned"); - let Some(entry) = inner.modules.get(module) else { - return Vec::new(); - }; - entry - .order - .iter() - .filter_map(|run| entry.rings.get(run).map(|ring| ring.meta(run.clone()))) - .collect() - } - - fn read(&self, run: &RunId, cursor: u64) -> LogPage { - let inner = self.inner.lock().expect("log store mutex poisoned"); - inner - .modules - .get(&*run.module) - .and_then(|entry| entry.rings.get(run)) - .map(|ring| ring.page(cursor)) - .unwrap_or_default() - } -} - -#[cfg(test)] -mod tests { - use tracing_core::Level; - - use super::*; - use crate::host::logs::{LogSource, RECORD_OVERHEAD}; - - fn limits(bytes_per_run: usize, runs_retained: usize) -> LogRetentionLimits { - LogRetentionLimits { - bytes_per_run, - runs_retained, - } - } - - fn run(module: &str, seq: u64) -> RunId { - RunId::new(module, seq) - } - - fn record(run: &RunId, message: &str) -> LogRecord { - LogRecord::now( - run.clone(), - LogSource::Stdout, - Level::INFO, - message.to_owned(), - ) - } - - #[test] - fn append_then_read_returns_records_in_order() { - let store = InMemoryRunLogStore::new(limits(1024, 4)); - let r = run("m", 0); - store.append(record(&r, "one")); - store.append(record(&r, "two")); - let page = store.read(&r, 0); - let msgs: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); - assert_eq!(msgs, ["one", "two"]); - assert_eq!(page.next_cursor, 2); - } - - #[test] - fn read_from_cursor_returns_only_newer_records() { - let store = InMemoryRunLogStore::new(limits(1024, 4)); - let r = run("m", 0); - store.append(record(&r, "a")); - store.append(record(&r, "b")); - let first = store.read(&r, 0); - assert_eq!(first.next_cursor, 2); - store.append(record(&r, "c")); - let next = store.read(&r, first.next_cursor); - let msgs: Vec<&str> = next.records.iter().map(|r| r.message.as_str()).collect(); - assert_eq!(msgs, ["c"]); - assert_eq!(next.next_cursor, 3); - } - - /// Cap that fits exactly `n` records carrying 4-byte messages. - fn cap_for(n: usize) -> usize { - n * (RECORD_OVERHEAD + 4) - } - - #[test] - fn ring_retains_exactly_at_the_byte_cap() { - // Three 4-byte messages under a three-record cap: exact fit, - // nothing evicted. - let store = InMemoryRunLogStore::new(limits(cap_for(3), 4)); - let r = run("m", 0); - for m in ["aaaa", "bbbb", "cccc"] { - store.append(record(&r, m)); - } - let meta = &store.list_runs("m")[0]; - assert_eq!(meta.retained, 3); - assert_eq!(meta.retained_bytes, cap_for(3)); - } - - #[test] - fn ring_evicts_oldest_past_the_byte_cap() { - // A fourth 4-byte message past the three-record cap evicts the - // oldest. - let store = InMemoryRunLogStore::new(limits(cap_for(3), 4)); - let r = run("m", 0); - for m in ["aaaa", "bbbb", "cccc", "dddd"] { - store.append(record(&r, m)); - } - let page = store.read(&r, 0); - let msgs: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); - assert_eq!(msgs, ["bbbb", "cccc", "dddd"]); - assert_eq!(page.next_cursor, 4, "cursor counts every append"); - let meta = &store.list_runs("m")[0]; - assert_eq!(meta.appended, 4); - assert_eq!(meta.retained, 3); - assert_eq!(meta.retained_bytes, cap_for(3)); - } - - #[test] - fn empty_message_flood_stays_bounded() { - // Zero-length messages still carry the per-record overhead, so a - // flood cannot grow the ring past the byte budget. - let cap = RECORD_OVERHEAD * 10; - let store = InMemoryRunLogStore::new(limits(cap, 4)); - let r = run("m", 0); - for _ in 0..10_000 { - store.append(record(&r, "")); - } - let meta = &store.list_runs("m")[0]; - assert_eq!(meta.appended, 10_000); - assert_eq!(meta.retained, cap / RECORD_OVERHEAD); - assert!(meta.retained_bytes <= cap); - } - - #[test] - fn oversized_single_record_is_retained_alone() { - // A single record larger than the whole cap is kept: it is the - // newest context a reader wants, so it is never evicted to nothing. - let store = InMemoryRunLogStore::new(limits(4, 4)); - let r = run("m", 0); - store.append(record(&r, "aaaa")); - store.append(record(&r, "this-is-way-over-the-cap")); - let page = store.read(&r, 0); - let msgs: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); - assert_eq!(msgs, ["this-is-way-over-the-cap"]); - } - - #[test] - fn runs_retained_evicts_the_oldest_run() { - let store = InMemoryRunLogStore::new(limits(1024, 2)); - let (r0, r1, r2) = (run("m", 0), run("m", 1), run("m", 2)); - store.append(record(&r0, "zero")); - store.append(record(&r1, "one")); - // Two runs retained so far. - assert_eq!(store.list_runs("m").len(), 2); - // A third run evicts the oldest (seq 0). - store.append(record(&r2, "two")); - let runs = store.list_runs("m"); - let seqs: Vec = runs.iter().map(|meta| meta.run.seq).collect(); - assert_eq!(seqs, [1, 2]); - assert!( - store.read(&r0, 0).records.is_empty(), - "evicted run is empty" - ); - } - - #[test] - fn old_run_stays_readable_until_evicted() { - let store = InMemoryRunLogStore::new(limits(1024, 2)); - let (r0, r1) = (run("m", 0), run("m", 1)); - store.append(record(&r0, "zero")); - store.append(record(&r1, "one")); - // Both runs still under the cap: the old run is readable. - let page = store.read(&r0, 0); - assert_eq!(page.records.len(), 1); - assert_eq!(page.records[0].message, "zero"); - } - - #[test] - fn runs_are_keyed_independently_across_restart() { - // Same module, two runs: the sequences do not collide and each - // ring accounts its own bytes. - let store = InMemoryRunLogStore::new(limits(1024, 4)); - let (r0, r1) = (run("m", 0), run("m", 1)); - store.append(record(&r0, "boot-0")); - store.append(record(&r1, "boot-1")); - assert_eq!(store.read(&r0, 0).records[0].message, "boot-0"); - assert_eq!(store.read(&r1, 0).records[0].message, "boot-1"); - } - - #[test] - fn read_of_unknown_run_is_empty() { - let store = InMemoryRunLogStore::new(limits(1024, 4)); - let page = store.read(&run("ghost", 0), 0); - assert!(page.records.is_empty()); - assert_eq!(page.next_cursor, 0); - } - - #[test] - fn runs_retained_of_one_keeps_only_the_newest_run() { - let store = InMemoryRunLogStore::new(limits(1024, 1)); - let (r0, r1) = (run("m", 0), run("m", 1)); - store.append(record(&r0, "zero")); - store.append(record(&r1, "one")); - let runs = store.list_runs("m"); - assert_eq!(runs.len(), 1); - assert_eq!(runs[0].run.seq, 1); - assert!(store.read(&r0, 0).records.is_empty()); - assert_eq!(store.read(&r1, 0).records[0].message, "one"); - } -} diff --git a/nexum/crates/nexum-runtime/src/host/mod.rs b/nexum/crates/nexum-runtime/src/host/mod.rs deleted file mode 100644 index 57103d25..00000000 --- a/nexum/crates/nexum-runtime/src/host/mod.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Host-side backends for the `nexum:host` interfaces, plus the -//! per-module `HostState` and the WIT `Host` trait impls. -//! -//! Layout: -//! - [`state`]: the `HostState` struct + `WasiView` impl, the receiver -//! every WIT `Host` trait is implemented for. `HostState` is generic -//! over the `RuntimeTypes` lattice; the composition root supplies the -//! concrete assembly. -//! - [`error`]: From conversions that project backend errors into the -//! WIT `chain-error` / `Fault` shapes, plus the `Fault` label and -//! message projections the supervisor records. -//! - [`provider_pool`], [`local_store_redb`], [`remote_store_bee`]: -//! capability backends. Pure code with no bindgen types, so each can -//! be unit-tested without spinning up a wasmtime store. -//! - `impls` (private): the bindgen-side trait impls, one file per core -//! WIT interface, that dispatch to the backends above. -//! - [`component`]: backend traits over the capability backends, the seam a generic runtime consumes. -//! - [`extension`]: the extension seam (linker hook, capability -//! namespace, service, provider kind, event sources) an extension is -//! wired in through at the composition root. Domain extensions live in -//! their own crates and plug in through this seam rather than being -//! hard-linked into the core host. -//! - [`actor`]: the supervised host-actor primitive provider instances -//! run behind (refuel, trap projection, serialising slot). -//! - [`http`]: the wasi:http outgoing gate enforcing the per-module -//! `[capabilities.http].allow` list. -//! - [`logs`]: the typed module-log pipeline (capture points -> router -> -//! tracing event + retention store) and its embedder read surface. - -pub mod actor; -pub mod component; -pub mod error; -pub mod extension; -pub mod http; -mod impls; -pub mod local_store_redb; -pub mod logs; -pub mod provider_pool; -pub mod remote_store_bee; -pub mod state; diff --git a/nexum/crates/nexum-runtime/src/host/provider_pool.rs b/nexum/crates/nexum-runtime/src/host/provider_pool.rs deleted file mode 100644 index 55d5e912..00000000 --- a/nexum/crates/nexum-runtime/src/host/provider_pool.rs +++ /dev/null @@ -1,690 +0,0 @@ -//! `nexum:host/chain` backend. -//! -//! Per-chain alloy provider, opened from the engine config at boot. -//! `request` is a raw JSON-RPC dispatch: the host hands `(method, -//! params)` straight to alloy's transport and returns the result body -//! verbatim. The method is a typed [`ChainMethod`], so only the -//! permitted read surface can reach the transport; params are passed -//! through without re-encoding. -//! -//! Transports: -//! - `ws://` / `wss://` - `WsConnect`; block following pushes `newHeads`. -//! - `http://` / `https://` - alloy's HTTP transport; block following polls -//! `eth_getBlockByNumber`, mirroring the `eth_getLogs` log poller. - -use std::borrow::Cow; -use std::collections::HashMap; -use std::pin::Pin; -use std::sync::Arc; -use std::time::Duration; - -use alloy_chains::Chain; -use alloy_primitives::Bytes; -use alloy_provider::{CanonicalEvent, DynProvider, Provider, ProviderBuilder, WsConnect}; -use alloy_rpc_client::ClientBuilder; -use alloy_rpc_types_eth::{Filter, Header, Log}; -use alloy_transport::layers::RetryBackoffLayer; -use futures::stream::Stream; -use futures::stream::StreamExt as _; -use serde_json::value::RawValue; -use strum::IntoStaticStr; -use thiserror::Error; -use tracing::info; - -use crate::engine_config::EngineConfig; -use crate::host::component::ChainMethod; - -/// Fallback head re-poll cadence for chains alloy has no block-time hint -/// for (custom / dev nets). Known chains derive the interval from -/// [`Chain::average_blocktime_hint`] so the block and log pollers track the -/// chain's block time rather than a one-size-fits-all constant: polling much -/// faster than the block time just burns RPC calls on empty ranges, polling -/// much slower adds latency. -const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2); - -/// Transport retry-layer parameters. `watch_canonical_logs_from` surfaces -/// RPC errors to the caller and ends the stream on the first one unless -/// the transport retries it (per alloy's own guidance on that builder). -/// This layer heals transient blips below the poller, so a momentary node -/// hiccup does not force a re-open - and a re-open is exactly where a gap -/// could reappear. -const RPC_MAX_RETRIES: u32 = 10; -const RPC_RETRY_BACKOFF_MS: u64 = 300; -/// Compute-units-per-second budget the retry layer paces rate-limited -/// nodes against; generous because this pool is read-only and low-QPS. -const RPC_RETRY_CUPS: u64 = 100; - -/// The transport retry layer applied to every provider in the pool. -fn retry_layer() -> RetryBackoffLayer { - RetryBackoffLayer::new(RPC_MAX_RETRIES, RPC_RETRY_BACKOFF_MS, RPC_RETRY_CUPS) -} - -/// One chain's opened provider plus how to drive it. -#[derive(Debug, Clone)] -struct ChainEndpoint { - provider: DynProvider, - timeout: Duration, - /// WS/IPC transport: `subscribe_blocks` pushes `newHeads`. HTTP has no - /// pubsub, so block following polls `eth_getBlockByNumber` instead. - supports_pubsub: bool, -} - -/// Pool of alloy providers keyed by chain. -#[derive(Debug, Clone)] -pub struct ProviderPool { - providers: Arc>, - /// In-flight `eth_getLogs` request groups the canonical log poller - /// runs while backfilling a gap. Paces catch-up throughput against - /// node load; `0` is clamped to `1` by alloy. - log_backfill_concurrency: usize, -} - -impl ProviderPool { - /// Open one provider per chain in `cfg.chains`. WebSocket URLs - /// engage alloy's pubsub transport; HTTP URLs use the HTTP - /// transport. Connection failures propagate to the caller; the - /// engine treats them as fatal at boot. - pub async fn from_config(cfg: &EngineConfig) -> Result { - let mut providers: HashMap = HashMap::new(); - // Sort by numeric id so the boot logs are deterministic - // (`Chain` is not `Ord`). - let mut entries: Vec<_> = cfg.chains.iter().collect(); - entries.sort_by_key(|(c, _)| c.id()); - for (chain, chain_cfg) in entries { - let url = chain_cfg.rpc_url.as_str(); - // The boot log carries the URL with embedded API keys - // redacted - log aggregators (Loki, Datadog, splunk) often - // ingest these lines and the key shouldn't end up in - // long-term storage. The engine still uses the full URL - // when actually connecting to the provider below. - info!( - chain_id = chain.id(), - url = %crate::engine_config::redact_url(url), - "opening chain RPC provider", - ); - let supports_pubsub = url.starts_with("ws://") || url.starts_with("wss://"); - let provider = if supports_pubsub { - let client = ClientBuilder::default() - .layer(retry_layer()) - .ws(WsConnect::new(url)) - .await - .map_err(|source| ProviderError::Connect { - chain: *chain, - source, - })?; - ProviderBuilder::new().connect_client(client).erased() - } else { - let parsed: url::Url = url.parse().map_err(|source| ProviderError::ConnectUrl { - chain: *chain, - source, - })?; - let client = ClientBuilder::default().layer(retry_layer()).http(parsed); - ProviderBuilder::new().connect_client(client).erased() - }; - if chain_cfg.request_timeout_secs == 0 { - return Err(ProviderError::ZeroTimeout { chain: *chain }); - } - let timeout = Duration::from_secs(chain_cfg.request_timeout_secs); - providers.insert( - *chain, - ChainEndpoint { - provider, - timeout, - supports_pubsub, - }, - ); - } - Ok(Self { - providers: Arc::new(providers), - log_backfill_concurrency: cfg.engine.log_backfill_concurrency, - }) - } - - /// Empty pool - used by tests. Every `request` call returns - /// `UnknownChain`. - #[cfg(test)] - pub fn empty() -> Self { - Self { - providers: Arc::new(HashMap::new()), - log_backfill_concurrency: 16, - } - } - - /// Follow new canonical block headers on `chain`. WS pushes them via - /// `eth_subscribe(newHeads)`; HTTP polls `eth_getBlockByNumber` at the - /// chain's block time, yielding the same [`BlockStream`] either way. - pub async fn subscribe_blocks(&self, chain: Chain) -> Result { - let ep = self - .providers - .get(&chain) - .ok_or(ProviderError::UnknownChain(chain))?; - if ep.supports_pubsub { - let sub = - ep.provider - .subscribe_blocks() - .await - .map_err(|source| ProviderError::Rpc { - method: "eth_subscribe(newHeads)".into(), - code: None, - data: None, - source, - })?; - let stream = sub.into_stream().map(Ok::<_, ProviderError>); - return Ok(Box::pin(stream)); - } - // HTTP fallback: poll the head, then follow canonical blocks by - // number at roughly the chain's block time. - let head = ep - .provider - .get_block_number() - .await - .map_err(|source| ProviderError::Rpc { - method: "eth_blockNumber".into(), - code: None, - data: None, - source, - })?; - let poll_interval = chain - .average_blocktime_hint() - .unwrap_or(DEFAULT_POLL_INTERVAL); - let stream = ep - .provider - .watch_canonical_blocks_from(head) - .poll_interval(poll_interval) - .into_stream() - // Reorg `Removed` events are dropped for now; the newHeads push - // path never signalled reorgs either. - .filter_map(|item| async move { - match item { - Ok(CanonicalEvent::Added(block)) => Some(Ok(block.header.clone())), - Ok(CanonicalEvent::Removed(_)) => None, - Err(source) => Some(Err(ProviderError::Rpc { - method: "eth_getBlockByNumber".into(), - code: None, - data: None, - source, - })), - } - }); - Ok(Box::pin(stream)) - } - - /// Current head block number (`eth_blockNumber`). Used as the - /// canonical log poller's `start_block` so a fresh subscription - /// begins at the tip instead of replaying history. - pub async fn block_number(&self, chain: Chain) -> Result { - let ep = self - .providers - .get(&chain) - .ok_or(ProviderError::UnknownChain(chain))?; - ep.provider - .get_block_number() - .await - .map_err(|source| ProviderError::Rpc { - method: "eth_blockNumber".into(), - code: None, - data: None, - source, - }) - } - - /// Open a canonical (reorg-aware) log stream on `chain` from - /// `start_block`. Backed by alloy's `eth_getLogs` block-range poller - /// rather than `eth_subscribe(logs)`, so it works over HTTP as well - /// as WS and recovers events by re-querying the gap rather than - /// silently dropping them across a reconnect. Each yielded item is - /// one canonical block's matching logs (a possibly-empty batch); - /// reorg rollbacks surface as a batch whose logs carry - /// `removed == true`. - pub fn watch_chain_logs( - &self, - chain: Chain, - filter: Filter, - start_block: u64, - ) -> Result { - let ep = self - .providers - .get(&chain) - .ok_or(ProviderError::UnknownChain(chain))?; - // Poll at roughly the chain's block time: known chains carry a - // hint, unknown (custom / dev) chains fall back to the default. - let poll_interval = chain - .average_blocktime_hint() - .unwrap_or(DEFAULT_POLL_INTERVAL); - let stream = ep - .provider - .watch_canonical_logs_from(start_block, &filter) - .rpc_concurrency(self.log_backfill_concurrency) - .poll_interval(poll_interval) - .into_stream() - .map(|item| { - item.map(|event| { - // Stamp `removed` from the canonical event so a - // reorged-away log reaches the module flagged, letting - // it unwind state it built from the earlier delivery. - let (removed, block_logs) = match event { - CanonicalEvent::Added(block_logs) => (false, block_logs), - CanonicalEvent::Removed(block_logs) => (true, block_logs), - }; - block_logs - .logs - .into_iter() - .map(|mut log| { - log.removed = removed; - log - }) - .collect::>() - }) - .map_err(|source| ProviderError::Rpc { - method: "eth_getLogs".into(), - code: None, - data: None, - source, - }) - }); - Ok(Box::pin(stream)) - } - - /// Raw JSON-RPC dispatch. `method` is a permitted read-surface - /// method; `params_json` must be the JSON encoding of the params - /// array (e.g. `"[\"0x...\",\"latest\"]"`), as produced by the - /// SDK's `chain::request` glue. - pub async fn request( - &self, - chain: Chain, - method: ChainMethod, - params_json: String, - ) -> Result { - let ep = self - .providers - .get(&chain) - .ok_or(ProviderError::UnknownChain(chain))?; - let name = method.as_str(); - // Pass the params through as a raw JSON value so alloy does - // not re-encode them on the way to the node. - let params: Box = - RawValue::from_string(params_json).map_err(|source| ProviderError::InvalidParams { - method: name.to_owned(), - source, - })?; - let result: Box = tokio::time::timeout( - ep.timeout, - ep.provider.raw_request(Cow::Borrowed(name), params), - ) - .await - .map_err(|_| ProviderError::Timeout { - method: name.to_owned(), - })? - .map_err(|source| { - // When the node returns a JSON-RPC error response - // (`{"error": {"code":..., "data":...}}`) - typically - // an `eth_call` revert - capture the structured - // payload and decode the hex `error.data` into raw - // bytes once here, so a guest receives the abi-encoded - // revert body directly. Transport-side failures - // (timeouts, serde, etc.) leave both `code` and `data` - // `None` so the projection can tell "no ErrorResp" - // apart from "ErrorResp with code = 0". - let (code, data) = match source.as_error_resp() { - Some(payload) => ( - Some(payload.code), - // alloy decodes the hex `error.data` JSON string into - // `Bytes` in one step; the guest binding is `Vec`, - // so land it there once. - payload - .try_data_as::() - .and_then(Result::ok) - .map(|b| b.to_vec()), - ), - None => (None, None), - }; - ProviderError::Rpc { - method: name.to_owned(), - code, - data, - source, - } - })?; - // Unbox the raw result into the returned String without - // copying the body; the WIT boundary copy is the only one left. - Ok(String::from(Box::::from(result))) - } -} - -/// Boxed stream of `newHeads`-style block headers. -pub type BlockStream = Pin> + Send>>; -/// Boxed stream of canonical per-block log batches from -/// [`ProviderPool::watch_chain_logs`]. Each item is one canonical -/// block's matching logs; reorg rollbacks carry `removed == true`. -pub type CanonicalLogStream = Pin, ProviderError>> + Send>>; - -/// Errors surfaced by [`ProviderPool`]. -/// -/// `IntoStaticStr` produces the snake_case variant name as -/// `&'static str` for metric labels and structured-log fields; the -/// per-variant Display still carries the detail via `thiserror`. -#[derive(Debug, Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum ProviderError { - /// Chain absent from the engine config. - #[error("unknown chain {0} (no engine.toml entry)")] - UnknownChain(Chain), - /// Could not open the underlying transport. - #[error("connect chain {chain}: {source}")] - Connect { - /// Chain we failed to dial. - chain: Chain, - /// Transport-side error. - #[source] - source: alloy_transport::TransportError, - }, - /// HTTP RPC URL did not parse as a [`url::Url`]. - #[error("connect chain {chain}: invalid URL: {source}")] - ConnectUrl { - /// Chain whose `rpc_url` was malformed. - chain: Chain, - /// Underlying parse failure. - #[source] - source: url::ParseError, - }, - /// The guest-supplied JSON params did not parse. - #[error("invalid params JSON for `{method}`: {source}")] - InvalidParams { - /// RPC method name. - method: String, - /// JSON-parser detail. - #[source] - source: serde_json::Error, - }, - /// `request_timeout_secs = 0` in the engine config: every call would - /// time out before it even starts. Rejected at boot. - #[error("chain {chain}: request_timeout_secs must not be 0")] - ZeroTimeout { - /// Chain with the misconfigured timeout. - chain: Chain, - }, - /// The RPC node did not respond within the configured per-request - /// timeout. Surfaces to the guest as a `timeout` fault; the module - /// decides whether to retry. - #[error("rpc `{method}` timed out")] - Timeout { - /// RPC method name. - method: String, - }, - /// The node returned an error for the dispatched call. - /// - /// When the underlying alloy `RpcError` carries a JSON-RPC - /// `ErrorResp` payload (the normal shape for `eth_call` reverts) - /// the structured `code` and `data` fields are propagated; for - /// transport-side failures both are `None`. - #[error("rpc `{method}` failed: {source}")] - Rpc { - /// RPC method name. - method: String, - /// JSON-RPC error code from `ErrorResp.code`. `None` when - /// the failure was transport-level (no structured response). - code: Option, - /// Decoded `ErrorResp.data` payload - for `eth_call` reverts - /// this is the abi-encoded revert body, hex-decoded from the - /// upstream JSON string once here (consumed directly by - /// an SDK revert decoder). `None` when the failure - /// was transport-level or the payload was not a hex string. - data: Option>, - /// Transport-side typed error. - #[source] - source: alloy_transport::TransportError, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn empty_pool_rejects_lookups() { - let pool = ProviderPool::empty(); - let err = pool - .request(Chain::from_id(1), ChainMethod::EthBlockNumber, "[]".into()) - .await - .unwrap_err(); - assert!(matches!(err, ProviderError::UnknownChain(c) if c == Chain::from_id(1))); - } - - #[tokio::test] - async fn empty_pool_rejects_block_subscribe() { - let pool = ProviderPool::empty(); - // Can't use .unwrap_err() because BlockStream doesn't impl Debug. - assert!(matches!( - pool.subscribe_blocks(Chain::from_id(1)).await, - Err(ProviderError::UnknownChain(c)) if c == Chain::from_id(1) - )); - } - - #[tokio::test] - async fn empty_pool_rejects_block_number() { - let pool = ProviderPool::empty(); - assert!(matches!( - pool.block_number(Chain::from_id(1)).await, - Err(ProviderError::UnknownChain(c)) if c == Chain::from_id(1) - )); - } - - #[test] - fn empty_pool_rejects_watch_chain_logs() { - let pool = ProviderPool::empty(); - let filter = alloy_rpc_types_eth::Filter::new(); - // Can't use .unwrap_err() because CanonicalLogStream doesn't impl Debug. - assert!(matches!( - pool.watch_chain_logs(Chain::from_id(1), filter, 0), - Err(ProviderError::UnknownChain(c)) if c == Chain::from_id(1) - )); - } - - #[tokio::test] - async fn invalid_params_json_is_rejected_before_network() { - // RawValue::from_string rejects non-JSON; verify the parse layer - // we rely on before forwarding to alloy. - let bad = "not json at all {{{"; - let result = RawValue::from_string(bad.to_owned()); - assert!(result.is_err(), "invalid JSON should fail RawValue parse"); - } - - /// Helper: build an `EngineConfig` with a single HTTP chain entry. - fn test_config(chain: Chain, rpc_url: &str) -> EngineConfig { - test_config_with_timeout(chain, rpc_url, 30) - } - - /// As [`test_config`], with an explicit per-request timeout. - fn test_config_with_timeout(chain: Chain, rpc_url: &str, timeout_secs: u64) -> EngineConfig { - use crate::engine_config::{ChainConfig, EngineConfig}; - let mut chains = HashMap::new(); - chains.insert( - chain, - ChainConfig { - rpc_url: rpc_url.to_owned(), - request_timeout_secs: timeout_secs, - }, - ); - EngineConfig { - chains, - ..Default::default() - } - } - - #[tokio::test] - async fn invalid_params_through_request_produces_error() { - let cfg = test_config(Chain::from_id(1), "http://127.0.0.1:1"); - let pool = ProviderPool::from_config(&cfg).await.unwrap(); - let err = pool - .request( - Chain::from_id(1), - ChainMethod::EthBlockNumber, - "not json {{{".into(), - ) - .await - .unwrap_err(); - assert!( - matches!(err, ProviderError::InvalidParams { .. }), - "expected InvalidParams, got: {err:?}" - ); - } - - #[tokio::test] - async fn rpc_error_on_unreachable_node() { - let cfg = test_config(Chain::from_id(1), "http://127.0.0.1:1"); - let pool = ProviderPool::from_config(&cfg).await.unwrap(); - let err = pool - .request(Chain::from_id(1), ChainMethod::EthBlockNumber, "[]".into()) - .await - .unwrap_err(); - assert!( - matches!(err, ProviderError::Rpc { .. }), - "expected Rpc error, got: {err:?}" - ); - } - - #[tokio::test] - async fn request_returns_result_body_verbatim() { - use wiremock::{Mock, MockServer, ResponseTemplate, matchers::any}; - - // The raw `result` bytes must come back byte-identical: no - // re-encoding, no DOM round trip, quotes preserved. - let server = MockServer::start().await; - Mock::given(any()) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"{"jsonrpc":"2.0","id":0,"result":{"number":"0x10","extra":[1,2]}}"#, - )) - .mount(&server) - .await; - - let cfg = test_config(Chain::from_id(1), &server.uri()); - let pool = ProviderPool::from_config(&cfg).await.unwrap(); - let body = pool - .request(Chain::from_id(1), ChainMethod::EthBlockNumber, "[]".into()) - .await - .unwrap(); - assert_eq!(body, r#"{"number":"0x10","extra":[1,2]}"#); - } - - #[tokio::test] - async fn rpc_error_on_malformed_node_response() { - use wiremock::{Mock, MockServer, ResponseTemplate, matchers::any}; - - let server = MockServer::start().await; - Mock::given(any()) - .respond_with(ResponseTemplate::new(200).set_body_string("not json")) - .mount(&server) - .await; - - let cfg = test_config(Chain::from_id(1), &server.uri()); - let pool = ProviderPool::from_config(&cfg).await.unwrap(); - let err = pool - .request(Chain::from_id(1), ChainMethod::EthBlockNumber, "[]".into()) - .await - .unwrap_err(); - assert!( - matches!(err, ProviderError::Rpc { .. }), - "expected Rpc error from malformed response, got: {err:?}" - ); - } - - #[test] - fn error_data_decodes_hex_string_and_ignores_non_hex() { - // The `try_data_as::` seam decodes the upstream - // `error.data` JSON string into bytes; a structured object or a - // non-hex string fails to deserialise, which the projection - // swallows to `None` (treated the same as "no revert body"). - let decode = |json: &str| serde_json::from_str::(json).ok().map(|b| b.to_vec()); - assert_eq!(decode("\"0x08c379a0\""), Some(vec![0x08, 0xc3, 0x79, 0xa0])); - assert_eq!(decode("{\"reason\":\"x\"}"), None); - assert_eq!(decode("\"not hex\""), None); - } - - #[tokio::test] - async fn rpc_error_data_is_hex_decoded_from_upstream() { - use wiremock::{Mock, MockServer, ResponseTemplate, matchers::any}; - - // The node returns a JSON-RPC `ErrorResp` with a hex `data` - // payload (the `eth_call` revert shape). The host must capture - // the code and the DECODED revert bytes on `ProviderError::Rpc`. - let revert_bytes = vec![0x08, 0xc3, 0x79, 0xa0, 0xde, 0xad, 0xbe, 0xef]; - let revert_hex = alloy_primitives::hex::encode_prefixed(&revert_bytes); - let server = MockServer::start().await; - Mock::given(any()) - .respond_with(ResponseTemplate::new(200).set_body_string(format!( - r#"{{"jsonrpc":"2.0","id":0,"error":{{"code":-32000,"message":"execution reverted","data":"{revert_hex}"}}}}"#, - ))) - .mount(&server) - .await; - - let cfg = test_config(Chain::from_id(1), &server.uri()); - let pool = ProviderPool::from_config(&cfg).await.unwrap(); - let err = pool - .request(Chain::from_id(1), ChainMethod::EthCall, "[]".into()) - .await - .unwrap_err(); - let ProviderError::Rpc { code, data, .. } = err else { - panic!("expected Rpc error, got: {err:?}"); - }; - assert_eq!(code, Some(-32000)); - assert_eq!(data, Some(revert_bytes)); - } - - #[tokio::test] - async fn request_times_out_when_node_hangs() { - use wiremock::{Mock, MockServer, ResponseTemplate, matchers::any}; - - let server = MockServer::start().await; - // Respond after 60 s - the pool is configured with a 1 s timeout, - // so `raw_request` is cancelled well before the body arrives. The - // large gap keeps the test from flaking on slow CI runners. - Mock::given(any()) - .respond_with( - ResponseTemplate::new(200) - .set_delay(Duration::from_secs(60)) - .set_body_string(r#"{"jsonrpc":"2.0","id":0,"result":"0x1"}"#), - ) - .mount(&server) - .await; - - let cfg = test_config_with_timeout(Chain::from_id(1), &server.uri(), 1); - let pool = ProviderPool::from_config(&cfg).await.unwrap(); - let err = pool - .request(Chain::from_id(1), ChainMethod::EthBlockNumber, "[]".into()) - .await - .unwrap_err(); - assert!( - matches!(err, ProviderError::Timeout { .. }), - "expected Timeout, got: {err:?}" - ); - } - - #[tokio::test] - async fn http_config_block_subscribe_takes_poll_path() { - use wiremock::{Mock, MockServer, ResponseTemplate, matchers::any}; - - // An HTTP transport has no pubsub, so `subscribe_blocks` must fall - // back to polling rather than erroring. The head fetch - // (`eth_blockNumber`) is the only call made at setup - the block - // poller stream is lazy - so one mocked response proves the poll - // path opens cleanly. - let server = MockServer::start().await; - Mock::given(any()) - .respond_with( - ResponseTemplate::new(200) - .set_body_string(r#"{"jsonrpc":"2.0","id":0,"result":"0x10"}"#), - ) - .mount(&server) - .await; - - let cfg = test_config(Chain::from_id(1), &server.uri()); - let pool = ProviderPool::from_config(&cfg).await.unwrap(); - // BlockStream doesn't impl Debug, so assert on `is_ok` rather than - // unwrapping. - assert!( - pool.subscribe_blocks(Chain::from_id(1)).await.is_ok(), - "http config should open the block poll path without erroring", - ); - } -} diff --git a/nexum/crates/nexum-runtime/src/host/remote_store_bee.rs b/nexum/crates/nexum-runtime/src/host/remote_store_bee.rs deleted file mode 100644 index 6fcbddeb..00000000 --- a/nexum/crates/nexum-runtime/src/host/remote_store_bee.rs +++ /dev/null @@ -1,407 +0,0 @@ -//! `remote-store` backend: the Swarm network over a Bee node's HTTP -//! API. Uploads and feed updates are stamped with the configured -//! postage batch; feed updates are signed host-side with the -//! configured feed key. - -use std::sync::Arc; - -use bee::swarm::{BatchId, EthAddress, PrivateKey, Reference, Topic}; - -use crate::engine_config::RemoteStoreSection; - -/// Canonical feed-update payload prefix: a big-endian unix timestamp. -const FEED_TIMESTAMP_LEN: usize = 8; - -/// Boot-time `[remote_store]` validation failures. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum RemoteStoreConfigError { - /// The Bee API base URL failed to parse. - #[error("remote-store api url: {0}")] - Api(bee::Error), - /// The postage batch id is not 32-byte hex. - #[error("remote-store postage_batch: {0}")] - PostageBatch(bee::Error), - /// The feed key is not a 32-byte hex private key. - #[error("remote-store feed_key: {0}")] - FeedKey(bee::Error), -} - -/// Runtime failures surfaced by [`RemoteStore`] operations. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum RemoteStoreError { - /// No `[remote_store]` table is configured. - #[error("remote-store is not configured")] - NotConfigured, - /// The operation stamps chunks but no postage batch is configured. - #[error("remote-store has no postage batch configured")] - NoPostageBatch, - /// `write-feed` needs a signing key and none is configured. - #[error("remote-store has no feed key configured")] - NoFeedKey, - /// A guest-supplied value has the wrong shape. - #[error("invalid {what}: {source}")] - Input { - /// Which argument was rejected. - what: &'static str, - /// The typed-byte constructor failure. - source: bee::Error, - }, - /// The referenced content did not resolve on the network. - #[error("reference {0} not found")] - NotFound(String), - /// A feed update shorter than the timestamp prefix. - #[error("malformed feed payload: {0} bytes")] - MalformedFeed(usize), - /// The Bee API refused or failed the request. - #[error("bee api: {0}")] - Api(bee::Error), -} - -/// The configured Bee endpoint plus its write credentials. -struct Backend { - client: bee::Client, - batch: Option, - feed_key: Option, -} - -/// Shared remote-store handle threaded into every module store; cheap -/// to clone. Unconfigured handles report [`RemoteStoreError::NotConfigured`] -/// on every operation. -#[derive(Clone)] -pub struct RemoteStore(Option>); - -impl std::fmt::Debug for RemoteStore { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_tuple("RemoteStore") - .field(&self.0.as_ref().map(|_| "bee")) - .finish() - } -} - -impl RemoteStore { - /// A handle with no backend: every operation reports - /// [`RemoteStoreError::NotConfigured`]. - pub fn disabled() -> Self { - Self(None) - } - - /// Open from the `[remote_store]` table; `None` yields a disabled - /// handle. - pub fn from_config( - section: Option<&RemoteStoreSection>, - ) -> Result { - let Some(section) = section else { - return Ok(Self::disabled()); - }; - let client = bee::Client::new(§ion.api).map_err(RemoteStoreConfigError::Api)?; - let batch = section - .postage_batch - .as_deref() - .map(BatchId::from_hex) - .transpose() - .map_err(RemoteStoreConfigError::PostageBatch)?; - let feed_key = section - .feed_key - .as_deref() - .map(PrivateKey::from_hex) - .transpose() - .map_err(RemoteStoreConfigError::FeedKey)?; - Ok(Self(Some(Arc::new(Backend { - client, - batch, - feed_key, - })))) - } - - fn backend(&self) -> Result<&Backend, RemoteStoreError> { - self.0.as_deref().ok_or(RemoteStoreError::NotConfigured) - } - - /// Upload raw data; returns the 32-byte content reference. - pub async fn upload(&self, data: Vec) -> Result, RemoteStoreError> { - let backend = self.backend()?; - let batch = backend - .batch - .as_ref() - .ok_or(RemoteStoreError::NoPostageBatch)?; - let result = backend - .client - .file() - .upload_data(batch, data, None) - .await - .map_err(RemoteStoreError::Api)?; - Ok(result.reference.to_vec()) - } - - /// Download raw data by content reference. - pub async fn download(&self, reference: Vec) -> Result, RemoteStoreError> { - let backend = self.backend()?; - let reference = Reference::new(&reference).map_err(|source| RemoteStoreError::Input { - what: "reference", - source, - })?; - match backend.client.file().download_data(&reference, None).await { - Ok(bytes) => Ok(bytes.to_vec()), - Err(e) if e.status() == Some(404) => { - Err(RemoteStoreError::NotFound(reference.to_hex())) - } - Err(e) => Err(RemoteStoreError::Api(e)), - } - } - - /// Latest value of the `(owner, topic)` feed, with the canonical - /// timestamp prefix stripped. `Ok(None)` on a lookup miss (Bee - /// reports a miss as 404 or 500). - pub async fn read_feed( - &self, - owner: Vec, - topic: Vec, - ) -> Result>, RemoteStoreError> { - let backend = self.backend()?; - let owner = EthAddress::new(&owner).map_err(|source| RemoteStoreError::Input { - what: "owner", - source, - })?; - let topic = Topic::new(&topic).map_err(|source| RemoteStoreError::Input { - what: "topic", - source, - })?; - match backend - .client - .file() - .fetch_latest_feed_update(&owner, &topic) - .await - { - Ok(update) => match update.payload.get(FEED_TIMESTAMP_LEN..) { - Some(data) => Ok(Some(data.to_vec())), - None => Err(RemoteStoreError::MalformedFeed(update.payload.len())), - }, - Err(e) if matches!(e.status(), Some(404 | 500)) => Ok(None), - Err(e) => Err(RemoteStoreError::Api(e)), - } - } - - /// Publish `data` as the next update of the configured identity's - /// `topic` feed; returns the update's chunk reference. - pub async fn write_feed( - &self, - topic: Vec, - data: Vec, - ) -> Result, RemoteStoreError> { - let backend = self.backend()?; - let batch = backend - .batch - .as_ref() - .ok_or(RemoteStoreError::NoPostageBatch)?; - let key = backend - .feed_key - .as_ref() - .ok_or(RemoteStoreError::NoFeedKey)?; - let topic = Topic::new(&topic).map_err(|source| RemoteStoreError::Input { - what: "topic", - source, - })?; - let result = backend - .client - .file() - .update_feed(batch, key, &topic, &data) - .await - .map_err(RemoteStoreError::Api)?; - Ok(result.reference.to_vec()) - } -} - -#[cfg(test)] -mod tests { - use serde_json::json; - use wiremock::matchers::{header, method, path, path_regex}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - use super::*; - - const BATCH_HEX: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - const REF_HEX: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const KEY_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; - - fn section(api: &str, batch: bool, key: bool) -> RemoteStoreSection { - RemoteStoreSection { - api: api.to_owned(), - postage_batch: batch.then(|| BATCH_HEX.to_owned()), - feed_key: key.then(|| KEY_HEX.to_owned()), - } - } - - fn store(api: &str, batch: bool, key: bool) -> RemoteStore { - RemoteStore::from_config(Some(§ion(api, batch, key))).expect("valid config") - } - - #[tokio::test] - async fn upload_stamps_and_returns_the_reference() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/bytes")) - .and(header("swarm-postage-batch-id", BATCH_HEX)) - .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "reference": REF_HEX }))) - .mount(&server) - .await; - - let reference = store(&server.uri(), true, false) - .upload(b"payload".to_vec()) - .await - .expect("upload"); - assert_eq!(reference, [0xaa; 32]); - } - - #[tokio::test] - async fn upload_without_a_batch_is_refused() { - let server = MockServer::start().await; - let err = store(&server.uri(), false, false) - .upload(b"payload".to_vec()) - .await - .expect_err("no batch"); - assert!(matches!(err, RemoteStoreError::NoPostageBatch), "{err}"); - } - - #[tokio::test] - async fn download_returns_the_bytes() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path(format!("/bytes/{REF_HEX}"))) - .respond_with(ResponseTemplate::new(200).set_body_bytes(b"payload".to_vec())) - .mount(&server) - .await; - - let data = store(&server.uri(), false, false) - .download(vec![0xaa; 32]) - .await - .expect("download"); - assert_eq!(data, b"payload"); - } - - #[tokio::test] - async fn download_miss_is_not_found() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .respond_with(ResponseTemplate::new(404)) - .mount(&server) - .await; - - let err = store(&server.uri(), false, false) - .download(vec![0xaa; 32]) - .await - .expect_err("missing reference"); - assert!(matches!(err, RemoteStoreError::NotFound(_)), "{err}"); - } - - #[tokio::test] - async fn download_rejects_a_malformed_reference() { - let server = MockServer::start().await; - let err = store(&server.uri(), false, false) - .download(vec![0xaa; 3]) - .await - .expect_err("short reference"); - assert!( - matches!( - err, - RemoteStoreError::Input { - what: "reference", - .. - } - ), - "{err}" - ); - } - - #[tokio::test] - async fn read_feed_strips_the_timestamp_prefix() { - let server = MockServer::start().await; - let mut payload = 7_u64.to_be_bytes().to_vec(); - payload.extend_from_slice(b"latest"); - Mock::given(method("GET")) - .and(path_regex("^/feeds/[0-9a-f]{40}/[0-9a-f]{64}$")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("swarm-feed-index", "0000000000000005") - .insert_header("swarm-feed-index-next", "0000000000000006") - .set_body_bytes(payload), - ) - .mount(&server) - .await; - - let value = store(&server.uri(), false, false) - .read_feed(vec![0x11; 20], vec![0x22; 32]) - .await - .expect("read feed"); - assert_eq!(value.as_deref(), Some(b"latest".as_slice())); - } - - #[tokio::test] - async fn read_feed_miss_is_none() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .respond_with(ResponseTemplate::new(404)) - .mount(&server) - .await; - - let value = store(&server.uri(), false, false) - .read_feed(vec![0x11; 20], vec![0x22; 32]) - .await - .expect("read feed"); - assert_eq!(value, None); - } - - #[tokio::test] - async fn write_feed_signs_the_next_update() { - let server = MockServer::start().await; - // No prior update: the writer starts at index 0. - Mock::given(method("GET")) - .and(path_regex("^/feeds/")) - .respond_with(ResponseTemplate::new(404)) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path_regex("^/soc/[0-9a-f]{40}/[0-9a-f]{64}$")) - .and(header("swarm-postage-batch-id", BATCH_HEX)) - .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "reference": REF_HEX }))) - .mount(&server) - .await; - - let reference = store(&server.uri(), true, true) - .write_feed(vec![0x22; 32], b"value".to_vec()) - .await - .expect("write feed"); - assert_eq!(reference, [0xaa; 32]); - } - - #[tokio::test] - async fn write_feed_without_a_key_is_refused() { - let server = MockServer::start().await; - let err = store(&server.uri(), true, false) - .write_feed(vec![0x22; 32], b"value".to_vec()) - .await - .expect_err("no key"); - assert!(matches!(err, RemoteStoreError::NoFeedKey), "{err}"); - } - - #[tokio::test] - async fn disabled_handle_reports_not_configured() { - let err = RemoteStore::disabled() - .upload(b"payload".to_vec()) - .await - .expect_err("disabled"); - assert!(matches!(err, RemoteStoreError::NotConfigured), "{err}"); - } - - #[test] - fn bad_config_fails_at_boot() { - let mut bad = section("http://localhost:1633", true, false); - bad.postage_batch = Some("nothex".to_owned()); - let err = RemoteStore::from_config(Some(&bad)).expect_err("bad batch"); - assert!( - matches!(err, RemoteStoreConfigError::PostageBatch(_)), - "{err}" - ); - } -} diff --git a/nexum/crates/nexum-runtime/src/host/state.rs b/nexum/crates/nexum-runtime/src/host/state.rs deleted file mode 100644 index f6410504..00000000 --- a/nexum/crates/nexum-runtime/src/host/state.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Per-instance host state and its WASI view. -//! -//! One [`HostState`] is created per module, lives inside the wasmtime -//! `Store`, and is the receiver every `Host` trait impl in -//! `super::impls` is implemented for. - -use std::sync::Arc; - -use wasmtime::component::ResourceTable; -use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; -use wasmtime_wasi_http::WasiHttpCtx; - -use super::component::{Handle, RuntimeTypes}; -use super::extension::HostServices; -use super::http::HttpGate; -use super::logs::{LogRouter, RunId}; -use super::remote_store_bee::RemoteStore; - -/// Per-module host state, generic over the [`RuntimeTypes`] lattice -/// binding the backend seams. The composition root supplies the -/// concrete assembly. -pub struct HostState { - pub wasi: WasiCtx, - pub table: ResourceTable, - /// Wasmtime memory/table/instance resource limits for this store. - pub limits: wasmtime::StoreLimits, - /// Per-store wasi:http context. - pub http_ctx: WasiHttpCtx, - /// Per-module allowlist gate every wasi:http outgoing request - /// passes through. - pub http_gate: HttpGate, - /// Messaging content topics this store may publish to. Empty means - /// unscoped (the module default and current messaging posture); a - /// provider carries its `[[adapters]].messaging_topics` grant here, - /// so an out-of-scope publish is refused before it reaches the - /// backend. - pub messaging_topics: Vec, - /// Identity of this store's run: module namespace plus the restart - /// sequence. Tags every captured log record. The namespace identity - /// for storage is baked into `store`'s prefix. - pub run: RunId, - /// Shared log pipeline the `nexum:host/logging` glue routes through. - pub log_router: Arc, - /// Extension backends (the lattice `Ext` payload). Reached generically - /// by an extension's `Host` impl through [`ExtState`]. - pub ext: T::Ext, - /// `chain` backend - per-chain alloy `DynProvider` pool. - pub chain: T::Chain, - /// Host-enforced cap on a single chain JSON-RPC response body. - /// Responses larger than this are rejected before they reach the guest. - pub chain_response_max_bytes: usize, - /// `local-store` backend - per-module handle with pre-computed - /// keccak256 namespace prefix. - pub store: Handle, - /// `remote-store` backend - shared Swarm handle over a Bee node. - pub remote: RemoteStore, - /// Extension-owned host services, keyed by extension namespace and - /// downcast at the call site. One shared map across every module store; - /// a provider store carries an empty map. - pub services: HostServices, -} - -// `WasiView: Send`, so the backends must be `Send` too; the lattice -// supertraits already guarantee it. -impl WasiView for HostState { - fn ctx(&mut self) -> WasiCtxView<'_> { - WasiCtxView { - ctx: &mut self.wasi, - table: &mut self.table, - } - } -} - -/// Generic access to the extension state slot of a host state. -/// -/// An extension crate implements its bindgen-local `Host` trait for the -/// foreign `HostState` (orphan-legal: the trait is local to the -/// extension) and reaches its own payload through this accessor, without -/// naming the concrete lattice `T`. The extension then bounds the payload -/// on its own trait to extract its backend. -pub trait ExtState { - /// The extension payload type (the lattice `Ext` member). - type Ext; - /// Borrow the extension payload. - fn ext(&self) -> &Self::Ext; -} - -impl ExtState for HostState { - type Ext = T::Ext; - fn ext(&self) -> &Self::Ext { - &self.ext - } -} diff --git a/nexum/crates/nexum-runtime/src/lib.rs b/nexum/crates/nexum-runtime/src/lib.rs deleted file mode 100644 index 4a29dd47..00000000 --- a/nexum/crates/nexum-runtime/src/lib.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Nexum runtime: a wasmtime-based host for WASM Component Model -//! modules, usable as an embeddable library. The bundled binary is a -//! thin consumer of the same public surface. -//! -//! Zero-leak charter: this crate is settlement-domain-agnostic. It -//! carries no domain symbol or WIT reference, `nexum:host` stays a -//! leaf WIT package, and no crate edge reaches a domain crate. The -//! zero-leak script under `scripts/` enforces this in CI. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] - -// alloy split its API across multiple crates; we depend on the -// transports directly so cargo resolves the right feature set, but -// the runtime code only names them through the `alloy_provider` -// re-exports. Silence `unused_crate_dependencies` with `as _`. -use alloy_rpc_client as _; -use alloy_transport as _; -use alloy_transport_ws as _; - -/// Sealing markers for [`preset::Runtime`] and -/// [`host::component::RuntimeTypes`]: implement alongside the trait. -#[doc(hidden)] -pub mod sealed { - pub trait SealedRuntimeTypes {} - pub trait SealedRuntime {} -} - -pub mod addons; -pub mod bindings; -pub mod bootstrap; -pub mod builder; -pub mod engine_config; -pub mod host; -pub mod manifest; -pub mod preset; -pub mod runtime; -pub mod supervisor; - -#[cfg(feature = "test-utils")] -pub mod test_utils; diff --git a/nexum/crates/nexum-runtime/src/manifest/capabilities.rs b/nexum/crates/nexum-runtime/src/manifest/capabilities.rs deleted file mode 100644 index 3492634f..00000000 --- a/nexum/crates/nexum-runtime/src/manifest/capabilities.rs +++ /dev/null @@ -1,569 +0,0 @@ -//! Capability enforcement: cross-checks the component's WIT imports -//! against the `[capabilities]` block declared in `module.toml`. -//! -//! The set of recognised capabilities is not fixed: the core namespace is -//! built in, and each runtime extension contributes its own namespace at -//! the composition root via [`CapabilityRegistry::register`]. An extension -//! interface is enforceable only once its namespace is registered. -//! -//! Components built through `#[nexum_sdk::module]` compile against a -//! per-module world derived from the same manifest, so their imports -//! equal their declarations by construction and this check is a pure -//! backstop for them; it retains its teeth for components built against -//! a wider world by hand, where nothing upstream narrows the imports. -//! -//! The WASI surface is gated the same way: io/clocks/random and all of -//! `wasi:cli` are ambient, `wasi:sockets` and `wasi:filesystem` are opt-in -//! via the `wasi-*` capabilities, and any other `wasi:` interface is -//! refused fail-closed. - -use std::collections::HashSet; - -use super::error::{CapabilityError, CapabilityViolation}; -use super::types::{CORE_CAPABILITIES, LoadedManifest}; - -/// One WIT namespace prefix plus the interface names under it that count as -/// capabilities. Core registers `nexum:host/`; an extension registers its -/// own. -#[derive(Clone, Copy)] -pub struct NamespaceCaps { - /// Interface-name prefix, e.g. `"nexum:host/"`. - pub prefix: &'static str, - /// Interface names under `prefix` that are capabilities. - pub ifaces: &'static [&'static str], -} - -/// The core namespace: the interfaces the `event-module` world links. -pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { - prefix: "nexum:host/", - ifaces: CORE_CAPABILITIES, -}; - -/// The interfaces a provider world links: the scoped transport only. A -/// provider has no local-store, remote-store, identity, or logging - it -/// moves bytes to and from its counterparty and nothing else. `http` is -/// not listed here for the same reason it is not in the core set: it -/// gates `wasi:http/*` and is handled by the registry directly. -pub const PROVIDER_CAPABILITIES: &[&str] = - &[nexum_world::caps::CHAIN, nexum_world::caps::MESSAGING]; - -/// The provider namespace: the same `nexum:host/` prefix as core but only -/// the scoped-transport interfaces. Validating a provider manifest against -/// a registry built from this namespace rejects a declaration of any core -/// interface a provider must not reach (e.g. `local-store`) as unknown. -pub const PROVIDER_NAMESPACE: NamespaceCaps = NamespaceCaps { - prefix: "nexum:host/", - ifaces: PROVIDER_CAPABILITIES, -}; - -/// Import prefix of the wasi:http package. Every interface under it -/// (outgoing-handler, types, ...) is gated by the single -/// [`HTTP_CAPABILITY`] declaration. -const WASI_HTTP_PREFIX: &str = "wasi:http/"; - -/// Capability name a module declares to import any `wasi:http/*` -/// interface; the per-module `[capabilities.http].allow` list scopes it. -const HTTP_CAPABILITY: &str = nexum_world::caps::HTTP; - -/// Gated WASI capability names. Declaring one grants the matching `wasi:` -/// interface group; see [`classify_wasi`]. `wasi:io`, `wasi:clocks`, -/// `wasi:random` and all of `wasi:cli` (environment included; the host -/// populates it empty) are ambient and need no declaration. -const WASI_CAPABILITIES: &[&str] = &["wasi-sockets", "wasi-filesystem"]; - -/// A `wasi:` import (other than `wasi:http`) classified against the gate. -enum WasiGate { - /// Always linked, never declared: io, clocks, random, stdio/exit/terminal. - Ambient, - /// Usable only when the named capability is declared. - Gated(&'static str), - /// Unrecognised `wasi:` interface: refused fail-closed. - Unknown, -} - -/// Classify a non-http `wasi:` interface id, ignoring any `@version` suffix. -fn classify_wasi(import_name: &str) -> WasiGate { - let iface = import_name.split('@').next().unwrap_or(import_name); - if iface.starts_with("wasi:io/") - || iface.starts_with("wasi:clocks/") - || iface.starts_with("wasi:random/") - { - WasiGate::Ambient - } else if iface.starts_with("wasi:filesystem/") { - WasiGate::Gated("wasi-filesystem") - } else if iface.starts_with("wasi:sockets/") { - WasiGate::Gated("wasi-sockets") - } else if iface.starts_with("wasi:cli/") { - WasiGate::Ambient - } else { - WasiGate::Unknown - } -} - -/// Registry of capability namespaces recognised by enforcement. Built from -/// the core namespace plus every registered extension. -#[derive(Clone)] -pub struct CapabilityRegistry { - namespaces: Vec, -} - -impl Default for CapabilityRegistry { - fn default() -> Self { - Self::core() - } -} - -impl CapabilityRegistry { - /// The registry with the core `nexum:host/` namespace. - pub fn core() -> Self { - Self { - namespaces: vec![CORE_NAMESPACE], - } - } - - /// The registry a provider validates against: only the scoped - /// transport interfaces plus `http`. A provider manifest that declares - /// a core-only capability (e.g. `local-store`) fails as unknown here, - /// and the provider linker withholds the same interfaces so the - /// component cannot instantiate against them either. - pub fn provider() -> Self { - Self { - namespaces: vec![PROVIDER_NAMESPACE], - } - } - - /// Add an extension's namespace. - pub fn register(&mut self, ns: NamespaceCaps) { - self.namespaces.push(ns); - } - - /// Whether `name` is a capability under any registered namespace. - /// Used to validate declared capability names in a manifest. - pub fn is_known(&self, name: &str) -> bool { - name == HTTP_CAPABILITY - || WASI_CAPABILITIES.contains(&name) - || self.namespaces.iter().any(|ns| ns.ifaces.contains(&name)) - } - - /// Comma-joined recognised capability names, for error messages. - pub fn known_names(&self) -> String { - self.namespaces - .iter() - .flat_map(|ns| ns.ifaces.iter().copied()) - .chain(std::iter::once(HTTP_CAPABILITY)) - .chain(WASI_CAPABILITIES.iter().copied()) - .collect::>() - .join(", ") - } - - /// Map a WIT import name to a capability name, or `None` for - /// non-capability imports. - /// - /// Returns `Some(iface)` only for interfaces under a registered - /// namespace, plus `Some("http")` for anything under `wasi:http/`; - /// type-only packages like `nexum:host/types` and the remaining - /// `wasi:*` namespaces fall through to `None` so they do not need a - /// manifest declaration. - /// - /// Examples: - /// - `"nexum:host/chain@0.1.0"` -> `Some("chain")` - /// - `"test:acme/acme-api@0.1.0"` -> `Some("acme-api")` once that - /// namespace is registered - /// - `"wasi:http/outgoing-handler@0.2.12"` -> `Some("http")` - /// - `"nexum:host/types@0.1.0"` -> `None` (type-only, not a capability) - /// - `"wasi:io/streams@0.2.0"` -> `None` - pub fn wit_import_to_cap<'a>(&self, import_name: &'a str) -> Option<&'a str> { - let without_version = import_name.split('@').next().unwrap_or(import_name); - if without_version.starts_with(WASI_HTTP_PREFIX) { - return Some(HTTP_CAPABILITY); - } - for ns in &self.namespaces { - if let Some(iface) = without_version.strip_prefix(ns.prefix) - && ns.ifaces.contains(&iface) - { - return Some(iface); - } - } - None - } -} - -/// Check that every capability-bearing WIT import of the component is covered -/// by the module's manifest declarations. Call after loading the component, -/// before instantiation. -/// -/// The WASI surface is gated fail-closed. With `[capabilities]` absent -/// (0.1-fallback) the registry surface stays permissive and load warns. -/// -/// `component_imports` is the name part of each import from -/// `component.component_type().imports(&engine)`. `registry` carries the -/// core namespace plus any extension namespaces. -pub fn enforce_capabilities<'a>( - loaded: &LoadedManifest, - component_imports: impl Iterator, - registry: &CapabilityRegistry, -) -> Result<(), CapabilityError> { - let caps = loaded.manifest.capabilities.as_ref(); - let fallback = caps.is_none(); - let declared: HashSet<&str> = caps - .into_iter() - .flat_map(|c| c.required.iter().chain(c.optional.iter())) - .map(String::as_str) - .collect(); - - for import_name in component_imports { - let without_version = import_name.split('@').next().unwrap_or(import_name); - // `wasi:http` is gated by the registry below; the rest of the WASI - // surface is gated here, fail-closed even in 0.1-fallback. - if without_version.starts_with("wasi:") && !without_version.starts_with(WASI_HTTP_PREFIX) { - match classify_wasi(import_name) { - WasiGate::Ambient => {} - WasiGate::Gated(cap) if declared.contains(cap) => {} - WasiGate::Gated(cap) => { - return Err(CapabilityViolation { - capability: cap.to_owned(), - wit_import: import_name.to_owned(), - } - .into()); - } - WasiGate::Unknown => { - return Err(CapabilityError::UnknownWasi { - wit_import: import_name.to_owned(), - }); - } - } - continue; - } - // Registry surface stays permissive in 0.1-fallback. - if fallback { - continue; - } - if let Some(cap) = registry.wit_import_to_cap(import_name) - && !declared.contains(cap) - { - return Err(CapabilityViolation { - capability: cap.to_owned(), - wit_import: import_name.to_owned(), - } - .into()); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::manifest::types::{CapabilitiesSection, Manifest}; - - /// A registry with one extension namespace registered, mirroring - /// what a composition root assembles. - fn registry_with_ext() -> CapabilityRegistry { - let mut r = CapabilityRegistry::core(); - r.register(NamespaceCaps { - prefix: "test:acme/", - ifaces: &["acme-api"], - }); - r - } - - #[test] - fn wit_import_to_cap_nexum_host() { - let r = CapabilityRegistry::core(); - assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.1.0"), Some("chain")); - assert_eq!( - r.wit_import_to_cap("nexum:host/local-store@0.1.0"), - Some("local-store") - ); - } - - #[test] - fn wit_import_to_cap_wasi_http_maps_to_http() { - let r = CapabilityRegistry::core(); - assert_eq!( - r.wit_import_to_cap("wasi:http/outgoing-handler@0.2.12"), - Some("http") - ); - assert_eq!(r.wit_import_to_cap("wasi:http/types@0.2.12"), Some("http")); - // Version-agnostic: the prefix decides, not the pinned version. - assert_eq!( - r.wit_import_to_cap("wasi:http/outgoing-handler@0.2.0"), - Some("http") - ); - assert_eq!(r.wit_import_to_cap("wasi:http/types"), Some("http")); - } - - #[test] - fn http_is_a_known_capability_name() { - let r = CapabilityRegistry::core(); - assert!(r.is_known("http")); - assert!(r.known_names().split(", ").any(|n| n == "http")); - } - - #[test] - fn wit_import_to_cap_extension_needs_registration() { - // Core registry does not recognise an extension namespace. - let core = CapabilityRegistry::core(); - assert_eq!(core.wit_import_to_cap("test:acme/acme-api@0.1.0"), None); - // Once registered, it resolves. - let r = registry_with_ext(); - assert_eq!( - r.wit_import_to_cap("test:acme/acme-api@0.1.0"), - Some("acme-api") - ); - } - - #[test] - fn wit_import_to_cap_non_http_wasi_is_none() { - let r = registry_with_ext(); - assert_eq!(r.wit_import_to_cap("wasi:io/streams@0.2.0"), None); - assert_eq!(r.wit_import_to_cap("wasi:cli/stdin@0.2.0"), None); - assert_eq!(r.wit_import_to_cap("wasi:sockets/tcp@0.2.0"), None); - } - - fn manifest_with_caps(required: &[&str], optional: &[&str]) -> LoadedManifest { - LoadedManifest { - manifest: Manifest { - capabilities: Some(CapabilitiesSection { - required: required.iter().map(|s| s.to_string()).collect(), - optional: optional.iter().map(|s| s.to_string()).collect(), - http: None, - }), - ..Default::default() - }, - http_allowlist: vec![], - config: vec![], - } - } - - fn manifest_no_caps() -> LoadedManifest { - LoadedManifest { - manifest: Manifest::default(), - http_allowlist: vec![], - config: vec![], - } - } - - #[test] - fn enforce_passes_when_caps_absent() { - // 0.1-fallback: no capabilities section -> all imports allowed - let loaded = manifest_no_caps(); - let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; - let r = registry_with_ext(); - assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); - } - - #[test] - fn enforce_passes_when_all_imports_declared() { - let loaded = manifest_with_caps(&["chain", "acme-api"], &["http"]); - let imports = [ - "nexum:host/chain@0.1.0", - "test:acme/acme-api@0.1.0", - "wasi:http/outgoing-handler@0.2.12", - "wasi:io/streams@0.2.0", // non-http wasi is always skipped - ]; - let r = registry_with_ext(); - assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); - } - - #[test] - fn enforce_rejects_wasi_http_import_without_declaration() { - let loaded = manifest_with_caps(&["chain"], &[]); - let imports = [ - "nexum:host/chain@0.1.0", - "wasi:http/outgoing-handler@0.2.12", - ]; - let r = registry_with_ext(); - let err = enforce_capabilities(&loaded, imports.into_iter(), &r).unwrap_err(); - let CapabilityError::Undeclared(v) = err else { - panic!("expected undeclared: {err:?}") - }; - assert_eq!(v.capability, "http"); - assert_eq!(v.wit_import, "wasi:http/outgoing-handler@0.2.12"); - } - - #[test] - fn enforce_accepts_wasi_http_when_http_declared() { - // Required and optional declarations both cover the import. - for (required, optional) in [(&["http"][..], &[][..]), (&[][..], &["http"][..])] { - let loaded = manifest_with_caps(required, optional); - let imports = [ - "wasi:http/outgoing-handler@0.2.12", - "wasi:http/types@0.2.12", - ]; - let r = registry_with_ext(); - assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); - } - } - - #[test] - fn enforce_rejects_undeclared_import() { - let loaded = manifest_with_caps(&["chain"], &[]); - // module imports remote-store but didn't declare it - let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; - let r = registry_with_ext(); - let err = enforce_capabilities(&loaded, imports.into_iter(), &r).unwrap_err(); - let CapabilityError::Undeclared(v) = err else { - panic!("expected undeclared: {err:?}") - }; - assert_eq!(v.capability, "remote-store"); - } - - #[test] - fn enforce_optional_caps_are_also_allowed() { - let loaded = manifest_with_caps(&["chain"], &["remote-store"]); - let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; - let r = registry_with_ext(); - assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); - } - - #[test] - fn provider_registry_knows_only_scoped_transport() { - // The scoped transport plus http are known; the core-only - // interfaces a provider must not reach are not, so a manifest - // declaring them fails validation as unknown. - let r = CapabilityRegistry::provider(); - assert!(r.is_known("chain")); - assert!(r.is_known("messaging")); - assert!(r.is_known("http")); - assert!(!r.is_known("local-store")); - assert!(!r.is_known("remote-store")); - assert!(!r.is_known("identity")); - assert!(!r.is_known("logging")); - } - - #[test] - fn provider_registry_maps_transport_imports_but_not_core_only() { - let r = CapabilityRegistry::provider(); - assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.1.0"), Some("chain")); - assert_eq!( - r.wit_import_to_cap("nexum:host/messaging@0.1.0"), - Some("messaging") - ); - assert_eq!( - r.wit_import_to_cap("wasi:http/outgoing-handler@0.2.12"), - Some("http") - ); - // A core-only interface is not a recognised provider capability. - assert_eq!(r.wit_import_to_cap("nexum:host/local-store@0.1.0"), None); - } - - #[test] - fn provider_manifest_declaring_a_core_only_cap_is_unknown() { - // The load path validates declared names against the registry; an - // provider declaring `local-store` must surface as unknown. - let r = CapabilityRegistry::provider(); - assert!(!r.is_known("local-store")); - assert!(r.known_names().split(", ").all(|n| n != "local-store")); - } - - #[test] - fn ambient_wasi_needs_no_declaration() { - let loaded = manifest_with_caps(&["logging"], &[]); - let imports = [ - "wasi:io/streams@0.2.6", - "wasi:io/poll@0.2.6", - "wasi:clocks/monotonic-clock@0.2.6", - "wasi:clocks/wall-clock@0.2.6", - "wasi:random/random@0.2.6", - "wasi:cli/stdout@0.2.6", - "wasi:cli/stdin@0.2.6", - "wasi:cli/stderr@0.2.6", - "wasi:cli/exit@0.2.6", - "wasi:cli/terminal-stdout@0.2.6", - "wasi:cli/environment@0.2.6", - ]; - let r = registry_with_ext(); - assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); - } - - #[test] - fn undeclared_gated_wasi_is_refused() { - let loaded = manifest_with_caps(&["logging"], &[]); - let r = registry_with_ext(); - for (import, cap) in [ - ("wasi:sockets/tcp@0.2.6", "wasi-sockets"), - ("wasi:filesystem/types@0.2.6", "wasi-filesystem"), - ] { - let err = enforce_capabilities(&loaded, [import].into_iter(), &r).unwrap_err(); - let CapabilityError::Undeclared(v) = err else { - panic!("expected undeclared for {import}: {err:?}") - }; - assert_eq!(v.capability, cap); - assert_eq!(v.wit_import, import); - } - } - - #[test] - fn declared_gated_wasi_is_permitted() { - let loaded = manifest_with_caps(&["wasi-sockets", "wasi-filesystem"], &[]); - let imports = [ - "wasi:sockets/tcp@0.2.6", - "wasi:sockets/udp@0.2.6", - "wasi:filesystem/types@0.2.6", - "wasi:filesystem/preopens@0.2.6", - ]; - let r = registry_with_ext(); - assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); - } - - #[test] - fn declaring_one_gated_cap_does_not_grant_another() { - let loaded = manifest_with_caps(&["wasi-filesystem"], &[]); - let r = registry_with_ext(); - assert!( - enforce_capabilities(&loaded, ["wasi:filesystem/types@0.2.6"].into_iter(), &r).is_ok() - ); - assert!(enforce_capabilities(&loaded, ["wasi:sockets/tcp@0.2.6"].into_iter(), &r).is_err()); - } - - #[test] - fn unknown_wasi_interface_is_refused_fail_closed() { - // Even with an unrelated gated cap declared, an unrecognised wasi: - // namespace is denied outright. - let loaded = manifest_with_caps(&["wasi-sockets"], &[]); - let r = registry_with_ext(); - let err = - enforce_capabilities(&loaded, ["wasi:nn/tensor@0.2.0"].into_iter(), &r).unwrap_err(); - assert!(matches!(err, CapabilityError::UnknownWasi { .. })); - } - - #[test] - fn wasi_gate_ignores_version_suffix() { - let declared = manifest_with_caps(&["wasi-sockets"], &[]); - let none = manifest_with_caps(&["logging"], &[]); - let r = registry_with_ext(); - assert!(enforce_capabilities(&declared, ["wasi:sockets/tcp"].into_iter(), &r).is_ok()); - assert!( - enforce_capabilities(&declared, ["wasi:sockets/tcp@0.2.6"].into_iter(), &r).is_ok() - ); - assert!(enforce_capabilities(&none, ["wasi:filesystem/types"].into_iter(), &r).is_err()); - } - - #[test] - fn fallback_gates_wasi_but_stays_permissive_on_registry_surface() { - // No [capabilities] section -> 0.1-fallback: registry imports pass, - // but the WASI surface is still gated fail-closed. - let loaded = manifest_no_caps(); - let r = registry_with_ext(); - assert!( - enforce_capabilities(&loaded, ["nexum:host/remote-store@0.1.0"].into_iter(), &r) - .is_ok() - ); - assert!(enforce_capabilities(&loaded, ["wasi:io/streams@0.2.6"].into_iter(), &r).is_ok()); - assert!(enforce_capabilities(&loaded, ["wasi:sockets/tcp@0.2.6"].into_iter(), &r).is_err()); - assert!(matches!( - enforce_capabilities(&loaded, ["wasi:nn/tensor@0.2.0"].into_iter(), &r).unwrap_err(), - CapabilityError::UnknownWasi { .. } - )); - } - - #[test] - fn wasi_capability_names_are_known() { - let r = registry_with_ext(); - for cap in ["wasi-sockets", "wasi-filesystem"] { - assert!(r.is_known(cap), "{cap} missing from known set"); - assert!(r.known_names().split(", ").any(|n| n == cap)); - } - } -} diff --git a/nexum/crates/nexum-runtime/src/manifest/error.rs b/nexum/crates/nexum-runtime/src/manifest/error.rs deleted file mode 100644 index ed5858c7..00000000 --- a/nexum/crates/nexum-runtime/src/manifest/error.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! Error types for manifest parsing and capability enforcement. - -use strum::IntoStaticStr; -use thiserror::Error; - -/// Errors returned while loading or validating a manifest. -/// -/// `IntoStaticStr` exposes the snake_case variant name as a -/// `&'static str` for the manifest-loader's `tracing::warn!` / -/// `metrics::counter!` call sites. -#[derive(Debug, Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum ParseError { - /// Failed to read the manifest file from disk. - #[error("manifest: i/o: {0}")] - Io(#[from] std::io::Error), - /// Manifest file was not valid TOML. - #[error("manifest: parse: {0}")] - Toml(#[from] toml::de::Error), - /// `[capabilities].required` or `.optional` listed a capability - /// the engine does not recognise. `known` is the comma-joined set of - /// core plus registered-extension capabilities at validation time. - #[error("manifest: unknown capability {name:?} in [capabilities] (known: {known})")] - UnknownCapability { - /// The unrecognised capability name. - name: String, - /// Comma-joined recognised capability names. - known: String, - }, - /// `[module].name` is not a single safe path component; it must not - /// contain `/`, `\`, or `..` so it cannot escape the state directory. - #[error("manifest: [module].name {0:?} must not contain '/', '\\', or '..'")] - InvalidModuleName(String), -} - -/// A capability-bearing WIT import the manifest did not declare. -#[derive(Debug, Error)] -#[error( - "component imports `{capability}` ({wit_import}) but it is not listed in \ - [capabilities].required or [capabilities].optional" -)] -pub struct CapabilityViolation { - /// Capability name (e.g. `"remote-store"`). - pub capability: String, - /// Full WIT import name as it appeared in the component (e.g. - /// `"nexum:host/remote-store@0.1.0"`). - pub wit_import: String, -} - -/// Error returned when a component's WIT imports exceed its declared -/// capabilities. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum CapabilityError { - /// A gated import was not declared in `[capabilities]`. - #[error(transparent)] - Undeclared(#[from] CapabilityViolation), - /// An unrecognised `wasi:` interface was imported; refused fail-closed. - #[error( - "component imports unrecognised WASI interface `{wit_import}`; \ - undeclared WASI is refused by default" - )] - UnknownWasi { - /// Full WIT import name. - wit_import: String, - }, -} diff --git a/nexum/crates/nexum-runtime/src/manifest/load.rs b/nexum/crates/nexum-runtime/src/manifest/load.rs deleted file mode 100644 index a7a70e98..00000000 --- a/nexum/crates/nexum-runtime/src/manifest/load.rs +++ /dev/null @@ -1,474 +0,0 @@ -//! Parse `module.toml` from disk, validate, and emit operator-visible -//! warnings. -//! -//! Also exposes the host-matching helper the wasi:http gate uses to -//! enforce the manifest's `[capabilities.http].allow` list at request -//! time. - -use std::path::Path; - -use tracing::{info, warn}; - -use super::capabilities::CapabilityRegistry; -use super::error::ParseError; -use super::types::{LoadedManifest, Manifest}; - -/// Read `module.toml` from `path`, parse, validate, and emit a deprecation -/// warning if `[capabilities]` is absent (0.1-compat fallback). Declared -/// capability names are validated against `registry`, so extension -/// capabilities are recognised only once their namespace is registered. -pub fn load(path: &Path, registry: &CapabilityRegistry) -> Result { - let raw = std::fs::read_to_string(path)?; - let manifest: Manifest = toml::from_str(&raw)?; - - validate_module_name(&manifest.module.name)?; - - let caps = manifest.capabilities.as_ref(); - if caps.is_none() { - warn!( - target: "manifest", - "no [capabilities] section in module.toml - defaulting to \ - all-required (0.1 behaviour). This default will be removed \ - in 0.3; add an explicit [capabilities] block." - ); - } - - if let Some(c) = caps { - for name in c.required.iter().chain(c.optional.iter()) { - if !registry.is_known(name) { - return Err(ParseError::UnknownCapability { - name: name.clone(), - known: registry.known_names(), - }); - } - } - if !c.required.is_empty() { - info!(target: "manifest", required = %c.required.join(", "), "required capabilities"); - } - if !c.optional.is_empty() { - info!( - target: "manifest", - optional = %c.optional.join(", "), - "optional capabilities (advisory in 0.2; trap-stub fallback ships in 0.3)", - ); - } - } - - let http_allowlist = caps - .and_then(|c| c.http.as_ref()) - .map(|h| h.allow.clone()) - .unwrap_or_default(); - if !http_allowlist.is_empty() { - info!(target: "manifest", allow = %http_allowlist.join(", "), "http allowlist"); - } - - let config = manifest - .config - .iter() - .map(|(k, v)| (k.clone(), stringify_toml_value(v))) - .collect(); - - Ok(LoadedManifest { - manifest, - http_allowlist, - config, - }) -} - -/// Synthesise a "0.1 fallback" manifest for when no `module.toml` is found. -/// Emits the same deprecation warning as a missing-section manifest. -pub fn fallback_manifest() -> LoadedManifest { - warn!( - target: "manifest", - "no module.toml found - defaulting to all-required (0.1 \ - behaviour). This default will be removed in 0.3; ship a \ - module.toml alongside your component." - ); - LoadedManifest { - manifest: Manifest::default(), - http_allowlist: Vec::new(), - config: Vec::new(), - } -} - -/// Reject a `[module].name` that is not a single safe path component, so a -/// hostile name cannot escape the state directory wherever it is used as one. -/// An empty name is allowed; the runtime falls back to `module`. -fn validate_module_name(name: &str) -> Result<(), ParseError> { - if name.contains('/') || name.contains('\\') || name.contains("..") { - return Err(ParseError::InvalidModuleName(name.to_owned())); - } - Ok(()) -} - -/// Check whether `host` matches any pattern in the allowlist. Patterns are -/// either exact (`api.example.com`) or `*.suffix` wildcards which match -/// any subdomain of `suffix` (but not `suffix` itself). Matching is -/// case-insensitive and host-only: no scheme, no port, and IPv6 literals -/// keep their brackets. -pub fn host_allowed(host: &str, allowlist: &[String]) -> bool { - let host = host.to_ascii_lowercase(); - allowlist.iter().any(|pat| { - let pat = pat.to_ascii_lowercase(); - if let Some(suffix) = pat.strip_prefix("*.") { - host.ends_with(&format!(".{suffix}")) - } else { - host == pat - } - }) -} - -fn stringify_toml_value(v: &toml::Value) -> String { - match v { - toml::Value::String(s) => s.clone(), - toml::Value::Integer(i) => i.to_string(), - toml::Value::Float(f) => f.to_string(), - toml::Value::Boolean(b) => b.to_string(), - toml::Value::Datetime(d) => d.to_string(), - toml::Value::Array(_) | toml::Value::Table(_) => v.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::manifest::types::Subscription; - - #[test] - fn load_parses_block_and_chain_log_subscriptions() { - let toml = r#" -[module] -name = "twap-monitor" - -[capabilities] -required = ["chain", "local-store"] - -[[subscription]] -kind = "block" -chain_id = 1 - -[[subscription]] -kind = "chain-log" -chain_id = 1 -address = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110" -event_signature = "0x00000000000000000000000000000000000000000000000000000000deadbeef" -"#; - let manifest: Manifest = toml::from_str(toml).expect("parse"); - assert_eq!(manifest.module.name, "twap-monitor"); - assert_eq!(manifest.subscriptions.len(), 2); - assert!(matches!( - &manifest.subscriptions[0], - Subscription::Block { chain_id: 1 } - )); - if let Subscription::ChainLog { - chain_id, address, .. - } = &manifest.subscriptions[1] - { - assert_eq!(*chain_id, 1); - assert!(address.is_some()); - } else { - panic!("expected ChainLog subscription"); - } - } - - #[test] - fn load_parses_the_retired_log_kind_as_an_extension_kind() { - // The chain-event kind is `chain-log`; a stale `kind = "log"` - // parses as an extension kind and boot refuses it against the - // extension vocabulary, so a not-yet-migrated manifest still - // surfaces clearly rather than silently dropping events. - let toml = r#" -[module] -name = "stale" - -[[subscription]] -kind = "log" -chain_id = "1" -"#; - let manifest: Manifest = toml::from_str(toml).expect("parse"); - assert!(matches!( - &manifest.subscriptions[0], - Subscription::Extension { kind, .. } if kind == "log" - )); - } - - #[test] - fn load_parses_extension_subscriptions_with_string_filters() { - let toml = r#" -[module] -name = "watcher" - -[[subscription]] -kind = "acme-status" - -[[subscription]] -kind = "acme-status" -scope = "primary" -"#; - let manifest: Manifest = toml::from_str(toml).expect("parse"); - assert!(matches!( - &manifest.subscriptions[0], - Subscription::Extension { kind, filters } if kind == "acme-status" && filters.is_empty() - )); - assert!(matches!( - &manifest.subscriptions[1], - Subscription::Extension { kind, filters } - if kind == "acme-status" && filters.get("scope").is_some_and(|v| v == "primary") - )); - } - - /// A non-string filter value on an extension kind is refused at parse. - #[test] - fn load_rejects_a_non_string_extension_filter() { - let toml = r#" -[module] -name = "watcher" - -[[subscription]] -kind = "acme-status" -scope = 7 -"#; - let err = toml::from_str::(toml).expect_err("non-string filter"); - assert!(err.to_string().contains("must be a string"), "{err}"); - } - - /// A non-core top-level section parses into the opaque extension - /// map: the runtime carries it verbatim and ascribes it no meaning. - #[test] - fn load_parses_extension_sections_opaquely() { - let toml = r#" -[module] -name = "keeper" - -[venue] -body_version = 2 - -[[subscription]] -kind = "block" -chain_id = 1 -"#; - let manifest: Manifest = toml::from_str(toml).expect("parse"); - assert_eq!(manifest.module.name, "keeper"); - assert_eq!(manifest.subscriptions.len(), 1); - assert_eq!(manifest.extensions.len(), 1); - let venue = manifest.extensions.get("venue").expect("venue section"); - assert_eq!( - venue.get("body_version").and_then(toml::Value::as_integer), - Some(2), - ); - } - - /// A manifest without extension sections carries an empty map. - #[test] - fn load_defaults_to_no_extension_sections() { - let toml = r#" -[module] -name = "plain" -"#; - let manifest: Manifest = toml::from_str(toml).expect("parse"); - assert!(manifest.extensions.is_empty()); - } - - #[test] - fn load_parses_cron_subscription() { - let toml = r#" -[module] -name = "scheduler" - -[[subscription]] -kind = "cron" -schedule = "*/5 * * * *" -"#; - let manifest: Manifest = toml::from_str(toml).expect("parse"); - assert!(matches!( - &manifest.subscriptions[0], - Subscription::Cron { .. } - )); - } - - #[test] - fn load_rejects_unknown_capability() { - let toml = r#" -[module] -name = "bad" - -[capabilities] -required = ["chain", "not-a-real-cap"] -"#; - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("module.toml"); - std::fs::write(&path, toml).unwrap(); - let err = load(&path, &CapabilityRegistry::core()).unwrap_err(); - assert!( - matches!(err, ParseError::UnknownCapability { ref name, .. } if name == "not-a-real-cap") - ); - } - - #[test] - fn load_rejects_the_retired_clock_capability() { - // `clock` is no longer a host capability (WASI clocks are ambient); - // a manifest declaring it fails like any other unknown name. - let toml = r#" -[module] -name = "stale" - -[capabilities] -required = ["clock"] -"#; - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("module.toml"); - std::fs::write(&path, toml).unwrap(); - let err = load(&path, &CapabilityRegistry::core()).unwrap_err(); - assert!(matches!(err, ParseError::UnknownCapability { ref name, .. } if name == "clock")); - } - - #[test] - fn load_parses_config_table() { - let toml = r#" -[module] -name = "example" - -[config] -chain_id = 1 -label = "mainnet" -enabled = true -"#; - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("module.toml"); - std::fs::write(&path, toml).unwrap(); - let loaded = load(&path, &CapabilityRegistry::core()).unwrap(); - let config: std::collections::HashMap<_, _> = loaded.config.into_iter().collect(); - assert_eq!(config.get("chain_id").map(String::as_str), Some("1")); - assert_eq!(config.get("label").map(String::as_str), Some("mainnet")); - assert_eq!(config.get("enabled").map(String::as_str), Some("true")); - } - - #[test] - fn component_kind_defaults_to_the_worker() { - use crate::manifest::types::ComponentKind; - let manifest: Manifest = toml::from_str( - r#" -[module] -name = "plain" -"#, - ) - .expect("parse"); - assert_eq!(manifest.module.kind, ComponentKind::Worker); - } - - #[test] - fn component_kind_carries_a_provider_spelling() { - use crate::manifest::types::ComponentKind; - let manifest: Manifest = toml::from_str( - r#" -[module] -name = "acme" -kind = "acme-provider" -"#, - ) - .expect("parse"); - assert_eq!( - manifest.module.kind, - ComponentKind::Provider("acme-provider".to_owned()), - ); - } - - /// An unknown spelling parses as a provider kind; boot refuses it - /// against the registered kinds, where the valid set is known. - #[test] - fn component_kind_keeps_an_unregistered_spelling_for_boot_to_refuse() { - use crate::manifest::types::ComponentKind; - let manifest: Manifest = toml::from_str( - r#" -[module] -name = "bad" -kind = "gadget" -"#, - ) - .expect("parse"); - assert_eq!( - manifest.module.kind, - ComponentKind::Provider("gadget".to_owned()), - ); - } - - #[test] - fn resources_section_parses() { - let toml = r#" -[module] -name = "twap" - -[module.resources] -max_memory_bytes = 10485760 -max_fuel_per_event = 100000 -max_state_bytes = 52428800 -"#; - let m: Manifest = toml::from_str(toml).expect("parse"); - assert_eq!(m.module.resources.max_memory_bytes, Some(10_485_760)); - assert_eq!(m.module.resources.max_fuel_per_event, Some(100_000)); - assert_eq!(m.module.resources.max_state_bytes, Some(52_428_800)); - } - - #[test] - fn resources_section_defaults_to_none() { - let m: Manifest = toml::from_str("[module]\nname = \"x\"\n").expect("parse"); - assert_eq!(m.module.resources.max_memory_bytes, None); - assert_eq!(m.module.resources.max_fuel_per_event, None); - assert_eq!(m.module.resources.max_state_bytes, None); - } - - #[test] - fn load_rejects_module_name_that_escapes_the_state_dir() { - for bad in ["../evil", "a/b", "a\\b", "..", "/etc/passwd", "foo/../bar"] { - // Single-quoted TOML literal string: no backslash-escape processing. - let toml = format!("[module]\nname = '{bad}'\n"); - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("module.toml"); - std::fs::write(&path, toml).unwrap(); - let err = load(&path, &CapabilityRegistry::core()).unwrap_err(); - assert!( - matches!(err, ParseError::InvalidModuleName(ref n) if n == bad), - "expected rejection for {bad:?}, got {err:?}", - ); - } - } - - #[test] - fn load_accepts_plain_module_name() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("module.toml"); - std::fs::write(&path, "[module]\nname = \"twap-monitor\"\n").unwrap(); - let loaded = load(&path, &CapabilityRegistry::core()).unwrap(); - assert_eq!(loaded.manifest.module.name, "twap-monitor"); - } - - #[test] - fn host_allowed_exact_and_wildcard() { - let allow = vec!["api.acme.example".to_string(), "*.discord.com".to_string()]; - assert!(host_allowed("api.acme.example", &allow)); - assert!(!host_allowed("evil.api.acme.example", &allow)); - assert!(host_allowed("foo.discord.com", &allow)); - assert!(host_allowed("a.b.discord.com", &allow)); - assert!(!host_allowed("discord.com", &allow)); - assert!(!host_allowed("nope.example", &allow)); - } - - #[test] - fn host_allowed_is_case_insensitive_both_ways() { - let upper = vec!["API.ACME.EXAMPLE".to_string()]; - let lower = vec!["api.acme.example".to_string()]; - assert!(host_allowed("api.acme.example", &upper)); - assert!(host_allowed("Api.Acme.Example", &lower)); - } - - #[test] - fn host_allowed_matches_hosts_not_authorities() { - // Entries are bare hosts; a port or userinfo in a pattern can - // never match a host string. - let allow = vec![ - "api.acme.example:8443".to_string(), - "u@api.acme.example".to_string(), - ]; - assert!(!host_allowed("api.acme.example", &allow)); - } -} diff --git a/nexum/crates/nexum-runtime/src/manifest/mod.rs b/nexum/crates/nexum-runtime/src/manifest/mod.rs deleted file mode 100644 index da7e0c06..00000000 --- a/nexum/crates/nexum-runtime/src/manifest/mod.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! `module.toml` parser and capability-enforcement helpers (0.2 scope). -//! -//! 0.2 intentionally ships a slim subset of the manifest spec: -//! -//! - `[capabilities].required` is parsed and validated (names must be in -//! the known capability set; the 0.2 reference engine always provides -//! all of them, so this is a sanity check + future-proofing). -//! - `[capabilities].optional` is parsed and logged; trap-stub fallback -//! for absent optionals is deferred to 0.3. -//! - `[capabilities.http].allow` is parsed and consulted by the -//! wasi:http gate before any outbound call. -//! - `[config]` is flattened to `Vec<(String, String)>` and passed to the -//! module's `init`. Typed `config-value` variant is deferred to 0.3. -//! -//! When the manifest file is missing or has no `[capabilities]` section, -//! a deprecation warning is emitted and the engine falls back to 0.1 -//! behaviour (treat every linked capability as required). This fallback -//! will be removed in 0.3. -//! -//! ## Layout -//! -//! - `types`: the serde `Manifest` shape + `LoadedManifest` the engine -//! actually consumes, plus the core-capability list. -//! - `load`: `module.toml` -> `LoadedManifest`, plus the host-matching -//! helper the wasi:http gate uses at request time. -//! - `capabilities`: WIT-import vs declared-capabilities cross-check, plus -//! the extension-extensible `CapabilityRegistry`. -//! - `error`: `ParseError`, `CapabilityViolation`, `CapabilityError`. - -mod capabilities; -mod error; -mod load; -mod types; - -pub(crate) use capabilities::enforce_capabilities; -pub use capabilities::{CapabilityRegistry, NamespaceCaps}; -pub(crate) use load::{fallback_manifest, host_allowed, load}; -pub use types::ExtensionSections; -pub(crate) use types::{ComponentKind, LoadedManifest, ResourceSection, Subscription}; -// CapabilityViolation, ParseError, and the *Section structs are -// reachable through these functions' return / argument types; -// consumers that need to name them directly do so via -// `crate::manifest::error::*` or `::types::*`. diff --git a/nexum/crates/nexum-runtime/src/manifest/types.rs b/nexum/crates/nexum-runtime/src/manifest/types.rs deleted file mode 100644 index 1960dc61..00000000 --- a/nexum/crates/nexum-runtime/src/manifest/types.rs +++ /dev/null @@ -1,291 +0,0 @@ -//! Data structures: `Manifest`, sections, and `LoadedManifest`. -//! -//! Plain serde shapes plus the core-capability list. The parsing -//! and validation logic lives in [`mod@super::load`]; capability enforcement -//! in [`super::capabilities`]. - -use std::collections::BTreeMap; -use std::fmt; - -use serde::Deserialize; -use serde::de::Error as _; - -/// Core capability names: the `nexum:host` interfaces the `event-module` -/// world links into every module linker, emitted from the -/// `nexum-world` capability table. The `http` capability is not a -/// `nexum:host` interface (it gates `wasi:http/*` imports) and is handled -/// separately by the registry. Domain-extension capabilities are not -/// listed here; each extension contributes its own namespace to the -/// [`super::capabilities::CapabilityRegistry`] at the composition root. -pub const CORE_CAPABILITIES: &[&str] = &nexum_world::CORE_IFACES; - -#[derive(Debug, Deserialize, Default)] -pub struct Manifest { - #[serde(default)] - pub module: ModuleSection, - #[serde(default)] - pub capabilities: Option, - #[serde(default)] - pub config: toml::Table, - /// Event subscriptions the runtime wires before calling - /// `_init`. See `docs/02-modules-events-packaging.md` for the - /// schema; 0.2 implements `block` and `chain-log` kinds, `cron` is - /// parsed and ignored (deferred to 0.3). - #[serde(default, rename = "subscription")] - pub subscriptions: Vec, - /// Extension-owned sections: every non-core top-level key, parsed - /// opaquely. The runtime ascribes them no meaning; it routes them - /// to the wired extensions' install predicates, and a section no - /// extension claims is refused at boot. - #[serde(flatten)] - pub extensions: ExtensionSections, -} - -/// Extension-owned manifest sections, keyed by top-level name. Opaque -/// to the runtime; each claiming extension parses its own. -pub type ExtensionSections = BTreeMap; - -/// One `[[subscription]]` table in `module.toml`. -/// -/// The discriminator is the `kind` field; remaining fields are -/// validated per-kind by the supervisor. A kind outside the core set -/// parses as [`Subscription::Extension`] and is validated at boot -/// against the kinds the wired extensions declare, so a typo still -/// fails loudly rather than silently disabling an event source. -#[derive(Debug, Clone)] -pub enum Subscription { - /// New-block events. Fan-out is shared per chain - the - /// supervisor opens one subscription per chain id and routes to - /// every module that asked for blocks on that chain. - Block { - /// EVM chain id. - chain_id: u64, - }, - /// Chain-log events matching `address` + topic-0. Fan-out is - /// per-module - the supervisor opens one subscription per - /// `[[subscription]]` entry and tags emitted events with the - /// owning module. - ChainLog { - /// EVM chain id. - chain_id: u64, - /// Contract address as `0x`-prefixed 20-byte hex. Optional. - address: Option, - /// Topic-0 of the event the module wants to consume. `0x`- - /// prefixed 32-byte hex. Optional - when absent the - /// subscription matches every event from the address(es). - event_signature: Option, - /// Resume across engine restarts. When `true` the host persists a - /// durable per-subscription cursor and re-opens the log poller - /// from just after the last dispatched block, instead of at the - /// current head. Delivery is then at-least-once, so the module must - /// tolerate redelivery (the keeper idempotency journal already - /// dedups it). - resume: bool, - /// Optional cap on how far back a `resume` subscription will - /// backfill, in blocks. `None` (the default) backfills the entire - /// gap with no loss; set it only for a consumer that explicitly - /// tolerates dropping the oldest missed blocks. - max_lookback: Option, - }, - /// Cron-scheduled tick. 0.2 parses but does not dispatch; the - /// supervisor emits a warning so the operator knows the - /// declaration is currently inert. `schedule` is preserved so a - /// 0.3 dispatcher can pick it up without re-parsing the manifest. - Cron { - /// Standard 5-field cron expression. - #[allow(dead_code)] - schedule: String, - }, - /// An extension-owned event kind. Every non-`kind` key is a string - /// filter matched against the event's routing attributes: an event - /// is delivered when its kind matches and every filter pair is - /// present in the event's attributes. - Extension { - /// The extension-declared subscription kind. - kind: String, - /// Attribute filters; empty admits every event of the kind. - filters: BTreeMap, - }, -} - -/// The core subscription kinds, parsed by shape. Any other kind falls -/// through to [`Subscription::Extension`]. -#[derive(Deserialize)] -#[serde(tag = "kind", rename_all = "lowercase")] -enum CoreSubscription { - Block { - chain_id: u64, - }, - #[serde(rename = "chain-log")] - ChainLog { - chain_id: u64, - #[serde(default)] - address: Option, - #[serde(default)] - event_signature: Option, - #[serde(default)] - resume: bool, - #[serde(default)] - max_lookback: Option, - }, - Cron { - schedule: String, - }, -} - -impl From for Subscription { - fn from(sub: CoreSubscription) -> Self { - match sub { - CoreSubscription::Block { chain_id } => Self::Block { chain_id }, - CoreSubscription::ChainLog { - chain_id, - address, - event_signature, - resume, - max_lookback, - } => Self::ChainLog { - chain_id, - address, - event_signature, - resume, - max_lookback, - }, - CoreSubscription::Cron { schedule } => Self::Cron { schedule }, - } - } -} - -impl<'de> Deserialize<'de> for Subscription { - fn deserialize>(deserializer: D) -> Result { - let table = toml::Table::deserialize(deserializer)?; - let Some(kind) = table.get("kind").and_then(toml::Value::as_str) else { - return Err(D::Error::missing_field("kind")); - }; - match kind { - "block" | "chain-log" | "cron" => toml::Value::Table(table.clone()) - .try_into::() - .map(Into::into) - .map_err(D::Error::custom), - _ => { - let kind = kind.to_owned(); - let mut filters = BTreeMap::new(); - for (key, value) in table { - if key == "kind" { - continue; - } - let Some(value) = value.as_str() else { - return Err(D::Error::custom(format!( - "subscription filter `{key}` must be a string" - ))); - }; - filters.insert(key, value.to_owned()); - } - Ok(Self::Extension { kind, filters }) - } - } - } -} - -#[derive(Debug, Deserialize, Default)] -#[allow(dead_code)] // version + component parsed for future 0.3 hash-verification. -pub struct ModuleSection { - #[serde(default)] - pub name: String, - #[serde(default)] - pub version: String, - #[serde(default)] - pub component: String, - /// Which component kind this manifest describes. Defaults to the - /// worker kind (`event-module`) so every existing `module.toml` keeps - /// its meaning; a provider names its registered kind. The supervisor - /// resolves the boot path from this discriminator. - #[serde(default)] - pub kind: ComponentKind, - /// Per-module resource overrides; each unset field inherits the engine - /// `[limits]` default. - #[serde(default)] - pub resources: ResourceSection, -} - -/// The worker kind's manifest spelling. -pub const WORKER_KIND: &str = "event-module"; - -/// The component kind a manifest declares: the core worker kind, or the -/// manifest spelling of a provider kind an extension registers. Defaults -/// to the worker so every manifest written before providers existed keeps -/// its meaning; an unregistered provider spelling is refused at boot, -/// where the registered kinds are known. -#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)] -#[serde(from = "String")] -pub enum ComponentKind { - /// Event-driven worker over the six core primitives (`event-module`). - #[default] - Worker, - /// A provider the host holds behind a serialised actor, named by its - /// manifest spelling. - Provider(String), -} - -impl From for ComponentKind { - fn from(kind: String) -> Self { - if kind == WORKER_KIND { - Self::Worker - } else { - Self::Provider(kind) - } - } -} - -impl fmt::Display for ComponentKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Worker => f.write_str(WORKER_KIND), - Self::Provider(kind) => f.write_str(kind), - } - } -} -/// `[module.resources]` overrides layered over the engine `[limits]` -/// defaults. Every field is optional; an unset field keeps the default. -#[derive(Debug, Deserialize, Default)] -pub struct ResourceSection { - /// Linear-memory cap, in bytes. - #[serde(default)] - pub max_memory_bytes: Option, - /// Fuel granted per event dispatch. - #[serde(default)] - pub max_fuel_per_event: Option, - /// Local-store byte quota (key + value bytes). - #[serde(default)] - pub max_state_bytes: Option, -} - -#[derive(Debug, Deserialize, Default)] -pub struct CapabilitiesSection { - #[serde(default)] - pub required: Vec, - #[serde(default)] - pub optional: Vec, - #[serde(default)] - pub http: Option, -} - -#[derive(Debug, Deserialize, Default)] -pub struct HttpSection { - #[serde(default)] - pub allow: Vec, -} - -/// Loaded + validated manifest, plus the data the engine needs to -/// instantiate a module. -#[derive(Debug)] -pub struct LoadedManifest { - pub manifest: Manifest, - /// Hosts wasi:http outgoing requests may target. Each entry is - /// either an exact hostname or a `*.suffix` wildcard. - pub http_allowlist: Vec, - /// `[config]` flattened to `(key, stringified-value)` pairs ready to - /// hand to a module's `init`. TOML scalars (string, integer, float, - /// boolean) become their text form. Arrays and tables are rendered as - /// their TOML representation. - pub config: Vec<(String, String)>, -} diff --git a/nexum/crates/nexum-runtime/src/preset.rs b/nexum/crates/nexum-runtime/src/preset.rs deleted file mode 100644 index 56fe1629..00000000 --- a/nexum/crates/nexum-runtime/src/preset.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! Runtime presets: a preset names a lattice, its component builders, its -//! extensions, and its add-on set as one bundle, so an embedder launches with -//! `RuntimeBuilder::new(cfg).runtime::().launch()` instead of naming -//! each seam. A preset carrying pre-built backends or non-static extensions -//! binds by value through -//! [`RuntimeBuilder::with_runtime`](crate::builder::RuntimeBuilder::with_runtime). -//! [`CoreRuntime`] is the domain-free default: the reference core backends -//! (a chain provider pool and a local redb store, no extension payload) with -//! the Prometheus add-on. A domain assembly ships its own preset naming its -//! extension builder in the `Ext` slot and returning its linker extensions. - -use std::sync::Arc; - -use crate::addons::{AddOns, PrometheusAddOn}; -use crate::engine_config::EngineConfig; -use crate::host::component::{ - ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, - ProviderPoolBuilder, RuntimeTypes, -}; -use crate::host::extension::Extension; -use crate::host::local_store_redb::LocalStore; -use crate::host::logs::LogPipeline; -use crate::host::provider_pool::ProviderPool; - -/// A bundled runtime assembly: the [`RuntimeTypes`] lattice plus the -/// component builders, extensions, and add-ons the launcher needs, gathered -/// behind one name. -/// [`RuntimeBuilder::runtime`](crate::builder::RuntimeBuilder::runtime) binds -/// a `Default` marker; -/// [`RuntimeBuilder::with_runtime`](crate::builder::RuntimeBuilder::with_runtime) -/// binds a value, so a preset can hand back already-built backends through a -/// pass-through builder such as `Prebuilt`. -/// -/// Sealed: a preset opts in by also implementing the sealing marker. -pub trait Runtime: crate::sealed::SealedRuntime { - /// The lattice the preset assembles. - type Types: RuntimeTypes; - /// Builds the chain backend ([`RuntimeTypes::Chain`]). - type ChainBuilder: ComponentBuilder::Chain>; - /// Builds the store backend ([`RuntimeTypes::Store`]). - type StoreBuilder: ComponentBuilder::Store>; - /// Builds the extension payload ([`RuntimeTypes::Ext`]). - type ExtBuilder: ComponentBuilder::Ext>; - /// Builds the shared [`LogPipeline`]. - type LogsBuilder: ComponentBuilder; - - /// The component builders that open the backends at launch. Consumes the - /// preset, so a value-bound preset hands over owned, pre-built backends. - fn components( - self, - ) -> ComponentsBuilder< - Self::ChainBuilder, - Self::StoreBuilder, - Self::ExtBuilder, - Self::LogsBuilder, - >; - - /// The cross-cutting add-ons installed before the engine boots. - fn add_ons(&self) -> AddOns; - - /// The extensions the preset launches with, derived from the loaded - /// config so an extension can carry config-resolved policy. None by - /// default; - /// [`PresetBuilder::with_extensions`](crate::builder::PresetBuilder::with_extensions) - /// appends on top. - fn extensions(&self, config: &EngineConfig) -> Vec>> { - let _ = config; - Vec::new() - } -} - -/// The domain-free default preset: the reference core backends (a chain -/// provider pool and a local redb store, no extension payload) with the -/// Prometheus add-on. Doubles as its own [`RuntimeTypes`] lattice. -#[derive(Debug, Clone, Copy, Default)] -pub struct CoreRuntime; - -impl crate::sealed::SealedRuntimeTypes for CoreRuntime {} -impl crate::sealed::SealedRuntime for CoreRuntime {} - -impl RuntimeTypes for CoreRuntime { - type Chain = ProviderPool; - type Store = LocalStore; - type Ext = (); -} - -impl Runtime for CoreRuntime { - type Types = Self; - type ChainBuilder = ProviderPoolBuilder; - type StoreBuilder = LocalStoreBuilder; - type ExtBuilder = (); - type LogsBuilder = LogPipelineBuilder; - - fn components(self) -> ComponentsBuilder { - ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) - } - - fn add_ons(&self) -> AddOns { - vec![Box::new(PrometheusAddOn)] - } -} diff --git a/nexum/crates/nexum-runtime/src/runtime/dispatch_rate.rs b/nexum/crates/nexum-runtime/src/runtime/dispatch_rate.rs deleted file mode 100644 index 23a9853d..00000000 --- a/nexum/crates/nexum-runtime/src/runtime/dispatch_rate.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Per-module dispatch rate limiter: one token bucket per module, checked -//! before `on_event`, drops over-rate events. Caps how often a dispatch -//! starts (fuel/memory/poison cap what one costs); per-module, so a flood -//! cannot starve other modules. Pure with injected time. - -use std::time::Instant; - -/// Token-bucket thresholds from `[limits.dispatch]`, else -/// [`DispatchRatePolicy::default`]. -#[derive(Debug, Clone, Copy)] -pub struct DispatchRatePolicy { - /// Bucket capacity: the burst allowance. One dispatch consumes one token. - pub capacity: u32, - /// Tokens replenished per second: the sustained ceiling. - pub refill_per_sec: u32, -} - -impl DispatchRatePolicy { - pub const fn new(capacity: u32, refill_per_sec: u32) -> Self { - Self { - capacity, - refill_per_sec, - } - } -} - -impl Default for DispatchRatePolicy { - fn default() -> Self { - Self::new(DEFAULT_DISPATCH_BURST, DEFAULT_DISPATCH_REFILL_PER_SEC) - } -} - -/// Production default burst allowance. -pub const DEFAULT_DISPATCH_BURST: u32 = 256; - -/// Production default sustained ceiling, in dispatches per second. -pub const DEFAULT_DISPATCH_REFILL_PER_SEC: u32 = 128; - -/// Per-module token-bucket state; fractional tokens, starts full. -#[derive(Debug)] -pub struct TokenBucket { - policy: DispatchRatePolicy, - /// Current tokens in `[0, capacity]`; fractional so slow refill is not lost. - tokens: f64, - last_refill: Instant, -} - -impl TokenBucket { - /// A bucket that starts full at `policy.capacity`, as of `now`. - pub fn new(policy: DispatchRatePolicy, now: Instant) -> Self { - Self { - policy, - tokens: f64::from(policy.capacity), - last_refill: now, - } - } - - /// Refill for elapsed time, then consume one token. `true` = allowed, - /// `false` = over-rate. `now` is injected to stay pure and testable. - pub fn try_acquire(&mut self, now: Instant) -> bool { - let capacity = f64::from(self.policy.capacity); - let elapsed = now - .saturating_duration_since(self.last_refill) - .as_secs_f64(); - self.tokens = (self.tokens + elapsed * f64::from(self.policy.refill_per_sec)).min(capacity); - self.last_refill = now; - if self.tokens >= 1.0 { - self.tokens -= 1.0; - true - } else { - false - } - } -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use super::*; - - #[test] - fn default_is_production_constants() { - let p = DispatchRatePolicy::default(); - assert_eq!(p.capacity, DEFAULT_DISPATCH_BURST); - assert_eq!(p.refill_per_sec, DEFAULT_DISPATCH_REFILL_PER_SEC); - } - - #[test] - fn bucket_starts_full_and_allows_a_burst_up_to_capacity() { - let now = Instant::now(); - let mut bucket = TokenBucket::new(DispatchRatePolicy::new(3, 1), now); - // Three dispatches in the same instant clear the burst allowance. - assert!(bucket.try_acquire(now)); - assert!(bucket.try_acquire(now)); - assert!(bucket.try_acquire(now)); - // The fourth over-rate event in the same instant is dropped. - assert!(!bucket.try_acquire(now)); - } - - #[test] - fn empty_bucket_refills_over_time() { - let start = Instant::now(); - let mut bucket = TokenBucket::new(DispatchRatePolicy::new(2, 4), start); - // Drain the burst. - assert!(bucket.try_acquire(start)); - assert!(bucket.try_acquire(start)); - assert!(!bucket.try_acquire(start), "burst exhausted"); - // 4 tokens/s means one token is back after 250 ms. - let later = start + Duration::from_millis(250); - assert!(bucket.try_acquire(later), "one token refilled after 250ms"); - assert!(!bucket.try_acquire(later), "only one token had refilled"); - } - - #[test] - fn refill_never_exceeds_capacity() { - let start = Instant::now(); - let mut bucket = TokenBucket::new(DispatchRatePolicy::new(2, 100), start); - assert!(bucket.try_acquire(start)); - assert!(bucket.try_acquire(start)); - // A long idle would refill 100 tokens/s, but the bucket caps at - // capacity: only `capacity` dispatches are allowed back-to-back. - let much_later = start + Duration::from_secs(10); - assert!(bucket.try_acquire(much_later)); - assert!(bucket.try_acquire(much_later)); - assert!( - !bucket.try_acquire(much_later), - "burst is capped at capacity, not the whole idle refill", - ); - } - - /// The acceptance criterion at the policy layer: a flooding source is - /// throttled while a second, independent source keeps being served. - #[test] - fn one_flooding_bucket_does_not_starve_another() { - let now = Instant::now(); - let policy = DispatchRatePolicy::new(2, 1); - let mut flooder = TokenBucket::new(policy, now); - let mut neighbour = TokenBucket::new(policy, now); - - // Hammer the flooder in a single instant: the first `capacity` - // dispatches pass, the rest are dropped. - let mut allowed = 0; - for _ in 0..100 { - if flooder.try_acquire(now) { - allowed += 1; - } - } - assert_eq!(allowed, 2, "flooder is throttled to its burst allowance"); - assert!(!flooder.try_acquire(now), "flooder stays throttled"); - - // The neighbour's bucket is untouched by the flood: it still - // serves its own full burst. - assert!(neighbour.try_acquire(now)); - assert!(neighbour.try_acquire(now)); - } -} diff --git a/nexum/crates/nexum-runtime/src/runtime/event_loop.rs b/nexum/crates/nexum-runtime/src/runtime/event_loop.rs deleted file mode 100644 index 3b848306..00000000 --- a/nexum/crates/nexum-runtime/src/runtime/event_loop.rs +++ /dev/null @@ -1,838 +0,0 @@ -//! Open live chain event sources and dispatch their events to the -//! supervisor until a shutdown signal arrives. Blocks come from -//! `eth_subscribe(newHeads)` (WS); chain-logs come from alloy's -//! canonical `eth_getLogs` block-range poller (HTTP or WS), which -//! recovers events across a reconnect by re-querying the gap instead -//! of dropping them. -//! -//! ## Per-stream reconnect with exponential backoff -//! -//! `open_block_streams` / `open_chain_log_streams` no longer return a -//! `Vec` that ends on the first drop. They each spawn one -//! reconnect-aware task per `(chain_id)` or `(module, chain_id, -//! filter)` tuple. The task: -//! -//! 1. Opens the block subscription / log poller via the provider pool. -//! 2. Pumps items to an mpsc channel until the underlying stream ends -//! (a WebSocket drop for blocks, or a terminal poller error for -//! logs - a hard RPC failure or a reorg past retained history). -//! 3. Logs the end + waits `restart_policy::backoff_for(attempt)` -//! (1s -> 2s -> ... cap 5min). -//! 4. Reopens. On the first event after a reopen, attempt resets -//! if the stream has been healthy for `HEALTHY_WINDOW`. -//! -//! The event loop reads the receiver as a regular `Stream`. The -//! reconnect tasks live for the lifetime of the engine; they exit -//! cleanly with [`TaskExit::ReceiverGone`] when their channel receiver -//! is dropped (which happens when `run` returns). They are spawned via -//! a [`TaskExecutor`] and their handles collected into a [`TaskSet`] -//! the loop drains on shutdown. - -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use alloy_chains::Chain; -use futures::StreamExt; -use futures::stream::{BoxStream, select_all}; -use thiserror::Error; -use tokio::sync::mpsc; -use tracing::{info, warn}; - -use crate::bindings::nexum; -use crate::host::component::{ChainProvider, RuntimeTypes}; -use crate::host::extension::{ExtensionEvent, ExtensionEventStream}; -use crate::host::provider_pool::ProviderError; -use crate::runtime::restart_policy::backoff_for; -use crate::supervisor::{ChainLogSub, Supervisor}; -use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; - -/// Errors carried by the tagged block / chain-log streams that the -/// supervisor consumes. Library-side code keeps `anyhow::Error` out -/// of long-lived stream item types per the rust idiomatic rubric. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum StreamError { - /// Underlying provider / transport failure while opening or - /// pumping the subscription. - #[error(transparent)] - Provider(#[from] ProviderError), -} - -/// Time the wrapper stream must observe uninterrupted events before -/// the backoff counter resets to 0. Long enough that a brief but -/// real connection blip does not silently undo the doubling, short -/// enough that a healthy node reverts to fast retries on the next -/// drop. -const HEALTHY_WINDOW: Duration = Duration::from_secs(60); - -/// Time without any block event that we treat as a gap worth a -/// positive recovery log line. Sepolia and Ethereum -/// mainnet both produce blocks reliably every ~12 s, so a silence -/// longer than this is either a transport-layer reconnect that alloy -/// handled internally (no `stream ended` reached the engine, hence -/// no `subscription reopened` log fires) or an upstream RPC stall. -/// Either way, the soak operator wants a positive log line when -/// blocks resume - otherwise an `alloy_transport_ws::native` ERROR -/// followed by silence looks identical to a hung engine. -const BLOCK_GAP_LOG_THRESHOLD: Duration = Duration::from_secs(60); - -/// Channel buffer for the reconnect tasks. Each chain / module -/// subscription gets its own task -> channel pair; buffer is small -/// because the event loop drains in real time. -const RECONNECT_CHANNEL_BUF: usize = 64; - -/// Gap size (blocks) at or above which a re-open logs a large-backfill -/// notice. Purely informational - nothing is ever skipped. -const LARGE_GAP_LOG_THRESHOLD: u64 = 1_000; - -/// Per-chain block subscriptions, one reconnect-aware task per -/// chain id. Tasks are spawned via `executor` and their handles pushed -/// into `tasks` so the caller can drive graceful shutdown (the engine -/// drains the set after closing its receivers - the tasks exit cleanly -/// when the receiver drops). -/// -/// Not `async`: the openers only spawn, they never await, so the caller -/// gets the tagged streams synchronously. -pub fn open_block_streams( - pool: &C, - chains: &[Chain], - executor: &TaskExecutor, - tasks: &mut TaskSet, -) -> Vec -where - C: ChainProvider + Clone + Send + Sync + 'static, -{ - let mut streams = Vec::new(); - for &chain in chains { - let (tx, rx) = mpsc::channel::>( - RECONNECT_CHANNEL_BUF, - ); - let pool = pool.clone(); - tasks.push(executor.spawn(reconnecting_block_task(pool, chain, tx))); - let tagged: TaggedBlockStream = Box::pin(receiver_stream(rx)); - streams.push(tagged); - } - streams -} - -/// Per-module chain-log subscriptions. Each entry gets its own reconnect- -/// aware task tagged with the owning module name + chain id. Tasks -/// are spawned via `executor` and pushed into `tasks` (see -/// [`open_block_streams`]). -pub fn open_chain_log_streams( - pool: &C, - subs: Vec, - executor: &TaskExecutor, - tasks: &mut TaskSet, -) -> Vec -where - C: ChainProvider + Clone + Send + Sync + 'static, -{ - let mut streams = Vec::new(); - for sub in subs { - let (tx, rx) = mpsc::channel::(RECONNECT_CHANNEL_BUF); - let pool = pool.clone(); - let resume = ChainLogResume { - // The cursor key is constant per subscription and cloned onto every - // log; `Arc` keeps that clone cheap. - cursor_key: sub.cursor_key.map(Arc::from), - initial_cursor: sub.initial_cursor, - max_lookback: sub.max_lookback, - }; - tasks.push(executor.spawn(reconnecting_chain_log_task( - pool, sub.module, sub.chain, sub.filter, resume, tx, - ))); - let tagged: TaggedChainLogStream = Box::pin(receiver_stream(rx)); - streams.push(tagged); - } - streams -} - -/// Wrap an `mpsc::Receiver` as a `Stream` using -/// `futures::stream::unfold`. Avoids pulling in `tokio-stream` just -/// for `ReceiverStream`. -fn receiver_stream( - rx: mpsc::Receiver, -) -> impl futures::Stream + Send { - futures::stream::unfold(rx, |mut rx| async move { - rx.recv().await.map(|item| (item, rx)) - }) -} - -/// Reconnect-aware loop for a single chain's block subscription. -/// Holds `(pool, chain_id)` and re-opens the underlying alloy -/// `eth_subscribe` stream with exponential backoff after every drop -/// or transport error. -async fn reconnecting_block_task( - pool: C, - chain: Chain, - tx: mpsc::Sender>, -) -> TaskExit -where - C: ChainProvider + Send + Sync + 'static, -{ - let chain_id = chain.id(); - let mut attempt: u32 = 0; - let mut last_event: Option = None; - loop { - match pool.subscribe_blocks(chain).await { - Ok(mut inner) => { - if attempt == 0 { - info!(chain_id, "block subscription open"); - } else { - info!(chain_id, attempt, "block subscription reopened"); - metrics::counter!( - "shepherd_stream_reconnects_total", - "kind" => "block", - "chain_id" => chain_id.to_string(), - ) - .increment(1); - } - while let Some(item) = inner.next().await { - let now = Instant::now(); - if attempt > 0 - && last_event.is_some_and(|t| now.duration_since(t) >= HEALTHY_WINDOW) - { - info!(chain_id, "block stream healthy - resetting backoff"); - attempt = 0; - } - // Detect transport-layer reconnects that - // alloy handled internally - `inner.next().await` - // keeps yielding events but with a long gap. The - // engine's reconnect path (`stream ended` -> wait - // backoff -> `subscription reopened`) does not fire - // for these, so without this log a soak operator - // sees an `alloy_transport_ws::native` ERROR - // followed by silence indistinguishable from a - // hung engine. - if let Some(gap) = - block_stream_gap_to_log(now, last_event, BLOCK_GAP_LOG_THRESHOLD) - { - let gap_s = gap.as_secs(); - info!( - chain_id, - gap_s, - kind = "block", - "stream gap closed - first event after silence \ - (likely an alloy-internal transport reconnect)" - ); - } - last_event = Some(now); - let tagged = item - .map(|header| (chain, header)) - .map_err(StreamError::from); - if tx.send(tagged).await.is_err() { - // Receiver dropped -> engine shutting down. - return TaskExit::ReceiverGone; - } - } - warn!(chain_id, "block stream ended (WebSocket dropped?)"); - attempt = attempt.saturating_add(1); - } - Err(err) => { - warn!(chain_id, error = %err, "block subscription failed"); - attempt = attempt.saturating_add(1); - } - } - let backoff = backoff_for(attempt); - warn!( - chain_id, - attempt, - backoff_ms = backoff.as_millis() as u64, - "reconnecting block subscription after backoff", - ); - tokio::time::sleep(backoff).await; - } -} - -/// Per-subscription resume and backfill knobs for a chain-log task. -struct ChainLogResume { - /// Durable cursor key, `Some` for a `resume` subscription; the block - /// under it seeds `initial_cursor`. - cursor_key: Option>, - /// Persisted resume block read at boot; the first open starts here. - initial_cursor: Option, - /// Opt-in cap (in blocks) on how far back the poller backfills; `None` - /// backfills the whole gap. - max_lookback: Option, -} - -/// Poller-backed loop for a single (module, chain) chain-log -/// subscription. Instead of `eth_subscribe(logs)` - which silently -/// drops events emitted during a WebSocket reconnect - it drives -/// alloy's canonical `eth_getLogs` block-range poller. The poller -/// reconciles reorgs and re-queries any gap internally, so no manual -/// backfill or dedup is needed here. A hard RPC error (after the -/// transport's own retries), or a reorg deeper than the poller's -/// retained history, ends the poller stream; this loop then re-opens -/// from the block after the last one it delivered and backfills the -/// entire missed range (nothing skipped) with exponential backoff - -/// unless the subscription set `max_lookback`, which bounds how far back -/// the backfill reaches. -async fn reconnecting_chain_log_task( - pool: C, - module: String, - chain: Chain, - filter: alloy_rpc_types_eth::Filter, - resume: ChainLogResume, - tx: mpsc::Sender, -) -> TaskExit -where - C: ChainProvider + Send + Sync + 'static, -{ - let ChainLogResume { - cursor_key, - initial_cursor, - max_lookback, - } = resume; - let chain_id = chain.id(); - let mut attempt: u32 = 0; - let mut last_event: Option = None; - // Highest block whose logs we have delivered; the resume point after a - // poller re-open, so the missed range is synced back rather than skipped. - let mut last_seen_block: Option = None; - // Persisted resume cursor, consumed on the first open: for a `resume` - // subscription the poller starts here (replaying the block in full) - // rather than at head. `None` for a fresh or non-resume subscription. - let mut boot_resume: Option = initial_cursor; - loop { - let head = match pool.block_number(chain).await { - Ok(head) => head, - Err(err) => { - attempt = attempt.saturating_add(1); - let backoff = backoff_for(attempt); - warn!( - module = %module, - chain_id, - error = %err, - attempt, - backoff_ms = backoff.as_millis() as u64, - "chain-log head fetch failed - retrying after backoff", - ); - tokio::time::sleep(backoff).await; - continue; - } - }; - // Choosing the poller start block: - // - `boot_resume` (persisted cursor, first open only): resume AT the - // cursor block, replaying it in full so a mid-block crash before - // the restart loses nothing. Never past head: a reorg that left - // the cursor ahead of head starts at head and lets the poller - // catch up. - // - otherwise a re-open resumes just after the last delivered block - // (within-process gap-free), or at head on the very first open. - // Either way the whole gap is backfilled with no lower floor, so - // nothing is skipped unless the subscription set `max_lookback`. - let mut start_block = match boot_resume.take() { - Some(resume) => resume.min(head), - None => poller_resume_block(last_seen_block, head), - }; - // Opt-in bound: `max_lookback` caps how far back a resume - // subscription backfills. The default (`None`) backfills fully; a - // set cap clamps the start up to `head - cap` and surfaces the - // dropped oldest blocks. - if let Some(cap) = max_lookback { - let floor = head.saturating_sub(cap); - if start_block < floor { - warn!( - module = %module, - chain_id, - skipped_from = start_block, - skipped_to = floor, - "chain-log gap exceeds max_lookback - skipping the oldest missed blocks", - ); - start_block = floor; - } - } - // A large gap is backfilled in full (never skipped); surface it so a long - // catch-up is visible rather than looking like a stall. - if head.saturating_sub(start_block) >= LARGE_GAP_LOG_THRESHOLD { - info!( - module = %module, - chain_id, - from = start_block, - to = head, - blocks = head.saturating_sub(start_block), - "chain-log poller backfilling a large gap" - ); - } - match pool.watch_chain_logs(chain, filter.clone(), start_block) { - Ok(mut inner) => { - if attempt == 0 { - info!(module = %module, chain_id, start_block, "chain-log poller open"); - } else { - info!( - module = %module, - chain_id, - attempt, - start_block, - "chain-log poller reopened" - ); - metrics::counter!( - "shepherd_stream_reconnects_total", - "kind" => "chain-log", - "chain_id" => chain_id.to_string(), - "module" => module.clone(), - ) - .increment(1); - } - while let Some(item) = inner.next().await { - let now = Instant::now(); - if attempt > 0 - && last_event.is_some_and(|t| now.duration_since(t) >= HEALTHY_WINDOW) - { - info!( - module = %module, - chain_id, - "chain-log stream healthy - resetting backoff" - ); - attempt = 0; - } - last_event = Some(now); - match item { - // One canonical block's matching logs; fan the - // batch out into the existing per-log dispatch - // path. Each log already carries its `removed` - // flag from the poller. - Ok(logs) => { - for log in logs { - if let Some(block) = log.block_number { - last_seen_block = - Some(last_seen_block.map_or(block, |seen| seen.max(block))); - } - let tagged = Ok((module.clone(), chain, log, cursor_key.clone())); - if tx.send(tagged).await.is_err() { - return TaskExit::ReceiverGone; - } - } - } - // A poller error is terminal for the alloy stream; - // break to re-open from a fresh head rather than - // pumping a dead stream. - Err(err) => { - warn!( - module = %module, - chain_id, - error = %err, - "chain-log poller error - reopening" - ); - break; - } - } - } - warn!(module = %module, chain_id, "chain-log poller stream ended - reopening"); - attempt = attempt.saturating_add(1); - } - Err(err) => { - warn!( - module = %module, - chain_id, - error = %err, - "chain-log poller open failed" - ); - attempt = attempt.saturating_add(1); - } - } - let backoff = backoff_for(attempt); - warn!( - module = %module, - chain_id, - attempt, - backoff_ms = backoff.as_millis() as u64, - "reconnecting chain-log poller after backoff", - ); - tokio::time::sleep(backoff).await; - } -} - -pub type TaggedBlockStream = std::pin::Pin< - Box< - dyn futures::Stream> - + Send, - >, ->; -/// One item on a tagged chain-log stream: `(module, chain, log, -/// cursor_key)` or a stream error. `cursor_key` is `Some` for a `resume` -/// subscription (constant per subscription; `Arc` for a cheap per-log -/// clone) and threads the durable cursor key through to the dispatch site. -pub type TaggedChainLog = - Result<(String, Chain, alloy_rpc_types_eth::Log, Option>), StreamError>; -pub type TaggedChainLogStream = - std::pin::Pin + Send>>; -/// Drive the supervisor with events until `shutdown` resolves. -/// -/// Graceful shutdown: the dispatch path is structured so -/// that `shutdown` is only observed *between* dispatches, never -/// mid-`call_on_event`. Each select fork either yields a fresh event -/// to dispatch or signals shutdown - the in-flight wasmtime call -/// finishes naturally before the loop exits. Whatever `shutdown` -/// yields (the launcher passes the graceful-drain guard) is held -/// until the loop returns, so the drain covers the final dispatch -/// and cursor commit. -/// -/// Returns the `(blocks, chain_logs)` tally of events drained from the -/// streams - the same numbers the shutdown log line reports. Tests -/// assert on the tally; the launch path ignores it. -pub async fn run( - supervisor: &mut Supervisor, - block_streams: Vec, - chain_log_streams: Vec, - extension_streams: Vec, - tasks: TaskSet, - shutdown: impl std::future::Future + Send, -) -> (u64, u64) { - // `select_all` over an empty Vec yields `None` immediately, which - // would trip the "stream ended -> shut down" arm below before the - // first block / chain-log ever flows. Engine configs that subscribe to - // only one event kind (e.g. all modules use `[[subscription]] kind - // = "block"`) are valid and must not be punished. Replace each - // empty side with `stream::pending()` so the corresponding select - // arm is never selected; the bail-on-None semantic still fires - // when a *non-empty* stream actually closes. - let mut blocks: BoxStream<'_, _> = if block_streams.is_empty() { - futures::stream::pending().boxed() - } else { - select_all(block_streams).boxed() - }; - let mut chain_logs: BoxStream<'_, _> = if chain_log_streams.is_empty() { - futures::stream::pending().boxed() - } else { - select_all(chain_log_streams).boxed() - }; - let mut extension_events: BoxStream<'_, _> = if extension_streams.is_empty() { - futures::stream::pending().boxed() - } else { - select_all(extension_streams).boxed() - }; - let mut shutdown = Box::pin(shutdown); - let mut dispatched_blocks: u64 = 0; - let mut dispatched_chain_logs: u64 = 0; - let mut dispatched_extension_events: u64 = 0; - let started = Instant::now(); - loop { - // Phase 1: pick the next event OR observe shutdown. The - // dispatch itself happens in phase 2 (outside the select) - // so an in-flight wasmtime call never gets cancelled by a - // shutdown signal arriving mid-dispatch. - enum NextEvent { - Block(nexum::host::types::Block), - // The alloy `Log` is boxed so the `Chain` tag does not push - // the enum past the large-variant lint threshold. - ChainLog( - String, - Chain, - Box, - Option>, - ), - Extension(ExtensionEvent), - // Carries the drain guard `shutdown` yielded. - Shutdown(G), - StreamPanic(&'static str), - } - let next = tokio::select! { - biased; - guard = &mut shutdown => NextEvent::Shutdown(guard), - next = blocks.next() => match next { - Some(Ok((chain, header))) => NextEvent::Block(nexum::host::types::Block { - chain_id: chain.id(), - number: header.number, - hash: header.hash.as_slice().to_vec(), - timestamp: header.timestamp.saturating_mul(1000), - }), - Some(Err(err)) => { - warn!(error = %err, "block stream error - continuing"); - continue; - } - None => NextEvent::StreamPanic("block"), - }, - next = chain_logs.next() => match next { - Some(Ok((module, chain, log, cursor_key))) => { - NextEvent::ChainLog(module, chain, Box::new(log), cursor_key) - } - Some(Err(err)) => { - warn!(error = %err, "chain-log stream error - continuing"); - continue; - } - None => NextEvent::StreamPanic("chain-log"), - }, - next = extension_events.next() => match next { - Some(event) => NextEvent::Extension(event), - // Extension source tasks loop forever; `None` means one exited. - None => NextEvent::StreamPanic("extension-event"), - }, - }; - - match next { - NextEvent::Block(block) => { - supervisor.dispatch_block(block).await; - dispatched_blocks += 1; - } - NextEvent::ChainLog(module, chain, log, cursor_key) => { - supervisor - .dispatch_chain_log(&module, chain, *log, cursor_key.as_deref()) - .await; - dispatched_chain_logs += 1; - } - NextEvent::Extension(event) => { - supervisor.dispatch_extension_event(event).await; - dispatched_extension_events += 1; - } - NextEvent::Shutdown(guard) => { - // Drop the stream-end receivers so the reconnect - // tasks observe a closed channel and exit. Then drain - // the task set so the engine genuinely sees the tasks - // finish before returning. - drop(blocks); - drop(chain_logs); - drop(extension_events); - tasks.shutdown().await; - info!( - dispatched_blocks, - dispatched_chain_logs, - dispatched_extension_events, - uptime_secs = started.elapsed().as_secs(), - "graceful shutdown complete", - ); - drop(guard); - return (dispatched_blocks, dispatched_chain_logs); - } - NextEvent::StreamPanic(kind) => { - // Reconnect tasks should loop forever. - // Hitting `None` from `select_all` means the task - // exited (panic or channel closed). Bail loudly. - drop(blocks); - drop(chain_logs); - drop(extension_events); - tasks.shutdown().await; - warn!( - kind, - "reconnect task ended unexpectedly - shutting down for engine restart" - ); - return (dispatched_blocks, dispatched_chain_logs); - } - } - } -} - -/// The block a re-opened log poller should start from. `None` (the -/// first open) starts at the head, so no history is replayed on boot. -/// Otherwise resume just after the last delivered block and backfill -/// the whole gap - there is no lookback cap, so nothing is ever -/// skipped. This is reorg-safe: the old blocks are final, and the -/// poller fetches one `eth_getLogs` per block (immune to a provider's -/// block-range limit). -fn poller_resume_block(last_seen_block: Option, head: u64) -> u64 { - match last_seen_block { - None => head, - Some(last) => last.saturating_add(1), - } -} - -/// Returns `Some(gap)` when the time between the last observed event -/// and `now` meets or exceeds `threshold` - the caller should emit a -/// positive-recovery log line at this point. `None` covers -/// both the first-event case (no `last_event` yet) and the normal -/// "events are arriving at expected cadence" case. -fn block_stream_gap_to_log( - now: Instant, - last_event: Option, - threshold: Duration, -) -> Option { - let last = last_event?; - let gap = now.duration_since(last); - (gap >= threshold).then_some(gap) -} - -/// Wait for SIGINT or (on Unix) SIGTERM, whichever arrives first. -pub async fn wait_for_shutdown_signal() -> anyhow::Result<&'static str> { - #[cfg(unix)] - { - use tokio::signal::unix::{SignalKind, signal}; - let mut sigterm = signal(SignalKind::terminate())?; - let mut sigint = signal(SignalKind::interrupt())?; - tokio::select! { - _ = sigterm.recv() => Ok("SIGTERM"), - _ = sigint.recv() => Ok("SIGINT"), - } - } - #[cfg(not(unix))] - { - tokio::signal::ctrl_c().await?; - Ok("ctrl-c") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── Structural tests: per-stream task allocation (#56) ────────────────── - - /// `open_block_streams` spawns one independent reconnect task per chain. - /// Per-chain task isolation means a slow or reconnecting chain does not - /// delay events from other chains — each chain has its own mpsc channel - /// and backoff timer. - #[tokio::test] - async fn open_block_streams_opens_one_task_per_chain() { - use crate::test_utils::MockChainProvider; - use nexum_tasks::TaskManager; - - let pool = MockChainProvider::new(); - let manager = TaskManager::new(); - let executor = manager.executor(); - let mut tasks = TaskSet::new(); - let chains = vec![ - alloy_chains::Chain::mainnet(), - alloy_chains::Chain::from_id(100), - ]; - let streams = open_block_streams(&pool, &chains, &executor, &mut tasks); - assert_eq!(streams.len(), 2, "one stream per chain"); - tasks.shutdown().await; - } - - /// `open_chain_log_streams` spawns one independent reconnect task per - /// (module, chain, filter) subscription. Two subscriptions from different - /// modules on the same chain each get their own task. - #[tokio::test] - async fn open_chain_log_streams_opens_one_task_per_subscription() { - use crate::test_utils::MockChainProvider; - use nexum_tasks::TaskManager; - - let pool = MockChainProvider::new(); - let manager = TaskManager::new(); - let executor = manager.executor(); - let mut tasks = TaskSet::new(); - let subs = vec![ - ChainLogSub { - module: "mod-a".to_string(), - chain: alloy_chains::Chain::mainnet(), - filter: alloy_rpc_types_eth::Filter::default(), - cursor_key: None, - initial_cursor: None, - max_lookback: None, - }, - ChainLogSub { - module: "mod-b".to_string(), - chain: alloy_chains::Chain::mainnet(), - filter: alloy_rpc_types_eth::Filter::default(), - cursor_key: None, - initial_cursor: None, - max_lookback: None, - }, - ]; - let streams = open_chain_log_streams(&pool, subs, &executor, &mut tasks); - assert_eq!(streams.len(), 2, "one stream per subscription"); - tasks.shutdown().await; - } - - /// Issue #58's task-exit contract, asserted directly: a reconnect - /// task whose downstream receiver drops exits on its own with - /// [`TaskExit::ReceiverGone`] - it is not aborted. This cannot be - /// observed through `TaskSet::shutdown`, which aborts every handle - /// before joining, so the bare handle is joined here. - #[tokio::test] - async fn reconnect_task_exits_receiver_gone_when_receiver_drops() { - use crate::test_utils::MockChainProvider; - use nexum_tasks::TaskManager; - - let pool = MockChainProvider::new(); - // Buffer one header so the task has an item to forward - the - // failing `tx.send` against the dropped receiver is the exit path - // under test. - pool.push_block(alloy_rpc_types_eth::Header::default()); - - let manager = TaskManager::new(); - let executor = manager.executor(); - let (tx, rx) = mpsc::channel(1); - let handle = executor.spawn(reconnecting_block_task( - pool.clone(), - alloy_chains::Chain::mainnet(), - tx, - )); - drop(rx); - - let exit = tokio::time::timeout(Duration::from_secs(5), handle.join()) - .await - .expect("task must exit promptly once the receiver is gone"); - assert_eq!( - exit, - Some(TaskExit::ReceiverGone), - "the task must exit naturally, not via abort (abort yields None)", - ); - } - - // ── block_stream_gap_to_log unit tests ────────────────────────────────── - - /// The helper that decides whether to emit a - /// "stream gap closed" line on the next block event. - #[test] - fn block_stream_gap_to_log_returns_none_when_no_prior_event() { - let now = Instant::now(); - assert_eq!( - block_stream_gap_to_log(now, None, Duration::from_secs(60)), - None, - ); - } - - #[test] - fn block_stream_gap_to_log_returns_none_when_under_threshold() { - let earlier = Instant::now(); - let now = earlier + Duration::from_secs(30); - assert_eq!( - block_stream_gap_to_log(now, Some(earlier), Duration::from_secs(60)), - None, - "30s < 60s threshold -> do not log", - ); - } - - #[test] - fn block_stream_gap_to_log_returns_some_at_threshold_boundary() { - let earlier = Instant::now(); - let now = earlier + Duration::from_secs(60); - assert_eq!( - block_stream_gap_to_log(now, Some(earlier), Duration::from_secs(60)), - Some(Duration::from_secs(60)), - "boundary is inclusive - exactly the threshold counts as a gap", - ); - } - - #[test] - fn block_stream_gap_to_log_returns_some_when_well_over_threshold() { - let earlier = Instant::now(); - let now = earlier + Duration::from_secs(3600); - // The 2026-06-23 soak observation: a 1h gap between the - // `alloy_transport_ws::native` ERROR at 09:05 and the next - // block at 10:05. This is the exact case the log line was - // added for. - let gap = block_stream_gap_to_log(now, Some(earlier), Duration::from_secs(60)) - .expect("1h gap is well over the 60s threshold"); - assert_eq!(gap.as_secs(), 3600); - } - - #[test] - fn poller_resume_block_first_open_starts_at_head() { - assert_eq!( - poller_resume_block(None, 100), - 100, - "first open starts at head, no history replay", - ); - } - - #[test] - fn poller_resume_block_resumes_after_last_delivered() { - assert_eq!( - poller_resume_block(Some(90), 100), - 91, - "a re-open resumes just after the last delivered block", - ); - } - - #[test] - fn poller_resume_block_backfills_the_full_gap() { - assert_eq!( - poller_resume_block(Some(10), 1_000_000), - 11, - "no lookback cap; resume just after the last delivered block and backfill the whole gap", - ); - } -} diff --git a/nexum/crates/nexum-runtime/src/runtime/limits.rs b/nexum/crates/nexum-runtime/src/runtime/limits.rs deleted file mode 100644 index b7778f11..00000000 --- a/nexum/crates/nexum-runtime/src/runtime/limits.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Re-exports for the configurable per-module wasmtime fuel + memory -//! limits. The canonical source is [`crate::engine_config::ModuleLimits`]. -//! -//! Fuel meters only guest instructions; host-call time is unmetered, so a -//! per-dispatch wall-clock deadline in [`crate::supervisor`] is the backstop. -//! diff --git a/nexum/crates/nexum-runtime/src/runtime/mod.rs b/nexum/crates/nexum-runtime/src/runtime/mod.rs deleted file mode 100644 index 613e10b8..00000000 --- a/nexum/crates/nexum-runtime/src/runtime/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Engine-side runtime: per-module resource limits and the event loop -//! that drives the supervisor from live chain subscriptions. - -pub mod dispatch_rate; -pub mod event_loop; -pub mod limits; -pub mod poison_policy; -pub mod restart_policy; diff --git a/nexum/crates/nexum-runtime/src/runtime/poison_policy.rs b/nexum/crates/nexum-runtime/src/runtime/poison_policy.rs deleted file mode 100644 index bfc1a427..00000000 --- a/nexum/crates/nexum-runtime/src/runtime/poison_policy.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Supervisor poison-pill policy. -//! -//! Modules that reach `max_failures` traps within a sliding -//! `window` are marked **poisoned**: the supervisor stops dispatching -//! events to them entirely (no further restart attempts), bumps a -//! `shepherd_module_poisoned{module}` gauge to 1, and logs the -//! quarantine event so an operator can investigate. Recovery -//! requires an operator-driven full engine restart (today): remove -//! the entry from `engine.toml::[[modules]]`, kill the process, fix -//! the module, restart. -//! -//! ## Difference from the restart policy -//! -//! `restart_policy::backoff_for` schedules retries for transient -//! traps; the failure counter resets on a successful dispatch. The -//! poison policy is the *sustained-failure* escalation: if a module -//! is still trapping after `max_failures` retries inside `window`, -//! it stops being a transient and becomes a permanent failure that -//! exhausts an operator's restart budget without ever recovering. -//! Stop retrying. -//! -//! The two policies share `LoadedModule.failure_count` for the -//! consecutive-failure semantic; poison adds a `failure_timestamps` -//! ring so the window check is independent of how the failures are -//! spaced (one second apart vs nine minutes apart both count toward -//! the same window). - -use std::time::Duration; - -/// Production defaults: 5 traps within 10 minutes -> quarantine. -/// Aggressive enough to catch a deterministically broken module -/// without waiting out the full exponential backoff (the 5th trap -/// happens at ~31 s into the schedule: 1+2+4+8+16 s); lenient -/// enough that a one-off RPC blip during a real extension submit does -/// not get a module quarantined. -pub const POISON_MAX_FAILURES: u32 = 5; -pub const POISON_WINDOW: Duration = Duration::from_secs(600); - -/// Configurable poison-pill thresholds. Resolved from `[limits.poison]` -/// on the supervisor boot paths (`ModuleLimits::poison`), falling back -/// to [`PoisonPolicy::default`] for production; operators shorten both -/// values to catch a deterministically broken module sooner. -#[derive(Debug, Clone, Copy)] -pub struct PoisonPolicy { - /// Maximum traps within `window` before the module is poisoned. - pub max_failures: u32, - /// Sliding window the failures are counted across. - pub window: Duration, -} - -impl PoisonPolicy { - pub const fn new(max_failures: u32, window: Duration) -> Self { - Self { - max_failures, - window, - } - } -} - -impl Default for PoisonPolicy { - fn default() -> Self { - Self::new(POISON_MAX_FAILURES, POISON_WINDOW) - } -} - -/// Return `true` when `failure_count` failures inside `window` -/// crosses the configured threshold. -pub fn should_poison(policy: PoisonPolicy, recent_failures: u32) -> bool { - recent_failures >= policy.max_failures -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_is_production_constants() { - let p = PoisonPolicy::default(); - assert_eq!(p.max_failures, POISON_MAX_FAILURES); - assert_eq!(p.window, POISON_WINDOW); - } - - #[test] - fn poisons_at_threshold() { - let p = PoisonPolicy::new(3, Duration::from_secs(60)); - assert!(!should_poison(p, 0)); - assert!(!should_poison(p, 2)); - assert!(should_poison(p, 3)); - assert!(should_poison(p, 100)); - } -} diff --git a/nexum/crates/nexum-runtime/src/runtime/restart_policy.rs b/nexum/crates/nexum-runtime/src/runtime/restart_policy.rs deleted file mode 100644 index 7b80dd62..00000000 --- a/nexum/crates/nexum-runtime/src/runtime/restart_policy.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Supervisor module restart policy. -//! -//! When a module traps in `on_event`, the supervisor flips `alive = -//! false` and schedules a restart attempt with exponential backoff. -//! The next dispatch eligible for that module retries the call; on -//! success the failure counter resets so a module that recovers -//! lands back in the steady-state schedule with no further delay. -//! -//! Policy: -//! -//! | failure_count | next_attempt delay | -//! |---|---| -//! | 1 | 1s | -//! | 2 | 2s | -//! | 3 | 4s | -//! | ... | doubles | -//! | 9+ | capped at 5 minutes | -//! -//! State is in-memory per supervisor process. Persistence across -//! engine restarts is out of scope (a separate 0.3 / M5 follow-up -//! that lands alongside `submitted:{uid}` cross-restart dedup). - -use std::time::Duration; - -/// Hard cap on the restart backoff. After ~8 doublings we plateau -/// here. Tuneable in 0.3 via `engine.toml::[engine.restart]`. -pub const RESTART_MAX_BACKOFF: Duration = Duration::from_secs(300); - -/// Compute the wait window the supervisor honours before the next -/// restart attempt of a module that has trapped `failure_count` times -/// in a row. -/// -/// `failure_count = 0` is the steady-state value (no failures yet); -/// it returns `Duration::ZERO` so the supervisor can call this -/// unconditionally without a branch at the call site. -/// -/// `failure_count >= 1` is "the module just trapped"; the first -/// retry is 1 s, doubling on each subsequent trap, capped at 5 min. -pub fn backoff_for(failure_count: u32) -> Duration { - if failure_count == 0 { - return Duration::ZERO; - } - // 1 << (n - 1) doubles: 1, 2, 4, 8, 16, ..., 256 at n=9. - // saturating_sub keeps n=1 -> 1s; the .min(9) keeps the shift - // from overflowing on absurdly large failure counts. - let shift = failure_count.saturating_sub(1).min(9); - let secs = 1u64 << shift; - Duration::from_secs(secs).min(RESTART_MAX_BACKOFF) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn steady_state_is_zero() { - assert_eq!(backoff_for(0), Duration::ZERO); - } - - #[test] - fn first_failure_waits_one_second() { - assert_eq!(backoff_for(1), Duration::from_secs(1)); - } - - #[test] - fn doubling_progression() { - assert_eq!(backoff_for(2), Duration::from_secs(2)); - assert_eq!(backoff_for(3), Duration::from_secs(4)); - assert_eq!(backoff_for(4), Duration::from_secs(8)); - assert_eq!(backoff_for(5), Duration::from_secs(16)); - } - - #[test] - fn caps_at_five_minutes() { - assert_eq!(backoff_for(20), RESTART_MAX_BACKOFF); - assert_eq!(backoff_for(u32::MAX), RESTART_MAX_BACKOFF); - } -} diff --git a/nexum/crates/nexum-runtime/src/supervisor.rs b/nexum/crates/nexum-runtime/src/supervisor.rs deleted file mode 100644 index e96f02fd..00000000 --- a/nexum/crates/nexum-runtime/src/supervisor.rs +++ /dev/null @@ -1,2065 +0,0 @@ -//! Multi-module supervisor. -//! -//! Loads every `[[modules]]` entry from `engine.toml`, instantiates -//! each as an `EventModule` binding against a dedicated wasmtime -//! `Store`, and routes the event types declared in each manifest's -//! `[[subscription]]` table. -//! -//! Trap handling: a wasmtime trap in `on_event` -//! marks the module `alive = false`, increments `failure_count`, and -//! schedules a `next_attempt` instant via `runtime::restart_policy:: -//! backoff_for`. The next dispatch eligible after that instant -//! re-instantiates the component (fresh `Store` + bindings; the -//! wasm instance left by a trap is poisoned with "cannot enter -//! component instance") and re-calls `init`. On a successful -//! `on_event` the failure counter resets to 0. -//! -//! Modules whose `init` returned `Err(fault)` are dead with -//! `next_attempt = None` and never get scheduled - the init failure -//! is treated as a manifest / config bug, not a transient. -//! -//! Providers ride the same sweeps: a trap inside a routed call flips -//! the [`Liveness`] their actor shares with the supervisor, the owning -//! service reports the instance unavailable while dead, and the sweep -//! reinstalls the provider after the same backoff and poison policies. -//! -//! Multi-chain isolation: `dispatch_block(block)` walks -//! every module but only enters those whose subscriptions match -//! `block.chain_id`. Per-module restart / poison / fuel limits are -//! independent across chains, so a poisoned module on chain A -//! cannot starve modules on chain B. The upstream WS reconnect -//! tasks own one per-chain backoff timer each, so a -//! chain-A connection drop does not block chain-B events. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; -use std::sync::Arc; -use std::time::Duration; - -use alloy_chains::Chain; -use anyhow::{Context, Error, Result, anyhow}; -use tracing::{debug, error, info, warn}; -use tracing_core::Level; -use wasmtime::component::{Component, HasSelf, Linker, ResourceTable}; -use wasmtime::{Engine, Store}; -use wasmtime_wasi::{HostMonotonicClock, HostWallClock, WasiCtxBuilder}; - -use crate::bindings::{Config, EventModule, nexum}; -use crate::engine_config::{ - AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits, -}; -use crate::host::actor::Liveness; -use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; -use crate::host::extension::ExtensionEvent; -use crate::host::extension::{ - Extension, HostService, HostServices, Installed, ProviderInstance, ProviderKind, - ProviderManifest, -}; -use crate::host::http::HttpGate; -#[cfg(test)] -use crate::host::local_store_redb::LocalStore; -use crate::host::logs::{LogRecord, LogSource, RunId, StdioStream}; -#[cfg(test)] -use crate::host::provider_pool::ProviderPool; -use crate::host::state::HostState; -use crate::manifest::{ - self, CapabilityRegistry, ComponentKind, LoadedManifest, ResourceSection, Subscription, -}; - -/// Owns every loaded module and exposes the dispatch surface the -/// event loop needs. Generic over the [`RuntimeTypes`] lattice binding -/// the component seam backends. -pub struct Supervisor { - modules: Vec>, - /// Providers loaded at boot, whether or not `init` succeeded. Swept - /// for restart and poison alongside the modules. - providers: Vec, - /// Registered provider kinds paired with their services, kept for the - /// provider restart sweep to reinstall through. - kinds: ProviderKinds, - /// Cached for module restart: re-instantiating a trapped module - /// requires a fresh wasmtime `Store` + `Linker`, which in turn need - /// the shared backends. The `Components` bundle is cheaply cloned - /// (Arc-backed members) so the supervisor takes an owned copy at boot. - engine: Engine, - components: Components, - /// Extensions wired at boot. Cached so the module-restart path can - /// rebuild an identical linker (core interfaces plus every extension - /// hook) without re-consulting the composition root. - extensions: Vec>>, - /// Extension-owned host services, built once at boot from the same - /// extension set and carried by every store. - services: HostServices, - /// Poison-pill thresholds resolved from `[limits.poison]` at boot - /// (production defaults: 5 failures / 10 min). - poison_policy: crate::runtime::poison_policy::PoisonPolicy, - /// Optional WASI clock override applied to every module store, - /// including the ones rebuilt on restart. `None` leaves the ambient - /// host clocks. - clocks: Option, -} - -/// Core-only lattice for the runtime's own tests: the reference core -/// backends with an empty extension slot (`Ext = ()`). Domain-extension -/// boot coverage lives in the extension crate that owns the backend. -#[cfg(test)] -#[derive(Clone, Copy, Default)] -pub(crate) struct TestTypes; - -#[cfg(test)] -impl crate::sealed::SealedRuntimeTypes for TestTypes {} - -#[cfg(test)] -impl RuntimeTypes for TestTypes { - type Chain = ProviderPool; - type Store = LocalStore; - type Ext = (); -} - -/// The supervisor the runtime's own tests drive. The launch path infers -/// its lattice from the composition root instead. -#[cfg(test)] -pub(crate) type DefaultSupervisor = Supervisor; - -/// A wasmtime `Store` holding the lattice `HostState`. Named so the -/// module and helper signatures stay legible. -type HostStore = Store>; - -/// Per-store WASI clock override applied to every module store. -/// -/// Threaded from the assembly through the boot paths onto each store's -/// `WasiCtxBuilder`. The shared wall and monotonic sources let a test handle -/// drive guest-visible time; leaving it `None` keeps the ambient host clocks, -/// so the default is behaviour-neutral. `RunId.started_at` is host wall-clock -/// and is unaffected. -#[derive(Clone)] -pub struct WasiClockOverride { - wall: Arc, - monotonic: Arc, -} - -impl WasiClockOverride { - /// Pair a shared wall clock with a shared monotonic clock. - pub fn new( - wall: Arc, - monotonic: Arc, - ) -> Self { - Self { wall, monotonic } - } -} - -/// Adapts a shared wall clock into the by-value `HostWallClock` the -/// `WasiCtxBuilder` takes ownership of per store. -struct SharedWallClock(Arc); - -impl HostWallClock for SharedWallClock { - fn resolution(&self) -> std::time::Duration { - self.0.resolution() - } - - fn now(&self) -> std::time::Duration { - self.0.now() - } -} - -/// Adapts a shared monotonic clock into the by-value `HostMonotonicClock` the -/// `WasiCtxBuilder` takes ownership of per store. -struct SharedMonotonicClock(Arc); - -impl HostMonotonicClock for SharedMonotonicClock { - fn resolution(&self) -> u64 { - self.0.resolution() - } - - fn now(&self) -> u64 { - self.0.now() - } -} - -/// A module's resource budget after layering its `[module.resources]` -/// overrides onto the engine `[limits]` defaults. -struct ResolvedLimits { - fuel: u64, - memory: usize, - state_bytes: u64, -} - -/// Layer a manifest's `[module.resources]` over the engine `[limits]` -/// defaults: each unset override field keeps the engine default. -fn resolve_module_limits(res: &ResourceSection, cfg: &ModuleLimits) -> ResolvedLimits { - ResolvedLimits { - fuel: res.max_fuel_per_event.unwrap_or(cfg.fuel()), - memory: res.max_memory_bytes.unwrap_or(cfg.memory()), - state_bytes: res.max_state_bytes.unwrap_or(cfg.state_bytes()), - } -} - -struct LoadedModule { - name: String, - bindings: EventModule, - store: HostStore, - /// The run this store instantiates. Restarts mint a fresh `RunId` - /// with an incremented sequence; the supervisor's death path stamps - /// the synthesized panic record with it. - run: RunId, - /// Subscriptions copied from `module.toml`. The supervisor reads - /// these on every event to decide whether to dispatch. - subscriptions: Vec, - /// Fuel budget refilled before each `on_event` invocation. - fuel_per_event: u64, - /// Wall-clock deadline for a whole dispatch, guest plus every host - /// call it awaits. Fuel bounds only guest instructions, so this is - /// the backstop against a dispatch parked in a slow or blocked host - /// call (see [`crate::runtime::limits`]). - event_deadline: Duration, - /// Memory cap applied to the wasmtime store on reinstantiation. - memory_limit: usize, - /// Local-store byte quota applied to the module store on reinstantiation. - local_store_bytes: u64, - /// Cached for restart: re-instantiating from the original - /// wasm bytes avoids re-reading the file on every restart. The - /// `Component` itself is internally `Arc`-backed by wasmtime. - component: Component, - /// Cached for restart: the manifest's `[config]` we pass - /// to `Guest::init`. Cloning a `Vec<(String, String)>` is cheap. - init_config: Config, - /// Cached for restart: HTTP allowlist baked into the - /// `HostState` we rebuild on each re-instantiation. - http_allowlist: Vec, - /// Cached for restart: outbound HTTP limits baked into the - /// `HostState` we rebuild on each re-instantiation. - http_limits: OutboundHttpLimits, - /// Cached for restart: chain response size cap baked into the - /// `HostState` we rebuild on each re-instantiation. - chain_response_max_bytes: usize, - /// Set to `false` when `on_event` traps. Dead modules are - /// excluded from dispatch until `next_attempt` is in the past. - /// Modules whose `init` failed have `alive = false` - /// + `next_attempt = None`, so they never come back. - alive: bool, - /// Number of consecutive trap-style failures since the last - /// successful dispatch. Resets to 0 on success. Drives the - /// exponential backoff via `restart_policy::backoff_for`. - failure_count: u32, - /// Earliest instant at which the supervisor may retry this - /// module after a trap. `None` for healthy modules + for modules - /// whose `init` failed (the latter never get scheduled because - /// the dispatch fast-path checks `next_attempt` *and* requires - /// `alive = false` before flipping back). - next_attempt: Option, - /// Sliding-window record of recent trap timestamps for the - /// poison-pill check. Entries older than the - /// `PoisonPolicy.window` are dropped on each push. - failure_timestamps: std::collections::VecDeque, - /// Once `true` the module is permanently quarantined: no restart - /// attempts, no dispatches, no metric churn. Recovery requires - /// an operator-driven full engine restart with the module - /// removed from `engine.toml::[[modules]]`. - poisoned: bool, - /// Per-module dispatch rate limiter. Checked in `dispatch_to` - /// before the guest runs, so an event flood on this module's - /// source is throttled at the dispatch boundary without touching - /// any other module's bucket. Over-rate events are dropped and - /// counted. - dispatch_bucket: crate::runtime::dispatch_rate::TokenBucket, -} - -/// One loaded provider. Mirrors [`LoadedModule`]'s restart and poison -/// bookkeeping; liveness is shared with the installed actor, which marks -/// it dead on a trap, and read back by the sweep. -struct LoadedProvider { - /// The provider's namespace: its manifest name, and the id its kind - /// installs it under. - name: String, - /// Registered kind spelling the restart sweep reinstalls through. - kind: &'static str, - /// Extension-owned manifest sections, as the worker install - /// predicates see them. - sections: manifest::ExtensionSections, - /// Cached for restart, like a module's. - component: Component, - /// Cached for restart: the manifest `[config]` handed to `init`. - init_config: Config, - /// Cached for restart: the operator's transport grants. - http_allow: Vec, - messaging_topics: Vec, - /// Cached for restart: the engine `[limits]` applied at boot. - http_limits: OutboundHttpLimits, - fuel_per_call: u64, - memory_limit: usize, - chain_response_max_bytes: usize, - local_store_bytes: u64, - /// Trap flag shared with the installed actor. - liveness: Liveness, - /// Sequence of the run currently installed; restarts increment it. - run_seq: u64, - /// The sweep's view of `liveness`: a `true` here against a dead - /// liveness is an unrecorded trap. Boot init failure leaves it `false` - /// with `next_attempt = None`, permanent like a module's. - alive: bool, - failure_count: u32, - next_attempt: Option, - failure_timestamps: std::collections::VecDeque, - poisoned: bool, -} - -/// One registered provider kind paired with the service its installs bind to. -type ProviderRow = (Box>, Arc); - -/// Registered provider kinds, keyed by their manifest spelling. -type ProviderKinds = BTreeMap<&'static str, ProviderRow>; - -/// Collect each extension's provider kind paired with that extension's -/// service. Refuses a duplicate spelling and a provider whose extension -/// owns no service to install into. -fn provider_kinds( - extensions: &[Arc>], - services: &HostServices, -) -> Result> { - let mut kinds = ProviderKinds::new(); - for ext in extensions { - let Some(provider) = ext.provider() else { - continue; - }; - let service = services.raw(ext.namespace()).cloned().ok_or_else(|| { - anyhow!( - "extension {} registers provider kind {} without a host service", - ext.namespace(), - provider.kind(), - ) - })?; - register_kind(&mut kinds, provider, service)?; - } - Ok(kinds) -} - -/// The union of subscription kinds the wired extensions declare; a -/// manifest subscription of any other non-core kind fails the load. -fn extension_subscription_vocabulary( - extensions: &[Arc>], -) -> BTreeSet<&'static str> { - extensions - .iter() - .flat_map(|ext| ext.subscriptions().iter().copied()) - .collect() -} - -/// Refuse a manifest section no wired extension claims, so a typo'd -/// section fails loudly instead of silently skipping its extension's -/// install predicate. -fn enforce_extension_sections( - owner: &str, - sections: &manifest::ExtensionSections, - extensions: &[Arc>], -) -> Result<()> { - for key in sections.keys() { - let claimed = extensions - .iter() - .any(|ext| ext.manifest_sections().contains(&key.as_str())); - if !claimed { - return Err(anyhow!( - "{owner} declares manifest section [{key}]; no wired extension claims it" - )); - } - } - Ok(()) -} - -/// Insert one kind row, refusing a duplicate manifest spelling. -fn register_kind( - kinds: &mut ProviderKinds, - provider: Box>, - service: Arc, -) -> Result<()> { - let kind = provider.kind(); - if kinds.insert(kind, (provider, service)).is_some() { - return Err(anyhow!("provider kind {kind} is registered twice")); - } - Ok(()) -} - -/// Comma-joined registered provider kind spellings, for boot errors. -fn registered_kinds(kinds: &ProviderKinds) -> String { - kinds.keys().copied().collect::>().join(", ") -} - -impl Supervisor { - /// Compile + instantiate every module declared in - /// `engine_cfg.modules`. The wasmtime `Engine` + `Linker` are - /// passed in so `main.rs` can build them once. - pub async fn boot( - engine: &Engine, - linker: &Linker>, - engine_cfg: &EngineConfig, - components: &Components, - extensions: &[Arc>], - clocks: Option, - ) -> Result { - let registry = capability_registry(extensions); - let services = HostServices::from_extensions(extensions)?; - // Provider kinds the boot loop resolves manifest kinds against. - let kinds = provider_kinds(extensions, &services)?; - // Providers boot first into their extension-owned services, so - // every module store built below already routes to the installed - // instances. Providers link only their kind's scoped imports. - let provider_registry = CapabilityRegistry::provider(); - let mut providers = Vec::with_capacity(engine_cfg.adapters.len()); - for entry in &engine_cfg.adapters { - let loaded = Self::load_provider( - engine, - entry, - components, - &engine_cfg.limits, - &provider_registry, - clocks.as_ref(), - &kinds, - extensions, - ) - .await - .with_context(|| format!("load provider {}", entry.path.display()))?; - providers.push(loaded); - } - // The loaded providers' manifests, as the worker install - // predicates see them. - let provider_manifests: Vec = providers - .iter() - .map(|p| ProviderManifest { - name: p.name.clone(), - kind: p.kind, - sections: p.sections.clone(), - }) - .collect(); - - let extension_kinds = extension_subscription_vocabulary(extensions); - let mut modules = Vec::with_capacity(engine_cfg.modules.len()); - for entry in &engine_cfg.modules { - let loaded = Self::load_one( - engine, - linker, - entry, - components, - &engine_cfg.limits, - ®istry, - clocks.as_ref(), - services.clone(), - &extension_kinds, - extensions, - &provider_manifests, - ) - .await - .with_context(|| format!("load module {}", entry.path.display()))?; - modules.push(loaded); - } - let alive = modules.iter().filter(|m| m.alive).count(); - let adapters_alive = providers.iter().filter(|p| p.alive).count(); - info!( - loaded = modules.len(), - alive, - adapters = providers.len(), - adapters_alive, - "supervisor up" - ); - Ok(Self { - modules, - providers, - kinds, - engine: engine.clone(), - components: components.clone(), - extensions: extensions.to_vec(), - services, - poison_policy: engine_cfg.limits.poison(), - clocks, - }) - } - - /// One-shot construction from a single ad-hoc `(component, manifest)` - /// pair. Used by the CLI-positional invocation so `just run` - /// against the example module keeps working without an - /// `engine.toml`. - // One flat argument per shared backend and resource knob, plus the - // optional clock override; bundling would obscure the call site. - #[allow(clippy::too_many_arguments)] - pub async fn boot_single( - engine: &Engine, - linker: &Linker>, - wasm: &Path, - manifest: Option<&Path>, - components: &Components, - limits: &ModuleLimits, - extensions: &[Arc>], - clocks: Option, - ) -> Result { - let registry = capability_registry(extensions); - let services = HostServices::from_extensions(extensions)?; - let entry = ModuleEntry { - path: wasm.to_path_buf(), - manifest: manifest.map(Path::to_path_buf), - }; - // The single-module override path serves `just run`; providers - // are configured through `engine.toml`, so none boot here. - let extension_kinds = extension_subscription_vocabulary(extensions); - let loaded = Self::load_one( - engine, - linker, - &entry, - components, - limits, - ®istry, - clocks.as_ref(), - services.clone(), - &extension_kinds, - extensions, - &[], - ) - .await?; - Ok(Self { - modules: vec![loaded], - providers: Vec::new(), - kinds: ProviderKinds::new(), - engine: engine.clone(), - components: components.clone(), - extensions: extensions.to_vec(), - services, - poison_policy: limits.poison(), - clocks, - }) - } - - /// Build a fresh wasmtime `Store` wired to the shared backends, with - /// the per-run namespace, allowlist, memory cap, and fuel applied. - /// Shared by `load_one` and `reinstantiate_one`; each call takes a - /// freshly minted [`RunId`] so a restart's store is a distinct run. - // One flat argument per resource knob threaded onto the store, plus the - // optional clock override. - #[allow(clippy::too_many_arguments)] - fn build_store( - engine: &Engine, - components: &Components, - run: RunId, - http_allowlist: Vec, - http_limits: OutboundHttpLimits, - messaging_topics: Vec, - memory_limit: usize, - fuel: u64, - chain_response_max_bytes: usize, - state_quota: u64, - clocks: Option<&WasiClockOverride>, - services: HostServices, - ) -> Result> { - let namespace: &str = &run.module; - // Capture guest stdout/stderr per store instead of inheriting the - // host's: each pipe is line-buffered and routed as run- and - // source-tagged log records. Stdin is deliberately left at the - // default closed stream rather than inherited; a sandboxed - // event-driven module has no host console to read. The ctx grants - // no network - // (`inherit_network` is never called), which keeps the ambient - // wasi:sockets bindings inert and the allowlisted wasi:http gate - // the only live network path. WASI clocks default to ambient; - // `WasiClockOverride`, when present, is the per-store - // virtualization point for deterministic guest time in tests and - // replay. - let router = components.logs.router(); - // Intentionally no inherit_env: the guest environment stays empty, so - // wasi:cli/environment leaks nothing of the host's. - let mut builder = WasiCtxBuilder::new(); - builder - .stdout(StdioStream::new( - router.clone(), - run.clone(), - LogSource::Stdout, - )) - .stderr(StdioStream::new( - router.clone(), - run.clone(), - LogSource::Stderr, - )); - if let Some(clocks) = clocks { - builder.wall_clock(SharedWallClock(clocks.wall.clone())); - builder.monotonic_clock(SharedMonotonicClock(clocks.monotonic.clone())); - } - let wasi = builder.build(); - let limits = wasmtime::StoreLimitsBuilder::new() - .memory_size(memory_limit) - .build(); - let module_store = components - .store - .module(namespace) - .map_err(|e| anyhow!("local-store namespace for {namespace}: {e}"))? - .with_quota(state_quota); - let mut store = Store::new( - engine, - HostState { - wasi, - table: ResourceTable::new(), - limits, - http_ctx: wasmtime_wasi_http::WasiHttpCtx::new(), - http_gate: HttpGate::new(namespace, http_allowlist, http_limits), - messaging_topics, - run, - log_router: router, - ext: components.ext.clone(), - chain: components.chain.clone(), - chain_response_max_bytes, - store: module_store, - remote: components.remote.clone(), - services, - }, - ); - store.limiter(|state| &mut state.limits); - store.set_fuel(fuel)?; - Ok(store) - } - - // One flat argument per shared input threaded onto the store. - #[allow(clippy::too_many_arguments)] - async fn load_one( - engine: &Engine, - linker: &Linker>, - entry: &ModuleEntry, - components: &Components, - limits_cfg: &ModuleLimits, - registry: &CapabilityRegistry, - clocks: Option<&WasiClockOverride>, - services: HostServices, - extension_kinds: &BTreeSet<&'static str>, - extensions: &[Arc>], - provider_manifests: &[ProviderManifest], - ) -> Result> { - let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); - let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { - Some(p) if p.exists() => { - info!(manifest = %p.display(), "loading module manifest"); - manifest::load(p, registry)? - } - _ => { - warn!( - component = %entry.path.display(), - "no module.toml - falling back to anonymous module" - ); - manifest::fallback_manifest() - } - }; - let module_namespace = if loaded_manifest.manifest.module.name.is_empty() { - "module".to_owned() - } else { - loaded_manifest.manifest.module.name.clone() - }; - - // Run the extension install predicates before any compile cost: - // every section must be claimed, and every claiming extension - // must admit the worker against the loaded providers' manifests. - let sections = &loaded_manifest.manifest.extensions; - enforce_extension_sections(&module_namespace, sections, extensions)?; - for ext in extensions { - ext.admit_worker(&module_namespace, sections, provider_manifests) - .with_context(|| format!("install refused for {}", entry.path.display()))?; - } - - // Compile + instantiate. - info!(component = %entry.path.display(), "compiling component"); - let component = Component::from_file(engine, &entry.path) - .map_err(Error::from) - .with_context(|| format!("compile {}", entry.path.display()))?; - - // Enforce capability declarations before spending time on instantiation. - manifest::enforce_capabilities( - &loaded_manifest, - component.component_type().imports(engine).map(|(n, _)| n), - registry, - ) - .with_context(|| format!("capability violation in {}", entry.path.display()))?; - // Layer the manifest's `[module.resources]` over the engine `[limits]` - // defaults: an unset override field keeps the engine default. - let ResolvedLimits { - fuel, - memory, - state_bytes, - } = resolve_module_limits(&loaded_manifest.manifest.module.resources, limits_cfg); - info!( - module = %module_namespace, - fuel, - memory_bytes = memory, - state_bytes, - "applied module resource limits", - ); - // First run of this module: sequence 0. Restarts increment it. - let run = RunId::new(module_namespace.clone(), 0); - let mut store = Self::build_store( - engine, - components, - run.clone(), - loaded_manifest.http_allowlist.clone(), - limits_cfg.http(), - // Event modules are unscoped for messaging; only providers - // carry a topic grant. - Vec::new(), - memory, - fuel, - limits_cfg.chain_response_max_bytes(), - state_bytes, - clocks, - services, - )?; - let bindings = EventModule::instantiate_async(&mut store, &component, linker) - .await - .map_err(Error::from) - .with_context(|| format!("instantiate {}", entry.path.display()))?; - - // Call `init` with the manifest's `[config]`. - let config: Config = if loaded_manifest.config.is_empty() { - vec![("name".into(), module_namespace.clone())] - } else { - loaded_manifest.config.clone() - }; - // Whether `init` returned `Ok(())`. When `init` returns - // `Err(fault)` the module's strategy state (e.g. an - // `OnceLock`) is left uninitialised. Existing M3 - // example modules short-circuit on the missing state via - // `SETTINGS.get().is_none() -> return Ok(())`, but future - // modules without that guard could panic, and even with the - // guard each dispatch wastes fuel + an RPC subscription tick - // on a no-op. The `LoadedModule.alive` flag below is set from - // this result so the dispatcher skips the failed module - // without surfacing it to the dispatch fast-path. - // `init` runs guest code that may call host functions; bound it - // in wall-clock like a dispatch so a hung host call during init - // cannot park boot indefinitely. A deadline or trap propagates as - // a load error. - let init_outcome = with_dispatch_deadline( - limits_cfg.event_deadline(), - bindings.call_init(&mut store, &config), - ) - .await - .map_err(Error::from)? - .map_err(Error::from)?; - let init_succeeded = match init_outcome { - Ok(()) => { - info!(module = %module_namespace, "init succeeded"); - true - } - Err(e) => { - warn!( - module = %module_namespace, - kind = crate::host::error::fault_label(&e), - message = %crate::host::error::fault_message(&e), - "init failed - module loaded but marked dead; dispatcher will skip it", - ); - false - } - }; - // Refuel after init so the first on_event starts with a full budget. - store.set_fuel(fuel)?; - - // Surface any `[[subscription]]` entries the host cannot - // service yet, so an operator running 0.2 against a 0.3 - // manifest does not silently drop events, and refuse an - // extension kind no wired extension declares. - for sub in &loaded_manifest.manifest.subscriptions { - match sub { - Subscription::Cron { .. } => warn!( - module = %module_namespace, - "cron subscriptions are declared but inert in 0.2 (lands in 0.3)", - ), - Subscription::Extension { kind, .. } - if !extension_kinds.contains(kind.as_str()) => - { - return Err(anyhow!( - "module {module_namespace} subscribes to unknown event kind {kind}; \ - no wired extension declares it" - )); - } - _ => {} - } - } - - Ok(LoadedModule { - name: module_namespace, - bindings, - store, - run, - subscriptions: loaded_manifest.manifest.subscriptions.clone(), - fuel_per_event: fuel, - event_deadline: limits_cfg.event_deadline(), - memory_limit: memory, - local_store_bytes: state_bytes, - alive: init_succeeded, - failure_count: 0, - next_attempt: None, - component, - init_config: config, - http_allowlist: loaded_manifest.http_allowlist.clone(), - http_limits: limits_cfg.http(), - chain_response_max_bytes: limits_cfg.chain_response_max_bytes(), - failure_timestamps: std::collections::VecDeque::new(), - poisoned: false, - dispatch_bucket: crate::runtime::dispatch_rate::TokenBucket::new( - limits_cfg.dispatch_rate(), - std::time::Instant::now(), - ), - }) - } - - /// Load one `[[adapters]]` entry: resolve its manifest, resolve the - /// declared kind against the registered provider kinds, enforce the - /// scoped-transport capability set, build a supervised store carrying - /// the operator's HTTP and messaging grants, and hand the instance to - /// its kind to instantiate and install. A failed guest `init` loads the - /// provider dead and unroutable, permanently like a module's. - // One flat argument per shared input threaded onto the store, matching - // the module load path. - #[allow(clippy::too_many_arguments)] - async fn load_provider( - engine: &Engine, - entry: &AdapterEntry, - components: &Components, - limits_cfg: &ModuleLimits, - registry: &CapabilityRegistry, - clocks: Option<&WasiClockOverride>, - kinds: &ProviderKinds, - extensions: &[Arc>], - ) -> Result { - let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); - let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { - Some(p) if p.exists() => { - info!(manifest = %p.display(), "loading provider manifest"); - manifest::load(p, registry)? - } - _ => { - warn!( - component = %entry.path.display(), - "no module.toml - falling back to anonymous provider" - ); - manifest::fallback_manifest() - } - }; - let namespace = if loaded_manifest.manifest.module.name.is_empty() { - "provider".to_owned() - } else { - loaded_manifest.manifest.module.name.clone() - }; - - // Run the extension install predicates before any compile cost: - // every section must be claimed, and every claiming extension - // must admit the provider's own sections. - let sections = loaded_manifest.manifest.extensions.clone(); - enforce_extension_sections(&namespace, §ions, extensions)?; - for ext in extensions { - ext.admit_provider(&namespace, §ions) - .with_context(|| format!("install refused for {}", entry.path.display()))?; - } - - // The manifest kind is the discriminator: an [[adapters]] entry - // must name a registered provider kind, caught here before - // instantiation. A fallback manifest has the default worker kind, - // so a provider must ship a module.toml that declares its kind - // explicitly. - let (kind, service) = match &loaded_manifest.manifest.module.kind { - ComponentKind::Worker => { - return Err(anyhow!( - "{} declares the worker kind; an [[adapters]] entry requires a \ - module.toml declaring a registered provider kind ({})", - entry.path.display(), - registered_kinds(kinds), - )); - } - ComponentKind::Provider(spelling) => kinds.get(spelling.as_str()).ok_or_else(|| { - anyhow!( - "{} declares unregistered provider kind {spelling}; registered \ - kinds: {}", - entry.path.display(), - registered_kinds(kinds), - ) - })?, - }; - - info!( - component = %entry.path.display(), - kind = kind.kind(), - "compiling provider component", - ); - let component = Component::from_file(engine, &entry.path) - .map_err(Error::from) - .with_context(|| format!("compile {}", entry.path.display()))?; - - // Enforce the scoped-transport capability set: `registry` is the - // provider registry, so a declaration of any core-only interface - // fails at manifest load, and an undeclared transport import fails - // here. The linker withholds the same core-only interfaces, so a - // provider reaching for one also fails to instantiate. - manifest::enforce_capabilities( - &loaded_manifest, - component.component_type().imports(engine).map(|(n, _)| n), - registry, - ) - .with_context(|| format!("capability violation in {}", entry.path.display()))?; - - info!( - provider = %namespace, - kind = kind.kind(), - fuel = limits_cfg.fuel(), - memory_bytes = limits_cfg.memory(), - http_allow = entry.http_allow.len(), - messaging_topics = entry.messaging_topics.len(), - "applied provider resource limits and transport scope", - ); - - let linker = build_provider_linker::(engine, kind.as_ref())?; - let run = RunId::new(namespace.clone(), 0); - // A provider links no service-consuming import, so its store carries - // an empty service map; the shared map holds the registry that owns - // the provider's store, and carrying it here would cycle. - let store = Self::build_store( - engine, - components, - run, - entry.http_allow.clone(), - limits_cfg.http(), - entry.messaging_topics.clone(), - limits_cfg.memory(), - limits_cfg.fuel(), - limits_cfg.chain_response_max_bytes(), - limits_cfg.state_bytes(), - clocks, - HostServices::default(), - )?; - - let config: Config = if loaded_manifest.config.is_empty() { - vec![("name".into(), namespace.clone())] - } else { - loaded_manifest.config.clone() - }; - let liveness = Liveness::default(); - let installed = kind - .install( - ProviderInstance { - component: &component, - linker: &linker, - store, - config: config.clone(), - sections: §ions, - fuel_per_call: limits_cfg.fuel(), - liveness: liveness.clone(), - }, - service, - ) - .await - .with_context(|| format!("install {}", entry.path.display()))?; - if installed == Installed::Dead { - liveness.mark_dead(); - } - Ok(LoadedProvider { - name: namespace, - kind: kind.kind(), - sections, - component, - init_config: config, - http_allow: entry.http_allow.clone(), - messaging_topics: entry.messaging_topics.clone(), - http_limits: limits_cfg.http(), - fuel_per_call: limits_cfg.fuel(), - memory_limit: limits_cfg.memory(), - chain_response_max_bytes: limits_cfg.chain_response_max_bytes(), - local_store_bytes: limits_cfg.state_bytes(), - liveness, - run_seq: 0, - alive: installed == Installed::Live, - failure_count: 0, - next_attempt: None, - failure_timestamps: std::collections::VecDeque::new(), - poisoned: false, - }) - } - - /// Number of modules currently loaded. - pub fn module_count(&self) -> usize { - self.modules.len() - } - - /// Number of providers loaded at boot, alive or not. - pub fn adapter_count(&self) -> usize { - self.providers.len() - } - - /// Number of adapters currently alive and routable. Live: a trap drops - /// it, the restart sweep raises it again. - #[cfg_attr(not(test), allow(dead_code))] - pub fn adapter_alive_count(&self) -> usize { - self.providers - .iter() - .filter(|p| p.liveness.is_alive()) - .count() - } - - /// Chains any **alive** module asked for block events on. Dead modules - /// (init-failed or currently in trap-backoff) are excluded so the - /// caller does not open live RPC subscriptions for chains with no - /// reachable module. The caller opens one shared block subscription - /// per chain and routes through `dispatch_block`. Sorted by numeric id - /// and deduped (`Chain` is not `Ord`, so this is not a `BTreeSet`). - pub fn block_chains(&self) -> Vec { - let mut out: Vec = Vec::new(); - for module in self.modules.iter().filter(|m| m.alive) { - for sub in &module.subscriptions { - if let Subscription::Block { chain_id } = sub { - out.push(Chain::from_id(*chain_id)); - } - } - } - out.sort_by_key(|c| c.id()); - out.dedup(); - out - } - - /// Per-module chain-log subscriptions for **alive** modules only. - /// Called once at launch, when a dead module can only mean init - /// failure (trap-backoff cannot exist yet); excluding them keeps the - /// caller from opening live log subscriptions no reachable module - /// consumes. Each entry names the module, chain, and filter the event - /// loop opens against the matching alloy provider; the resulting - /// stream tags every log with `module_name` so `dispatch_chain_log` - /// routes correctly. - pub fn chain_log_subscriptions(&self) -> Vec { - let mut out = Vec::new(); - for module in self.modules.iter().filter(|m| m.alive) { - for sub in &module.subscriptions { - if let Subscription::ChainLog { - chain_id, - address, - event_signature, - resume, - max_lookback, - } = sub - { - match build_alloy_filter(address.as_deref(), event_signature.as_deref()) { - Ok(filter) => { - let chain = Chain::from_id(*chain_id); - // A `resume` subscription gets a durable cursor - // key and its persisted resume point, read once - // here at boot; others start at head as before. - let (cursor_key, initial_cursor) = if *resume { - let key = chainlog_cursor_key( - chain, - address.as_deref(), - event_signature.as_deref(), - ); - let seed = self.read_chain_log_cursor(&module.name, &key); - (Some(key), seed) - } else { - (None, None) - }; - out.push(ChainLogSub { - module: module.name.clone(), - chain, - filter, - cursor_key, - initial_cursor, - max_lookback: *max_lookback, - }); - } - Err(err) => warn!( - module = %module.name, - chain_id, - error = %err, - "invalid chain-log subscription - skipping", - ), - } - } - } - } - out - } - - /// Read the persisted resume cursor for a chain-log subscription, or - /// `None` when absent / unreadable - both treated as "start at head". - fn read_chain_log_cursor(&self, module: &str, key: &str) -> Option { - let handle = self.components.store.module(module).ok()?; - let bytes = handle.get(key).ok()??; - let arr: [u8; 8] = bytes.try_into().ok()?; - Some(u64::from_le_bytes(arr)) - } - - /// Dispatch a block event to every module subscribed to - /// `block.chain_id`. Returns the number of modules invoked. - /// Modules that trap are marked dead and excluded from future dispatch. - /// Rebuild a module from its cached `Component` + `init_config` - /// after a wasmtime trap. A trap leaves the original - /// `Store` + component instance in a poisoned state ("cannot - /// enter component instance" on the next call); the only way to - /// recover is to create a fresh `Store` + re-instantiate. The - /// `LoadedModule.subscriptions` and `LoadedModule.name` are - /// preserved so the dispatch routing keeps working. - /// - /// On success the module's `alive` flag is left for the caller - /// to flip; on failure (e.g. `init` returns Err again) the - /// module stays dead and the failure_count keeps climbing. - async fn reinstantiate_one(&mut self, idx: usize) -> Result<()> { - // Re-build the linker: core interfaces plus every extension hook, - // identical to the boot-time linker. Cheap `add_to_linker` calls - // against the cached `Engine`. - let linker = build_linker::(&self.engine, &self.extensions)?; - - // Borrowed before the `&mut self.modules[idx]` reborrow so the restart - // path applies the same clock override and the same shared services - // as the initial boot. - let clocks = self.clocks.clone(); - let services = self.services.clone(); - let module = &mut self.modules[idx]; - // A restart is a new run: bump the sequence so its logs key - // apart from the dead run's, which stays readable until evicted. - let run = RunId::new(module.name.clone(), module.run.seq + 1); - let mut store = Self::build_store( - &self.engine, - &self.components, - run.clone(), - module.http_allowlist.clone(), - module.http_limits, - Vec::new(), - module.memory_limit, - module.fuel_per_event, - module.chain_response_max_bytes, - module.local_store_bytes, - clocks.as_ref(), - services, - )?; - let bindings = EventModule::instantiate_async(&mut store, &module.component, &linker) - .await - .map_err(Error::from) - .with_context(|| format!("reinstantiate {}", module.name))?; - let init_outcome = with_dispatch_deadline( - module.event_deadline, - bindings.call_init(&mut store, &module.init_config), - ) - .await - .map_err(Error::from)? - .map_err(Error::from)?; - match init_outcome { - Ok(()) => {} - Err(e) => { - return Err(anyhow!( - "init returned fault on restart: {} ({})", - crate::host::error::fault_message(&e), - crate::host::error::fault_label(&e), - )); - } - } - module.bindings = bindings; - module.store = store; - module.run = run; - Ok(()) - } - - pub async fn dispatch_block(&mut self, block: nexum::host::types::Block) -> usize { - let chain = Chain::from_id(block.chain_id); - let chain_id = chain.id(); - let block_number = block.number; - let event = nexum::host::types::Event::Block(block); - let now = std::time::Instant::now(); - // Hoist the local-store reference out so the per-module - // borrow checker is happy when we write the progress - // marker after a successful dispatch. - let local_store = self.components.store.clone(); - - // Phase 1: find dead modules whose backoff window - // has elapsed and re-instantiate them in place. The wasmtime - // store + component instance left by a trap is poisoned - // ("cannot enter component instance" on the next call), so - // recovery requires a fresh Store + re-instantiated bindings. - // - // Poisoned modules are excluded from the restart - // sweep entirely. Once quarantined they stay dead until - // an operator removes them from `engine.toml::[[modules]]` - // and restarts the engine. - let restart_candidates: Vec = (0..self.modules.len()) - .filter(|&i| { - let m = &self.modules[i]; - !m.poisoned && !m.alive && m.next_attempt.is_some_and(|t| t <= now) - }) - .collect(); - for idx in restart_candidates { - self.try_restart(idx).await; - } - self.sweep_providers().await; - - let mut dispatched = 0; - let candidate_indices: Vec = (0..self.modules.len()) - .filter(|&i| { - let m = &self.modules[i]; - if m.poisoned || !m.alive { - return false; - } - m.subscriptions - .iter() - .any(|s| matches!(s, Subscription::Block { chain_id: cid } if chain == *cid)) - }) - .collect(); - for idx in candidate_indices { - if matches!( - self.dispatch_to(idx, chain_id, "block", block_number, &event) - .await, - DispatchOutcome::Ok, - ) { - // Persist the per-module-per-chain progress - // marker so a graceful restart (or even a crash) - // leaves a paper trail. Writes failure is best- - // effort; a warn is enough. - let module_name = self.modules[idx].name.clone(); - let key = progress_key(chain); - match local_store.module(&module_name) { - Ok(ms) => { - if let Err(e) = ms.set(&key, &block_number.to_le_bytes()) { - warn!( - module = %module_name, - chain_id, - error = %e, - "failed to persist last_dispatched_block marker", - ); - } - } - Err(e) => { - warn!( - module = %module_name, - chain_id, - error = %e, - "failed to open module store for progress marker", - ); - } - } - dispatched += 1; - } - } - dispatched - } - - /// Dispatch a chain-log event to the specific module that opened the - /// subscription. Returns `true` when the module accepted the dispatch; - /// `false` when the module is dead, not found, or its callback failed. - /// A trapping module is marked dead and excluded from future dispatch. - pub async fn dispatch_chain_log( - &mut self, - module_name: &str, - chain: Chain, - log: alloy_rpc_types_eth::Log, - cursor_key: Option<&str>, - ) -> bool { - let now = std::time::Instant::now(); - self.sweep_providers().await; - let Some(idx) = self.modules.iter().position(|m| m.name == module_name) else { - warn!(module = %module_name, "no such module - dropping chain-log"); - return false; - }; - - // Poison-pill: quarantined modules get no chain-log - // dispatches at all - same as block. The check happens - // before the restart sweep so a poisoned module never - // triggers a restart attempt. - if self.modules[idx].poisoned { - return false; - } - - // Restart-on-trap: re-instantiate before dispatch - // if the backoff window elapsed. See `dispatch_block` for - // the symmetric path. - let needs_restart = { - let m = &self.modules[idx]; - !m.alive && m.next_attempt.is_some_and(|t| t <= now) - }; - if needs_restart { - self.try_restart(idx).await; - } - - if !self.modules[idx].alive { - return false; - } - - let block_number = log.block_number; - let event = nexum::host::types::Event::ChainLogs(nexum::host::types::ChainLogs { - chain_id: chain.id(), - logs: vec![nexum::host::types::ChainLog::from(&log)], - }); - let ok = matches!( - self.dispatch_to( - idx, - chain.id(), - "chain-log", - block_number.unwrap_or_default(), - &event - ) - .await, - DispatchOutcome::Ok, - ); - // Persist the resume cursor only after a successful dispatch, so a - // block is never recorded as done before the module processed it. - // Advancing to the highest dispatched block is enough; a re-dispatch - // of the same block after a restart is idempotent (at-least-once). - if ok && let (Some(key), Some(block)) = (cursor_key, block_number) { - let store = self.components.store.clone(); - match store.module(module_name) { - Ok(ms) => { - if let Err(e) = ms.set(key, &block.to_le_bytes()) { - warn!( - module = %module_name, - error = %e, - "failed to persist chain-log cursor", - ); - } - } - Err(e) => warn!( - module = %module_name, - error = %e, - "failed to open module store for chain-log cursor", - ), - } - } - ok - } - - /// Dispatch one extension-observed event to every module holding a - /// subscription of its kind whose filters all match the event's - /// attributes. Returns the number of modules invoked. Mirrors - /// `dispatch_block`: dead modules past their backoff are restarted - /// first, poisoned modules are skipped. - pub async fn dispatch_extension_event(&mut self, event: ExtensionEvent) -> usize { - let now = std::time::Instant::now(); - let restart_candidates: Vec = (0..self.modules.len()) - .filter(|&i| { - let m = &self.modules[i]; - !m.poisoned && !m.alive && m.next_attempt.is_some_and(|t| t <= now) - }) - .collect(); - for idx in restart_candidates { - self.try_restart(idx).await; - } - self.sweep_providers().await; - - let candidate_indices: Vec = (0..self.modules.len()) - .filter(|&i| { - let m = &self.modules[i]; - if m.poisoned || !m.alive { - return false; - } - m.subscriptions.iter().any(|s| { - matches!( - s, - Subscription::Extension { kind, filters } - if kind == event.kind && filters.iter().all(|(fk, fv)| { - event.attrs.iter().any(|(ak, av)| ak == fk && av == fv) - }) - ) - }) - }) - .collect(); - let mut dispatched = 0; - for idx in candidate_indices { - // Extension events are not chain-scoped: the telemetry chain - // id and block number carry the 0 sentinel. - if matches!( - self.dispatch_to(idx, 0, event.kind, 0, &event.event).await, - DispatchOutcome::Ok, - ) { - dispatched += 1; - } - } - dispatched - } - - /// The extension subscription kinds at least one loaded module - /// declares. An extension opens an event source only when its kind - /// appears here: with no subscriber every event would be dropped on - /// arrival. - pub fn extension_subscription_kinds(&self) -> BTreeSet { - self.modules - .iter() - .flat_map(|m| m.subscriptions.iter()) - .filter_map(|s| match s { - Subscription::Extension { kind, .. } => Some(kind.clone()), - _ => None, - }) - .collect() - } - - /// The extension-owned services, as booted. Shared by every module - /// store through the service map. - pub fn services(&self) -> &HostServices { - &self.services - } - - /// Shared per-module dispatch path: refuel, call `on_event`, and - /// process the three outcomes (ok / fault / trap) with the - /// same telemetry + lifecycle bookkeeping. Returns whether the - /// guest call succeeded; the caller layers any path-specific - /// follow-up (e.g. the progress marker on `dispatch_block`). - /// `chain_id` is telemetry only; chain-less event kinds pass 0. - async fn dispatch_to( - &mut self, - idx: usize, - chain_id: u64, - event_kind: &'static str, - block_number: u64, - event: &nexum::host::types::Event, - ) -> DispatchOutcome { - let poison_policy = self.poison_policy; - // Hoisted before the per-module borrow so the trap arm can - // synthesize a panic record without re-borrowing `self`. - let router = self.components.logs.router(); - let module = &mut self.modules[idx]; - // Dispatch-boundary rate limit: throttle before spending any - // fuel or entering the guest, so a flood of cheap-to-dispatch - // events on this module's source cannot exhaust the host. The - // bucket is per-module, so a throttled module never starves the - // others. Over-rate events are dropped and counted; the module - // stays alive and its failure / poison state is untouched. - if !module - .dispatch_bucket - .try_acquire(std::time::Instant::now()) - { - debug!( - module = %module.name, - chain_id, - event_kind, - block_number, - "dispatch rate limit exceeded - dropping event", - ); - metrics::counter!( - "shepherd_dispatch_dropped_total", - "module" => module.name.clone(), - "event_kind" => event_kind, - ) - .increment(1); - return DispatchOutcome::RateLimited; - } - if let Err(e) = module.store.set_fuel(module.fuel_per_event) { - error!( - module = %module.name, - chain_id, - event_kind, - error = %e, - "set_fuel failed - skipping" - ); - return DispatchOutcome::Skipped; - } - let start = std::time::Instant::now(); - // Fuel bounds only guest instructions; time spent inside a host - // call (chain RPC, redb, HTTP) is unmetered, so bound the whole - // dispatch, guest plus every host call it awaits, in wall-clock. - // A deadline hit is fatal like a trap: cancelling the call leaves - // the store unusable, and the trap arm marks the module dead so - // the restart sweep reinstantiates it on a fresh store. - let deadline = module.event_deadline; - let call = module.bindings.call_on_event(&mut module.store, event); - let outcome = with_dispatch_deadline(deadline, call) - .await - .unwrap_or_else(|exceeded| Err(wasmtime::Error::from(exceeded))); - match outcome { - Ok(Ok(())) => { - let elapsed = start.elapsed(); - let latency_ms = elapsed.as_millis() as u64; - debug!( - module = %module.name, - chain_id, - event_kind, - block_number, - latency_ms, - "dispatch ok" - ); - metrics::histogram!( - "shepherd_event_latency_seconds", - "module" => module.name.clone(), - "event_kind" => event_kind, - ) - .record(elapsed.as_secs_f64()); - // Successful dispatch clears the failure - // history. A module that recovered after N traps - // lands back in the steady-state schedule with no - // further delay. - module.failure_count = 0; - module.next_attempt = None; - DispatchOutcome::Ok - } - Ok(Err(fault)) => { - let elapsed = start.elapsed(); - let latency_ms = elapsed.as_millis() as u64; - let kind = crate::host::error::fault_label(&fault); - warn!( - module = %module.name, - chain_id, - event_kind, - block_number, - latency_ms, - kind, - message = %crate::host::error::fault_message(&fault), - "on-event returned fault", - ); - metrics::counter!( - "shepherd_module_errors_total", - "module" => module.name.clone(), - "error_kind" => kind, - ) - .increment(1); - DispatchOutcome::Fault - } - Err(trap) => { - let elapsed = start.elapsed(); - let latency_ms = elapsed.as_millis() as u64; - module.failure_count = module.failure_count.saturating_add(1); - let backoff = crate::runtime::restart_policy::backoff_for(module.failure_count); - let next_attempt = std::time::Instant::now() + backoff; - error!( - module = %module.name, - chain_id, - event_kind, - block_number, - latency_ms, - failure_count = module.failure_count, - backoff_ms = backoff.as_millis() as u64, - error = %trap, - "on-event trapped - module marked dead; will retry after backoff", - ); - metrics::counter!( - "shepherd_module_errors_total", - "module" => module.name.clone(), - "error_kind" => "trap", - ) - .increment(1); - module.alive = false; - module.next_attempt = Some(next_attempt); - // Death diagnosis: leave a retrievable panic record on the - // dead run so an operator sees why it terminated even - // after the store is torn down. The record carries the - // trap's root cause only; the full trap with its wasm - // frame list already went to host tracing above. - router.record(LogRecord::now( - module.run.clone(), - LogSource::Panic, - Level::ERROR, - format!("run terminated abnormally: {}", trap.root_cause()), - )); - record_failure_and_maybe_poison(module, poison_policy, &trap.to_string()); - DispatchOutcome::Trapped - } - } - } - - /// Attempt to re-instantiate a dead module in place. On success - /// the module is marked `alive`; on failure the failure counter - /// is bumped and `next_attempt` slides further out per the - /// restart-policy backoff. Used by both dispatch paths. - async fn try_restart(&mut self, idx: usize) { - let name = self.modules[idx].name.clone(); - let failure_count = self.modules[idx].failure_count; - info!(module = %name, failure_count, "restart attempt"); - metrics::counter!( - "shepherd_module_restarts_total", - "module" => name.clone(), - ) - .increment(1); - match self.reinstantiate_one(idx).await { - Ok(()) => { - self.modules[idx].alive = true; - info!(module = %name, "restart succeeded"); - } - Err(e) => { - // Re-instantiation failed: bump the backoff again so - // the next attempt is further out. - let m = &mut self.modules[idx]; - m.failure_count = m.failure_count.saturating_add(1); - let backoff = crate::runtime::restart_policy::backoff_for(m.failure_count); - m.next_attempt = Some(std::time::Instant::now() + backoff); - error!( - module = %name, - failure_count = m.failure_count, - backoff_ms = backoff.as_millis() as u64, - error = %e, - "restart failed - will retry after backoff", - ); - } - } - } - - /// Fold providers into the recovery path: record any trap the shared - /// liveness reports (backoff plus poison bookkeeping), then reinstall - /// dead, unpoisoned providers whose backoff has elapsed. Runs at the - /// head of every dispatch, beside the module restart sweep. - async fn sweep_providers(&mut self) { - let now = std::time::Instant::now(); - let policy = self.poison_policy; - for idx in 0..self.providers.len() { - let provider = &mut self.providers[idx]; - if provider.alive - && let Some(died_at) = provider.liveness.dead_since() - { - provider.alive = false; - provider.failure_count = provider.failure_count.saturating_add(1); - let backoff = crate::runtime::restart_policy::backoff_for(provider.failure_count); - // Backoff counts from the death, not from this sweep, so a - // trap whose backoff already elapsed restarts right below. - provider.next_attempt = Some(died_at.checked_add(backoff).unwrap_or(now)); - warn!( - adapter = %provider.name, - failure_count = provider.failure_count, - backoff_ms = backoff.as_millis() as u64, - "adapter trapped - marked dead; will restart after backoff", - ); - metrics::counter!( - "shepherd_adapter_errors_total", - "adapter" => provider.name.clone(), - "error_kind" => "trap", - ) - .increment(1); - if let Some(recent) = poison_crossed(&mut provider.failure_timestamps, policy) - && !provider.poisoned - { - provider.poisoned = true; - warn!( - adapter = %provider.name, - recent_failures = recent, - window_secs = policy.window.as_secs(), - "adapter poisoned - quarantined; remove from engine.toml + restart to clear", - ); - metrics::gauge!( - "shepherd_adapter_poisoned", - "adapter" => provider.name.clone(), - ) - .set(1.0); - } - } - let provider = &self.providers[idx]; - if !provider.poisoned - && !provider.alive - && provider.next_attempt.is_some_and(|t| t <= now) - { - self.try_restart_provider(idx).await; - } - } - } - - /// Attempt to reinstall a dead provider in place: fresh store, fresh - /// instance, `init`, and a re-install replacing the dead slot. On - /// success the shared liveness is revived; on failure the backoff - /// slides further out, like a module restart. - async fn try_restart_provider(&mut self, idx: usize) { - let name = self.providers[idx].name.clone(); - let failure_count = self.providers[idx].failure_count; - info!(adapter = %name, failure_count, "adapter restart attempt"); - metrics::counter!( - "shepherd_adapter_restarts_total", - "adapter" => name.clone(), - ) - .increment(1); - let outcome = self.reinstall_provider(idx).await; - let provider = &mut self.providers[idx]; - match outcome { - Ok(Installed::Live) => { - provider.run_seq += 1; - provider.liveness.mark_alive(); - provider.alive = true; - provider.failure_count = 0; - provider.next_attempt = None; - info!(adapter = %name, "adapter restart succeeded"); - } - Ok(Installed::Dead) => { - defer_provider_restart(provider, "init returned fault on restart"); - } - Err(e) => defer_provider_restart(provider, &format!("{e:#}")), - } - } - - /// Rebuild a provider from its cached component and grants, then hand - /// it back to its kind to instantiate and install over the dead slot. - async fn reinstall_provider(&mut self, idx: usize) -> Result { - let provider = &self.providers[idx]; - let (kind, service) = self - .kinds - .get(provider.kind) - .ok_or_else(|| anyhow!("provider kind {} is not registered", provider.kind))?; - let linker = build_provider_linker::(&self.engine, kind.as_ref())?; - // A restart is a new run, like a module's. - let run = RunId::new(provider.name.clone(), provider.run_seq + 1); - let store = Self::build_store( - &self.engine, - &self.components, - run, - provider.http_allow.clone(), - provider.http_limits, - provider.messaging_topics.clone(), - provider.memory_limit, - provider.fuel_per_call, - provider.chain_response_max_bytes, - provider.local_store_bytes, - self.clocks.as_ref(), - HostServices::default(), - )?; - kind.install( - ProviderInstance { - component: &provider.component, - linker: &linker, - store, - config: provider.init_config.clone(), - sections: &provider.sections, - fuel_per_call: provider.fuel_per_call, - liveness: provider.liveness.clone(), - }, - service, - ) - .await - } - - /// Count of modules currently alive. A module is not alive when its - /// `init` returned `Err` (permanent, never retried) or when `on_event` - /// trapped and its restart backoff has not yet elapsed. - pub fn alive_count(&self) -> usize { - self.modules.iter().filter(|m| m.alive).count() - } - - /// True when at least one init-failed module declared subscriptions. - /// Lets the launch path distinguish "no manifest declares any - /// `[[subscription]]`" (benign: exit cleanly) from "every declared - /// subscription belongs to a dead module" (operator error: abort). - pub fn dead_modules_hold_subscriptions(&self) -> bool { - self.modules - .iter() - .any(|m| !m.alive && !m.subscriptions.is_empty()) - } - - /// Also expose a per-module poisoned state for - /// metrics + integration tests. - #[cfg_attr(not(test), allow(dead_code))] - pub fn poisoned_count(&self) -> usize { - self.modules.iter().filter(|m| m.poisoned).count() - } -} - -/// Build a `Linker` binding the core `event-module` interfaces plus every -/// extension's own interfaces for `HostState`. Shared by the supervisor -/// restart path and the bootstrap launch path. -/// -/// Extension hooks run after the core interfaces. A module that imports an -/// extension interface instantiates only if that extension's hook is -/// present here, so the same `extensions` slice must drive both this linker -/// and capability enforcement via the crate-internal `capability_registry`. -pub fn build_linker( - engine: &Engine, - extensions: &[Arc>], -) -> anyhow::Result>> { - let mut linker = Linker::>::new(engine); - EventModule::add_to_linker::, HasSelf>>(&mut linker, |state| state)?; - wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; - // wasi:http only; the p2 call above already covers the shared - // wasi:io/wasi:clocks interfaces. - wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; - for ext in extensions { - ext.link(&mut linker)?; - } - Ok(linker) -} - -/// Build a `Linker` for one provider kind: the kind's own scoped imports -/// plus the ambient WASI base and the allowlisted `wasi:http`. The core -/// `nexum:host` interfaces a provider must not touch (local-store, -/// remote-store, identity, logging) are deliberately withheld, so a -/// provider that imports one of them fails to instantiate rather than -/// silently gaining reach. Extensions are not linked into providers: a -/// provider speaks its protocol over the standard transport, not a domain -/// extension surface. -pub fn build_provider_linker( - engine: &Engine, - kind: &dyn ProviderKind, -) -> anyhow::Result>> { - let mut linker = Linker::>::new(engine); - kind.link(&mut linker)?; - wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; - wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; - Ok(linker) -} - -/// Resolve a component's manifest path: the explicit `manifest` override -/// wins, else a sibling `module.toml`, else the deprecated `nexum.toml` -/// with a rename warning. `None` when neither sibling exists. Shared by the -/// module and adapter load paths. -fn resolve_manifest_path(component: &Path, explicit: Option<&Path>) -> Option { - if let Some(path) = explicit { - return Some(path.to_path_buf()); - } - // Canonical name is module.toml (ADR-0001). nexum.toml is accepted - // with a deprecation warning during the 0.1->0.2 transition. - let dir = component.parent()?.to_owned(); - let canonical = dir.join("module.toml"); - if canonical.exists() { - return Some(canonical); - } - let legacy = dir.join("nexum.toml"); - if legacy.exists() { - warn!( - target: "manifest", - path = %legacy.display(), - "nexum.toml is deprecated; rename to module.toml \ - (ADR-0001). Support will be removed in 0.3." - ); - return Some(legacy); - } - None -} - -/// Assemble the capability registry from the core namespace plus every -/// extension's namespace. The result must agree with the linker built from -/// the same `extensions`: enforcement recognises an extension import as a -/// declared capability only when its namespace is registered here. -pub(crate) fn capability_registry( - extensions: &[Arc>], -) -> CapabilityRegistry { - let mut registry = CapabilityRegistry::core(); - for ext in extensions { - registry.register(ext.capabilities()); - } - registry -} - -/// A guest dispatch, guest execution plus every host call it awaited, -/// outlived its wall-clock deadline and was cancelled. Distinct from a -/// fuel trap: fuel bounds guest instructions, this bounds time spent in -/// host calls (chain RPC, redb, HTTP), which fuel does not meter. -#[derive(Debug, thiserror::Error)] -#[error( - "dispatch exceeded its {0:?} wall-clock deadline \ - (a host call blocked or ran too long)" -)] -struct DeadlineExceeded(Duration); - -/// Run a guest dispatch future under a wall-clock `deadline`. -/// -/// Fuel and epoch metering bound only *guest* instructions; time spent -/// inside a host call is unmetered (see [`crate::runtime::limits`]), so -/// without this a module could park the dispatch indefinitely behind a -/// cheap-in-fuel host call. Returns `Err(DeadlineExceeded)` once the -/// future, guest plus every host call it awaited, outlives `deadline`; -/// dropping the future on timeout cancels the in-flight host call at its -/// next await point. Pure guest CPU spinning stays fuel's job: a future -/// that never yields cannot be interrupted here, which is exactly why -/// fuel and this deadline are complementary rather than redundant. -async fn with_dispatch_deadline( - deadline: Duration, - fut: F, -) -> Result { - tokio::time::timeout(deadline, fut) - .await - .map_err(|_elapsed| DeadlineExceeded(deadline)) -} - -/// Outcome of [`Supervisor::dispatch_to`] for a single module. -/// -/// Returned to the caller so path-specific follow-ups (e.g. the -/// progress marker on the block path) can branch on whether -/// the guest actually ran cleanly. Kept private; only the two -/// `dispatch_*` entry points consume it. -#[derive(Debug, Eq, PartialEq)] -enum DispatchOutcome { - /// Guest returned `Ok(())`. - Ok, - /// Guest returned a typed `fault` via WIT. - Fault, - /// Guest trapped (panic / OOM / fuel exhaustion / etc.). Module - /// has been marked dead and may be quarantined per the - /// poison-policy. - Trapped, - /// `set_fuel` failed before the call. Module is left alive but - /// this event is skipped. - Skipped, - /// The per-module dispatch rate limit was exceeded. The event is - /// dropped before the guest runs; the module stays alive and its - /// failure / poison state is untouched. - RateLimited, -} - -/// Push the current trap timestamp into a component's failure-window -/// ring, drop entries older than the policy window, and report the -/// recent-failure count once it crosses `policy.max_failures`. Shared by -/// the module and provider poison sweeps. -fn poison_crossed( - failure_timestamps: &mut std::collections::VecDeque, - policy: crate::runtime::poison_policy::PoisonPolicy, -) -> Option { - let now = std::time::Instant::now(); - while let Some(&front) = failure_timestamps.front() { - if now.duration_since(front) > policy.window { - failure_timestamps.pop_front(); - } else { - break; - } - } - failure_timestamps.push_back(now); - let recent = failure_timestamps.len() as u32; - crate::runtime::poison_policy::should_poison(policy, recent).then_some(recent) -} - -/// Flip `poisoned = true` once the module's failure window crosses the -/// policy threshold. The first transition emits the -/// `shepherd_module_poisoned` gauge + a structured WARN. -fn record_failure_and_maybe_poison( - module: &mut LoadedModule, - policy: crate::runtime::poison_policy::PoisonPolicy, - last_error: &str, -) { - if let Some(recent) = poison_crossed(&mut module.failure_timestamps, policy) - && !module.poisoned - { - module.poisoned = true; - warn!( - module = %module.name, - recent_failures = recent, - window_secs = policy.window.as_secs(), - last_error, - "module poisoned - quarantined; remove from engine.toml + restart to clear", - ); - metrics::gauge!( - "shepherd_module_poisoned", - "module" => module.name.clone(), - ) - .set(1.0); - } -} - -/// Slide a failed provider restart's next attempt further out. -fn defer_provider_restart(provider: &mut LoadedProvider, error: &str) { - provider.failure_count = provider.failure_count.saturating_add(1); - let backoff = crate::runtime::restart_policy::backoff_for(provider.failure_count); - provider.next_attempt = Some(std::time::Instant::now() + backoff); - error!( - adapter = %provider.name, - failure_count = provider.failure_count, - backoff_ms = backoff.as_millis() as u64, - error, - "adapter restart failed - will retry after backoff", - ); -} - -/// Persisted per-chain progress key; must stay numeric for data compat. -fn progress_key(chain: Chain) -> String { - format!("last_dispatched_block:{}", chain.id()) -} - -/// A resolved chain-log subscription for the event loop: the owning -/// module, the chain + alloy `Filter`, and - when the subscription opted -/// into `resume` - the durable cursor key plus the block to resume from -/// (read from the store at boot). -pub struct ChainLogSub { - /// Module that declared the subscription; also its store namespace. - pub module: String, - /// Chain the filter applies to. - pub chain: Chain, - /// Alloy filter the poller opens with. - pub filter: alloy_rpc_types_eth::Filter, - /// `Some` iff `resume = true`: the store key the resume cursor is read - /// and written under. - pub cursor_key: Option, - /// The persisted resume block, read at boot for a `resume` - /// subscription; `None` on first run or when `resume` is off. - pub initial_cursor: Option, - /// Opt-in cap on how far back the poller backfills, in blocks. `None` - /// backfills the whole gap; `Some(cap)` bounds the start to - /// `head - cap`, dropping the oldest missed blocks. - pub max_lookback: Option, -} - -/// Durable resume-cursor key for a chain-log subscription. Derived from -/// the normalized manifest inputs - NOT the alloy `Filter`, whose hash -/// uses a process-randomized `HashSet` and is not reproducible across -/// restarts. Stable and independent of `[[subscription]]` ordering. The -/// module name is the store namespace, so it is not part of the digest. -fn chainlog_cursor_key( - chain: Chain, - address: Option<&str>, - event_signature: Option<&str>, -) -> String { - let normalized = format!( - "{}|{}|{}", - chain.id(), - address.unwrap_or("").to_ascii_lowercase(), - event_signature.unwrap_or("").to_ascii_lowercase(), - ); - format!( - "chainlog_cursor:{:x}", - alloy_primitives::keccak256(normalized.as_bytes()) - ) -} - -impl From<&alloy_rpc_types_eth::Log> for nexum::host::types::ChainLog { - /// Project an alloy `Log` onto the WIT `chain-log` record, preserving every - /// RPC field so the guest reconstructs the alloy log without loss. The chain - /// id is not on the alloy log; the subscription context supplies it at the - /// `chain-logs` batch level. - fn from(log: &alloy_rpc_types_eth::Log) -> Self { - Self { - address: log.address().as_slice().to_vec(), - topics: log.topics().iter().map(|t| t.as_slice().to_vec()).collect(), - data: log.inner.data.data.to_vec(), - block_hash: log.block_hash.map(|h| h.as_slice().to_vec()), - block_number: log.block_number, - block_timestamp: log.block_timestamp, - transaction_hash: log.transaction_hash.map(|h| h.as_slice().to_vec()), - transaction_index: log.transaction_index, - log_index: log.log_index, - removed: log.removed, - } - } -} - -/// Errors surfaced by [`build_alloy_filter`]. -/// -/// Variants thread the underlying alloy parse error via `#[source]` -/// instead of `to_string()`-ing it - keeps the typed chain intact for -/// the supervisor's `tracing::warn!(error = %err, ...)` log line at -/// the call site (where the `Display` chain prints the parse detail). -/// -/// `IntoStaticStr` exposes the snake_case variant name as a -/// `&'static str` so the warn log can carry -/// `error_kind = address | topic` without a match-ladder. -#[derive(Debug, thiserror::Error, strum::IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -enum FilterError { - /// `[[subscriptions]].address` did not parse as an EVM address. - #[error("invalid chain-log address {address:?}: {source}")] - Address { - /// Raw operator-supplied hex string. - address: String, - /// Underlying alloy parse failure. - #[source] - source: alloy_primitives::hex::FromHexError, - }, - /// `[[subscriptions]].event_signature` did not parse as a 32-byte topic. - #[error("invalid topic {topic:?}: {source}")] - Topic { - /// Raw operator-supplied hex string. - topic: String, - /// Underlying alloy parse failure. - #[source] - source: alloy_primitives::hex::FromHexError, - }, -} - -/// Translate a `[[subscription]]` chain-log entry into an alloy `Filter`. -fn build_alloy_filter( - address: Option<&str>, - event_signature: Option<&str>, -) -> std::result::Result { - use alloy_primitives::{Address, B256}; - let mut filter = alloy_rpc_types_eth::Filter::new(); - if let Some(addr_hex) = address { - let addr: Address = addr_hex.parse().map_err(|source| FilterError::Address { - address: addr_hex.to_owned(), - source, - })?; - filter = filter.address(addr); - } - if let Some(topic_hex) = event_signature { - let topic: B256 = topic_hex.parse().map_err(|source| FilterError::Topic { - topic: topic_hex.to_owned(), - source, - })?; - filter = filter.event_signature(topic); - } - Ok(filter) -} - -#[cfg(test)] -mod tests; diff --git a/nexum/crates/nexum-runtime/src/supervisor/tests.rs b/nexum/crates/nexum-runtime/src/supervisor/tests.rs deleted file mode 100644 index d5dccb87..00000000 --- a/nexum/crates/nexum-runtime/src/supervisor/tests.rs +++ /dev/null @@ -1,2554 +0,0 @@ -use std::path::{Path, PathBuf}; - -use super::*; -use crate::engine_config::ModuleLimits; -use crate::manifest::ResourceSection; - -#[test] -fn module_limits_default_to_engine_limits_when_unset() { - let cfg = ModuleLimits::default(); - let resolved = resolve_module_limits(&ResourceSection::default(), &cfg); - assert_eq!(resolved.fuel, cfg.fuel()); - assert_eq!(resolved.memory, cfg.memory()); - assert_eq!(resolved.state_bytes, cfg.state_bytes()); -} - -#[test] -fn manifest_resource_overrides_take_effect_and_are_field_local() { - let cfg = ModuleLimits::default(); - // Only fuel is overridden; memory + state keep the engine defaults. - let res = ResourceSection { - max_memory_bytes: None, - max_fuel_per_event: Some(100_000), - max_state_bytes: Some(2048), - }; - let resolved = resolve_module_limits(&res, &cfg); - assert_eq!(resolved.fuel, 100_000); - assert_eq!(resolved.memory, cfg.memory()); - assert_eq!(resolved.state_bytes, 2048); -} - -/// A manifest section a wired extension claims passes; an unclaimed one -/// (a typo, or a section for an unwired extension) is refused. -#[test] -fn extension_sections_must_be_claimed() { - struct Claiming; - impl Extension for Claiming { - fn namespace(&self) -> &'static str { - "acme" - } - fn capabilities(&self) -> crate::manifest::NamespaceCaps { - crate::manifest::NamespaceCaps { - prefix: "acme:ext/", - ifaces: &[], - } - } - fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { - Ok(()) - } - fn manifest_sections(&self) -> &'static [&'static str] { - &["venue"] - } - } - let extensions: Vec>> = vec![Arc::new(Claiming)]; - - let mut sections = manifest::ExtensionSections::new(); - sections.insert("venue".into(), toml::Value::Boolean(true)); - enforce_extension_sections("keeper", §ions, &extensions).expect("claimed section"); - - sections.insert("venu".into(), toml::Value::Boolean(true)); - let err = enforce_extension_sections("keeper", §ions, &extensions) - .expect_err("unclaimed section"); - assert!(err.to_string().contains("[venu]"), "{err}"); - assert!(err.to_string().contains("keeper"), "{err}"); -} - -#[tokio::test] -async fn empty_supervisor_returns_no_subscriptions() { - let engine = make_wasmtime_engine(); - let sup = boot_mock_supervisor(&engine).await; - assert!(sup.block_chains().is_empty()); - assert!(sup.chain_log_subscriptions().is_empty()); - assert_eq!(sup.module_count(), 0); -} - -/// Data-compat guard: the persisted progress marker keys on the numeric -/// chain id, never the `Chain` `Display` name. A named chain must still -/// yield `last_dispatched_block:11155111`, not `...:sepolia`, so existing -/// redb entries keep resolving after this refactor. -#[test] -fn progress_marker_key_uses_numeric_chain_id() { - let chain = Chain::from_id(11_155_111); - assert_eq!(progress_key(chain), "last_dispatched_block:11155111"); -} - -/// Regression guard: engines whose modules only declare -/// `[[subscription]] kind = "block"` (or only `kind = "chain-log"`) must not -/// bail at boot. Previously `select_all` on an empty `Vec` yielded -/// `None` immediately and the "stream ended -> shut down" arm fired -/// before any event flowed. The fix in `runtime/event_loop.rs` -/// substitutes `stream::pending()` when the Vec is empty so the -/// corresponding select arm is never selected. -/// -/// Surfaced when wiring up `engine.m3.toml` for the M3 testnet runbook: -/// the 3 M3 example modules (price-alert, balance-tracker, stop-loss) -/// all subscribe to blocks only, no logs. The engine bailed within -/// ~50 ms of `supervisor ready` until this fix landed. -#[tokio::test] -async fn run_does_not_bail_when_both_stream_kinds_are_empty() { - use std::time::{Duration, Instant}; - - let engine = make_wasmtime_engine(); - let mut supervisor = boot_mock_supervisor(&engine).await; - let started = Instant::now(); - let shutdown = tokio::time::sleep(Duration::from_millis(50)); - - crate::runtime::event_loop::run( - &mut supervisor, - Vec::new(), - Vec::new(), - Vec::new(), - nexum_tasks::TaskSet::new(), - shutdown, - ) - .await; - - // If the bug were present, `run` returns ~0 ms (the empty `logs` - // stream's first `.next()` yields `None` and the loop bails on - // the bail-on-None arm). With the fix, `run` blocks on `shutdown` - // for the full 50 ms. - let elapsed = started.elapsed(); - assert!( - elapsed >= Duration::from_millis(40), - "run returned in {elapsed:?}, expected >= ~50ms (shutdown timer)", - ); -} - -// ── event_loop integration tests (#56 + #58) ───────────────────────── -// -// Verify the stream-open + run() + shutdown lifecycle end to end at the -// supervisor boundary, without loading a real wasm module. - -/// Block and chain-log streams are both consumed within the same `run()` -/// session — the `biased` select does not starve either event kind. One -/// item of each kind is queued before the loop starts; `run()`'s returned -/// tally must show both were drained. A regression that breaks either -/// select arm (or reorders the `biased` polling so one side never fires) -/// leaves its count at 0 and fails the assertion. Issue #56. -#[tokio::test] -async fn run_delivers_block_and_chain_log_events_without_starvation() { - use std::time::Duration; - - use alloy_chains::Chain; - use alloy_rpc_types_eth::Filter; - - use crate::runtime::event_loop::{open_block_streams, open_chain_log_streams, run}; - use crate::test_utils::MockChainProvider; - use nexum_tasks::{TaskManager, TaskSet}; - - let engine = make_wasmtime_engine(); - let mut supervisor = boot_mock_supervisor(&engine).await; - let pool = MockChainProvider::new(); - let manager = TaskManager::new(); - let executor = manager.executor(); - let mut tasks = TaskSet::new(); - - // Pre-push one event of each kind before the loop starts so both mpsc - // channels have an item for `run()` to drain on its first pass. - pool.push_block(alloy_rpc_types_eth::Header::default()); - pool.push_chain_log(alloy_rpc_types_eth::Log::default()); - - let block_streams = open_block_streams(&pool, &[Chain::mainnet()], &executor, &mut tasks); - let log_subs = vec![crate::supervisor::ChainLogSub { - module: "test-module".to_string(), - chain: Chain::mainnet(), - filter: Filter::default(), - cursor_key: None, - initial_cursor: None, - max_lookback: None, - }]; - let chain_log_streams = open_chain_log_streams(&pool, log_subs, &executor, &mut tasks); - - // The shutdown window only bounds wall time; the assertion is on the - // tally, not on timing. 500 ms is orders of magnitude more than the - // two channel hops need, so a miss means a broken select arm, not a - // slow scheduler. - let shutdown = tokio::time::sleep(Duration::from_millis(500)); - let (blocks, chain_logs) = tokio::time::timeout( - Duration::from_secs(10), - run( - &mut supervisor, - block_streams, - chain_log_streams, - Vec::new(), - tasks, - shutdown, - ), - ) - .await - .expect("run() must return once shutdown fires"); - assert_eq!(blocks, 1, "the queued block must be drained and dispatched"); - assert_eq!( - chain_logs, 1, - "the queued chain-log must be drained and dispatched", - ); -} - -/// After `run()` returns on the shutdown path, all reconnect tasks are -/// drained: the Shutdown arm calls `tasks.shutdown()`, which aborts every -/// handle and then joins each one, so no task detaches and outlives the -/// engine. (The companion contract — a task parked on a dropped receiver -/// exits with `ReceiverGone` on its own — is asserted directly in -/// `event_loop::tests::reconnect_task_exits_receiver_gone_when_receiver_drops`; -/// it cannot be observed here because `TaskSet::shutdown` aborts first.) -/// Issue #58. -#[tokio::test] -async fn run_drains_reconnect_tasks_cleanly_on_shutdown() { - use std::time::Duration; - - use alloy_chains::Chain; - - use crate::runtime::event_loop::{open_block_streams, run}; - use crate::test_utils::MockChainProvider; - use nexum_tasks::{TaskManager, TaskSet}; - - let engine = make_wasmtime_engine(); - let mut supervisor = boot_mock_supervisor(&engine).await; - let pool = MockChainProvider::new(); - let manager = TaskManager::new(); - let executor = manager.executor(); - let mut tasks = TaskSet::new(); - - // Two subscription tasks — both must drain before `run()` returns. - let block_streams = open_block_streams( - &pool, - &[Chain::mainnet(), Chain::from_id(100)], - &executor, - &mut tasks, - ); - - let shutdown = tokio::time::sleep(Duration::from_millis(10)); - // If the drain were absent, the spawned reconnect tasks would detach - // and outlive the supervisor; if the drain hung, the timeout fails - // fast instead of stalling the suite until the CI job limit. - tokio::time::timeout( - Duration::from_secs(10), - run( - &mut supervisor, - block_streams, - vec![], - Vec::new(), - tasks, - shutdown, - ), - ) - .await - .expect("run() + task drain must complete promptly after shutdown"); -} - -// ── E2E helpers ─────────────────────────────────────────────────────── - -/// Path to the pre-built example WASM component. Tests that need it -/// call `example_wasm_or_skip()` which skips gracefully if absent. -fn example_wasm() -> PathBuf { - // CARGO_MANIFEST_DIR → nexum/crates/nexum-runtime; three parents up - // is the workspace root carrying `target/`. - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .parent() - .unwrap() - .join("target/wasm32-wasip2/release/example.wasm") -} - -fn example_module_toml() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("modules/example/module.toml") -} - -/// Returns `None` and prints a skip message if the fixture isn't built. -fn example_wasm_or_skip() -> Option { - let p = example_wasm(); - if p.exists() { - Some(p) - } else { - eprintln!( - "SKIP: {} not found - run `just build-module` to enable E2E tests", - p.display() - ); - None - } -} - -fn make_wasmtime_engine() -> wasmtime::Engine { - let mut config = wasmtime::Config::new(); - config.wasm_component_model(true); - config.consume_fuel(true); - wasmtime::Engine::new(&config).expect("wasmtime engine") -} - -/// The core-only extension set: no domain extensions. Domain-extension -/// boot coverage lives in the extension crate that owns the backend. -fn core_extensions() -> Vec>> { - Vec::new() -} - -fn make_linker(engine: &wasmtime::Engine) -> Linker> { - crate::supervisor::build_linker::(engine, &core_extensions()).expect("build_linker") -} - -/// Synthetic component bundle for tests: an empty chain pool, an empty -/// extension slot, and the given store. -fn test_components(store: crate::host::local_store_redb::LocalStore) -> Components { - Components { - chain: ProviderPool::empty(), - store, - ext: (), - logs: crate::test_utils::in_memory_logs(), - remote: crate::host::remote_store_bee::RemoteStore::disabled(), - } -} - -/// Return `(dir, store)` so the test holds the `TempDir` for the -/// duration of the test scope and cleans it up on drop. Forgetting -/// the dir (the old `ManuallyDrop` approach) leaks it for the -/// entire process lifetime. -fn temp_local_store() -> (tempfile::TempDir, crate::host::local_store_redb::LocalStore) { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("ls.redb"); - let store = crate::host::local_store_redb::LocalStore::open(path).expect("local store"); - (dir, store) -} - -/// Boot a zero-module supervisor over the in-process mock backends via the -/// real `boot` path. The default config declares no modules, so `boot` -/// returns with an empty module set, touching neither disk nor network. -async fn boot_mock_supervisor( - engine: &wasmtime::Engine, -) -> Supervisor { - let components = crate::test_utils::mock_components(); - let config = EngineConfig::default(); - let linker = crate::supervisor::build_linker::(engine, &[]) - .expect("build_linker"); - Supervisor::boot(engine, &linker, &config, &components, &[], None) - .await - .expect("boot mock supervisor") -} - -// ── E2E tests ───────────────────────────────────────────────────────── - -/// Boot supervisor with the example module; verify it starts alive. -#[tokio::test] -async fn e2e_supervisor_boots_example_module() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - - let limits = ModuleLimits::default(); - let supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(example_module_toml()).as_deref(), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single"); - - assert_eq!(supervisor.module_count(), 1); - assert_eq!(supervisor.alive_count(), 1); -} - -/// The per-module world contract: the example component's -/// capability-bearing imports are exactly what its manifest declares -/// (`logging`), by construction of the emitted world rather than by -/// the toolchain eliding unused imports of a blanket world. -#[test] -fn e2e_example_component_imports_equal_declared_capabilities() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let engine = make_wasmtime_engine(); - let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); - let imports: Vec = component - .component_type() - .imports(&engine) - .map(|(name, _)| name.to_owned()) - .collect(); - - // Capability-bearing imports resolve to exactly the declared set. - let registry = CapabilityRegistry::core(); - let caps: std::collections::BTreeSet<&str> = imports - .iter() - .filter_map(|name| registry.wit_import_to_cap(name)) - .collect(); - assert_eq!( - caps, - std::collections::BTreeSet::from(["logging"]), - "imports were: {imports:?}" - ); - - // No extension interface leaks in either: the per-module world holds - // exactly what the manifest declared. - assert!( - imports - .iter() - .all(|name| name.starts_with("nexum:host/") || name.starts_with("wasi:")), - "imports were: {imports:?}" - ); -} - -/// Boot with a manifest that subscribes to block events; dispatch one -/// block event and verify the module was invoked and stayed alive. -#[tokio::test] -async fn e2e_block_subscription_dispatched() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let dir = tempfile::tempdir().unwrap(); - let manifest = dir.path().join("module.toml"); - std::fs::write( - &manifest, - r#" -[module] -name = "example" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "block" -chain_id = 1 -"#, - ) - .unwrap(); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - let limits = ModuleLimits::default(); - - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single"); - - let block = nexum::host::types::Block { - chain_id: 1, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - }; - let dispatched = supervisor.dispatch_block(block).await; - assert_eq!(dispatched, 1, "one module subscribed to chain 1 blocks"); - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); -} - -/// A `ManualClock` override threads through `boot_single` onto the module -/// store and is behaviour-neutral: the module boots, dispatches a block, and -/// stays alive exactly as it does on the ambient clock. Locks the plumbing so -/// the seam keeps reaching the store on the boot path. -#[cfg(feature = "test-utils")] -#[tokio::test] -async fn e2e_manual_clock_override_boots_and_dispatches() { - use std::time::{Duration, UNIX_EPOCH}; - - use crate::test_utils::clock::ManualClock; - - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let dir = tempfile::tempdir().unwrap(); - let manifest = dir.path().join("module.toml"); - std::fs::write( - &manifest, - r#" -[module] -name = "example" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "block" -chain_id = 1 -"#, - ) - .unwrap(); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - let limits = ModuleLimits::default(); - - let clock = ManualClock::new(); - clock.set(UNIX_EPOCH + Duration::from_secs(1_700_000_000)); - - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - Some(clock.as_override()), - ) - .await - .expect("boot_single with a manual clock override"); - - let block = nexum::host::types::Block { - chain_id: 1, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - }; - let dispatched = supervisor.dispatch_block(block).await; - assert_eq!(dispatched, 1, "the overridden-clock module dispatched"); - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - - // Advancing the shared handle is observable on the same source the store - // reads; the boot path did not clone away from it. - clock.advance(Duration::from_secs(1)); - assert_eq!( - wasmtime_wasi::HostWallClock::now(&clock), - Duration::from_secs(1_700_000_001), - ); -} - -// ── Production module integration tests ──────────────────── -// -// One test per module that goes through the real wit-bindgen + -// WitBindgenHost adapter + supervisor dispatch path, not just the -// strategy-level MockHost coverage. Mirrors the example-module e2e -// shape above; each test is guarded by `module_wasm_or_skip()` so -// local runs without a fresh `--target wasm32-wasip2 --release` -// build are skipped rather than failing. - -const SEPOLIA: u64 = 11_155_111; - -/// Path to a production module's .wasm artefact under the workspace -/// target dir. `Cargo` writes the artefact as `.wasm` with -/// hyphens replaced by underscores, so the helper mirrors that. -fn module_wasm(module_name: &str) -> PathBuf { - let artifact = module_name.replace('-', "_"); - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .parent() - .unwrap() - .join(format!("target/wasm32-wasip2/release/{artifact}.wasm")) -} - -fn module_wasm_or_skip(module_name: &str) -> Option { - let p = module_wasm(module_name); - if p.exists() { - Some(p) - } else if std::env::var_os("CI").is_some() { - // The CI test job builds every module wasm before running the - // suite, so a missing artifact here means the pipeline regressed. - // Fail loudly rather than skip into a hollow green. - panic!( - "{} not found under CI - the test job must build the module wasms before the suite runs", - p.display() - ); - } else { - eprintln!( - "SKIP: {} not found - build with `cargo build -p {module_name} --target wasm32-wasip2 --release`", - p.display() - ); - None - } -} - -/// Resolve a real `module.toml` for one of the production modules. -/// Looking up the real manifest (rather than synthesising one) keeps -/// the integration test honest about the capability set + subscription -/// shape each module actually ships. -fn production_module_toml(relative_path: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join(relative_path) -} - -fn synthetic_sepolia_block() -> nexum::host::types::Block { - nexum::host::types::Block { - chain_id: SEPOLIA, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - } -} - -/// Boot a single module from `(wasm, manifest)` and return the live -/// supervisor. Shared body across the 5 integration tests. -async fn boot_production_module( - engine: &wasmtime::Engine, - linker: &Linker>, - local_store: &crate::host::local_store_redb::LocalStore, - wasm: &Path, - manifest: &Path, -) -> DefaultSupervisor { - let components = test_components(local_store.clone()); - let limits = ModuleLimits::default(); - Supervisor::boot_single( - engine, - linker, - wasm, - Some(manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single") -} - -#[tokio::test] -async fn e2e_price_alert_block_dispatch() { - let Some(wasm) = module_wasm_or_skip("price-alert") else { - return; - }; - let manifest = production_module_toml("modules/examples/price-alert/module.toml"); - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - - let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - let dispatched = supervisor.dispatch_block(synthetic_sepolia_block()).await; - assert_eq!(dispatched, 1); - assert_eq!(supervisor.alive_count(), 1); -} - -#[tokio::test] -async fn e2e_balance_tracker_block_dispatch() { - let Some(wasm) = module_wasm_or_skip("balance-tracker") else { - return; - }; - let manifest = production_module_toml("modules/examples/balance-tracker/module.toml"); - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - - let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - let dispatched = supervisor.dispatch_block(synthetic_sepolia_block()).await; - assert_eq!(dispatched, 1); - assert_eq!(supervisor.alive_count(), 1); -} - -/// End-to-end wasi:http path: http-probe fetches a loopback server -/// admitted by its allowlist, then fetches an off-list host and -/// requires the HTTP-request-denied outcome inside the guest. The -/// module returns `Ok` from `on_event` only when both legs hold, so -/// `dispatched == 1` asserts the success AND denied paths together. -/// The off-list host is never resolved or dialled (the gate denies -/// before any connection), so the test needs no external network. -#[tokio::test] -async fn e2e_http_probe_allowlisted_fetch_and_denied_path() { - let Some(wasm) = module_wasm_or_skip("http-probe") else { - return; - }; - let server = wiremock::MockServer::start().await; - wiremock::Mock::given(wiremock::matchers::method("GET")) - .and(wiremock::matchers::path("/status")) - .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("ok")) - .mount(&server) - .await; - - let dir = tempfile::tempdir().unwrap(); - let manifest = dir.path().join("module.toml"); - std::fs::write( - &manifest, - format!( - r#" -[module] -name = "http-probe" - -[capabilities] -required = ["logging", "http"] - -[capabilities.http] -allow = ["127.0.0.1"] - -[[subscription]] -kind = "block" -chain_id = 1 - -[config] -probe_url = "{}/status" -denied_url = "http://denied.invalid/" -"#, - server.uri(), - ), - ) - .unwrap(); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_store_dir, store) = temp_local_store(); - - let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - let block = nexum::host::types::Block { - chain_id: 1, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - }; - let dispatched = supervisor.dispatch_block(block).await; - assert_eq!( - dispatched, 1, - "both http-probe legs (allowlisted fetch + denied off-list fetch) must succeed", - ); - assert_eq!(supervisor.alive_count(), 1); -} - -// ── Init-failed modules must be marked dead ──────────────── - -/// Drive `Supervisor::boot_single` with a module whose `[config]` -/// carries a malformed `threshold` value (`"not-a-number"`). The -/// module's `init` returns `Err(fault.invalid-input)`. -/// Previously the supervisor still marked the module -/// `alive = true`, so it received block dispatches forever. The fix -/// flips `alive = false` when `init` fails. -/// -/// Surfaced live on Sepolia in -/// `docs/operations/m3-edge-case-validation.md` scenario 1.4. -#[tokio::test] -async fn init_failure_marks_module_dead_and_excludes_from_dispatch() { - let Some(wasm) = module_wasm_or_skip("price-alert") else { - return; - }; - - // Synthesise a manifest with the same shape as the real - // price-alert module but with a `threshold` that the strategy - // rejects in `parse_config`. - let dir = tempfile::tempdir().unwrap(); - let manifest = dir.path().join("module.toml"); - std::fs::write( - &manifest, - r#" -[module] -name = "price-alert" - -[capabilities] -required = ["logging", "chain"] - -[[subscription]] -kind = "block" -chain_id = 11155111 - -[config] -oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" -decimals = "8" -threshold = "not-a-number" -direction = "below" -every_n_blocks = "1" -"#, - ) - .unwrap(); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - - let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - - // The module loaded successfully (wasm compiled, capabilities - // matched, manifest parsed) but `init` returned InvalidInput. - assert_eq!(supervisor.module_count(), 1, "module is loaded"); - assert_eq!( - supervisor.alive_count(), - 0, - "init-failed module must be marked dead", - ); - - // Dispatch the synthetic block. The init-failed module must - // not be reached by the dispatcher. - let dispatched = supervisor.dispatch_block(synthetic_sepolia_block()).await; - assert_eq!( - dispatched, 0, - "no live module is subscribed to chain 11155111 blocks", - ); -} - -/// Dead modules (here: init-failed, `alive = false`) must not contribute -/// their chain to `block_chains()` or `chain_log_subscriptions()`. Without -/// the alive filter the builder opens live RPC subscriptions against chains -/// that will never dispatch to any module, wasting connections and emitting -/// zero-dispatch events until shutdown. -#[tokio::test] -async fn dead_modules_excluded_from_subscription_lists() { - let Some(wasm) = module_wasm_or_skip("price-alert") else { - return; - }; - - let dir = tempfile::tempdir().unwrap(); - let manifest = dir.path().join("module.toml"); - // Manifest declares both a block and a chain-log subscription so the - // test genuinely exercises both filter paths — not just the trivially - // empty chain_log case of a block-only module. - std::fs::write( - &manifest, - r#" -[module] -name = "price-alert" - -[capabilities] -required = ["logging", "chain"] - -[[subscription]] -kind = "block" -chain_id = 11155111 - -[[subscription]] -kind = "chain-log" -chain_id = 11155111 -address = "0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC" -event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" - -[config] -oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" -decimals = "8" -threshold = "not-a-number" -direction = "below" -every_n_blocks = "1" -"#, - ) - .unwrap(); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - let supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - - assert_eq!(supervisor.alive_count(), 0, "init-failed module is dead"); - assert!( - supervisor.block_chains().is_empty(), - "dead module must not contribute to block_chains()", - ); - assert!( - supervisor.chain_log_subscriptions().is_empty(), - "dead module must not contribute to chain_log_subscriptions()", - ); - assert!( - supervisor.dead_modules_hold_subscriptions(), - "the filtered-out subscriptions must be attributed to the dead module", - ); -} - -/// Positive control for the alive filter: with one dead and one alive -/// module, the alive module's subscriptions must survive the filter. -/// Guards against a regression where the filter (or a manifest-schema -/// change) empties the lists for everyone, which the all-dead test -/// above cannot distinguish from correct filtering. -#[tokio::test] -async fn alive_module_subscriptions_survive_alongside_dead_module() { - let Some(price_alert_wasm) = module_wasm_or_skip("price-alert") else { - return; - }; - let Some(example_wasm) = example_wasm_or_skip() else { - return; - }; - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - - let tmp = tempfile::tempdir().unwrap(); - // price-alert with an unparseable threshold: loads, then init fails. - let dead_manifest = tmp.path().join("price-alert.toml"); - std::fs::write( - &dead_manifest, - r#" -[module] -name = "price-alert" - -[capabilities] -required = ["logging", "chain"] - -[[subscription]] -kind = "block" -chain_id = 11155111 - -[config] -oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" -decimals = "8" -threshold = "not-a-number" -direction = "below" -every_n_blocks = "1" -"#, - ) - .unwrap(); - // example module inits fine and subscribes to chain 1 blocks. - let alive_manifest = tmp.path().join("example.toml"); - std::fs::write( - &alive_manifest, - r#" -[module] -name = "example" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "block" -chain_id = 1 -"#, - ) - .unwrap(); - - let engine_cfg = crate::engine_config::EngineConfig { - engine: crate::engine_config::EngineSection { - state_dir: tmp.path().to_path_buf(), - log_level: "info".into(), - metrics: crate::engine_config::MetricsSection::default(), - ..Default::default() - }, - limits: crate::engine_config::ModuleLimits::default(), - chains: std::collections::HashMap::new(), - extensions: std::collections::HashMap::new(), - remote_store: None, - modules: vec![ - crate::engine_config::ModuleEntry { - path: price_alert_wasm, - manifest: Some(dead_manifest), - }, - crate::engine_config::ModuleEntry { - path: example_wasm, - manifest: Some(alive_manifest), - }, - ], - adapters: Vec::new(), - }; - - let supervisor = Supervisor::boot( - &engine, - &linker, - &engine_cfg, - &components, - &core_extensions(), - None, - ) - .await - .expect("boot"); - - assert_eq!(supervisor.module_count(), 2); - assert_eq!(supervisor.alive_count(), 1, "only the example is alive"); - let chains = supervisor.block_chains(); - assert_eq!( - chains.iter().map(|c| c.id()).collect::>(), - vec![1], - "the alive module's chain survives; the dead module's does not", - ); - assert!( - supervisor.dead_modules_hold_subscriptions(), - "the dead module's dropped subscription is attributable", - ); -} - -// `with_dispatch_deadline` bounds a dispatch in wall-clock, covering -// host-call time fuel cannot meter. - -/// `with_dispatch_deadline` cancels rather than awaits an over-long future: -/// a sleep far past the deadline is dropped, not run. The end-to-end case is -/// `dispatch_deadline_cuts_off_a_blocked_host_call_and_recovers`. -#[tokio::test] -async fn dispatch_deadline_interrupts_a_sleeping_host_call() { - use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; - - let ran_to_completion = Arc::new(AtomicBool::new(false)); - let flag = ran_to_completion.clone(); - // Models a guest whose host call parks for an hour (a hung RPC / a - // server that never answers). Without the deadline this future would - // hold the dispatch for the full hour. - let dispatch = async move { - tokio::time::sleep(Duration::from_secs(3600)).await; - flag.store(true, Ordering::SeqCst); - }; - - let result = with_dispatch_deadline(Duration::from_millis(50), dispatch).await; - - assert!( - result.is_err(), - "a host call sleeping 1h must be cut off by the 50ms deadline", - ); - assert!( - !ran_to_completion.load(Ordering::SeqCst), - "the sleeping future must be cancelled, not left to run unbounded", - ); -} - -/// The deadline does not punish a dispatch that finishes promptly: the -/// inner future's value is returned untouched. -#[tokio::test] -async fn dispatch_deadline_lets_a_prompt_call_finish() { - let result = with_dispatch_deadline(Duration::from_secs(30), async { 7_u8 }).await; - assert_eq!(result.expect("prompt call is well under the deadline"), 7); -} - -/// The resolved deadline honours an override, falls back to the default -/// when unset, and saturates a degenerate `0` up to the 1s floor so it -/// cannot cut every dispatch off instantly. -#[test] -fn event_deadline_resolves_override_default_and_floor() { - let default = ModuleLimits::default(); - assert_eq!( - default.event_deadline(), - Duration::from_secs(120), - "unset resolves to the built-in default", - ); - - let overridden = ModuleLimits { - event_deadline_secs: Some(5), - ..ModuleLimits::default() - }; - assert_eq!(overridden.event_deadline(), Duration::from_secs(5)); - - let degenerate = ModuleLimits { - event_deadline_secs: Some(0), - ..ModuleLimits::default() - }; - assert_eq!( - degenerate.event_deadline(), - Duration::from_secs(1), - "a zero override saturates up to the 1s floor", - ); -} - -/// A guest suspended inside a host call is cut off by the wall-clock -/// deadline, the poisoned store torn down and the module marked dead, then a -/// later dispatch reinstantiates it on a fresh store. The `slow-host` fixture -/// parks its first `chain::request` an hour past a 1s deadline override; the -/// park is one-shot, so the module recovers after the restart backoff. -#[tokio::test] -async fn dispatch_deadline_cuts_off_a_blocked_host_call_and_recovers() { - use std::time::Instant; - - let Some(wasm) = module_wasm_or_skip("slow-host") else { - return; - }; - - let engine = make_wasmtime_engine(); - let linker = crate::supervisor::build_linker::(&engine, &[]) - .expect("build_linker"); - - // Program the chain backend: the first request parks for an hour (a - // hung node), every request answers `eth_blockNumber` once it runs. - // The park is consumed when the first request begins, so the request - // dropped at the deadline leaves the next one prompt. - let chain = crate::test_utils::MockChainProvider::new(); - chain.on_method( - crate::host::component::ChainMethod::EthBlockNumber, - "\"0x1\"", - ); - chain.delay_next_request(Duration::from_secs(3600)); - let components = - crate::test_utils::mock_components_from(chain, crate::test_utils::MockStateStore::new()); - - let manifest = fixture_module_toml("modules/fixtures/slow-host/module.toml"); - // 1s is the floor the resolver saturates up to; short enough to keep - // the test quick, long enough to prove the call was cut off (the park - // is an hour) rather than never started. - let limits = ModuleLimits { - event_deadline_secs: Some(1), - ..ModuleLimits::default() - }; - - let mut supervisor = Supervisor::::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &[], - None, - ) - .await - .expect("boot_single"); - assert_eq!(supervisor.alive_count(), 1, "slow-host loads alive"); - - let block = nexum::host::types::Block { - chain_id: 1, - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - }; - - // First dispatch: the guest suspends inside the parked host call and - // the 1s deadline cuts it off. It resolves in ~deadline wall-time, not - // the hour the mock would otherwise park for. - let started = Instant::now(); - let dispatched = supervisor.dispatch_block(block.clone()).await; - let elapsed = started.elapsed(); - assert_eq!(dispatched, 0, "the deadline cut the blocked host call off"); - assert!( - elapsed < Duration::from_secs(30), - "cut off in ~deadline wall-time ({elapsed:?}), not the 1h park", - ); - assert_eq!( - supervisor.alive_count(), - 0, - "the module is marked dead after the deadline, like a trap", - ); - - // Wait out the 1s restart backoff, then dispatch again. Phase 1 of the - // dispatch reinstantiates the dead module on a fresh store (proving the - // store poisoned by the dropped fiber was correctly torn down and - // rebuilt); the guest's next request is prompt, so it dispatches Ok. - tokio::time::sleep(Duration::from_millis(1_200)).await; - let dispatched_again = supervisor.dispatch_block(block).await; - assert_eq!( - dispatched_again, 1, - "after backoff the module restarts on a fresh store and dispatches", - ); - assert_eq!( - supervisor.alive_count(), - 1, - "the recovered module is alive again", - ); -} - -// ── Resource-limit enforcement tests ─────────────────────── -// -// Two evil-by-design fixtures under `modules/fixtures/` exercise the -// per-module fuel + memory caps (DEFAULT_FUEL_PER_EVENT -// + DEFAULT_MEMORY_LIMIT). The tests assert: -// -// 1. The host catches the trap (OutOfFuel / memory-grow rejection) -// without panicking the supervisor. -// 2. The trapping module is marked dead (alive_count drops to 0 for a -// single-module supervisor). -// 3. A subsequent dispatch does not re-enter the dead module + the -// engine itself remains alive (dispatched count is 0, no crash). -// -// Locks the M1 fuel/memory wiring against regression so future -// changes to the supervisor cannot silently bypass the limits. - -fn fixture_module_toml(relative_path: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join(relative_path) -} - -/// Boot a single fixture (.wasm + module.toml) under the supervisor. -/// Shared body across the two resource-limit tests. -async fn boot_fixture(wasm: &Path, manifest_relative: &str) -> DefaultSupervisor { - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - let manifest = fixture_module_toml(manifest_relative); - let limits = crate::engine_config::ModuleLimits::default(); - Supervisor::boot_single( - &engine, - &linker, - wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single") -} - -#[tokio::test] -async fn resource_limit_fuel_bomb_traps_and_marks_module_dead() { - let Some(wasm) = module_wasm_or_skip("fuel-bomb") else { - return; - }; - let mut supervisor = boot_fixture(&wasm, "modules/fixtures/fuel-bomb/module.toml").await; - assert_eq!(supervisor.module_count(), 1); - assert_eq!(supervisor.alive_count(), 1, "loads alive"); - - // First dispatch enters the fuel-bomb's unbounded loop. wasmtime - // burns through the per-event fuel budget; the call returns Err - // (a trap), the supervisor catches it and marks the module dead. - let block = nexum::host::types::Block { - chain_id: 1, - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - }; - let dispatched = supervisor.dispatch_block(block.clone()).await; - assert_eq!( - dispatched, 0, - "fuel-bomb trapped, no module accepted the dispatch", - ); - assert_eq!( - supervisor.alive_count(), - 0, - "fuel-bomb is marked dead after the trap", - ); - - // Engine is still healthy for further dispatches. - let dispatched_again = supervisor.dispatch_block(block).await; - assert_eq!( - dispatched_again, 0, - "dead module excluded from second dispatch", - ); -} - -#[tokio::test] -async fn resource_limit_dead_bomb_does_not_starve_healthy_module() { - // Strongest assertion of the isolation invariant: load fuel-bomb - // + the M1 example module side-by-side. After the bomb traps, - // dispatch a second block and confirm the example module still - // receives it (dispatched == 1, alive_count == 1 because only - // one of the two is alive). - let Some(bomb_wasm) = module_wasm_or_skip("fuel-bomb") else { - return; - }; - let Some(example_wasm) = example_wasm_or_skip() else { - return; - }; - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - - // Hand-build an EngineConfig with both modules subscribed to - // chain 1 blocks. fuel-bomb's manifest already declares the - // block subscription; the example module needs a synthesised - // manifest because its on-disk manifest does not subscribe to - // blocks by default. - let tmp = tempfile::tempdir().unwrap(); - let example_manifest = tmp.path().join("example.toml"); - std::fs::write( - &example_manifest, - r#" -[module] -name = "example" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "block" -chain_id = 1 -"#, - ) - .unwrap(); - - let engine_cfg = crate::engine_config::EngineConfig { - engine: crate::engine_config::EngineSection { - state_dir: tmp.path().to_path_buf(), - log_level: "info".into(), - metrics: crate::engine_config::MetricsSection::default(), - ..Default::default() - }, - limits: crate::engine_config::ModuleLimits::default(), - chains: std::collections::HashMap::new(), - extensions: std::collections::HashMap::new(), - remote_store: None, - modules: vec![ - crate::engine_config::ModuleEntry { - path: bomb_wasm.clone(), - manifest: Some(fixture_module_toml( - "modules/fixtures/fuel-bomb/module.toml", - )), - }, - crate::engine_config::ModuleEntry { - path: example_wasm.clone(), - manifest: Some(example_manifest.clone()), - }, - ], - adapters: Vec::new(), - }; - - let mut supervisor = Supervisor::boot( - &engine, - &linker, - &engine_cfg, - &components, - &core_extensions(), - None, - ) - .await - .expect("boot"); - - assert_eq!(supervisor.module_count(), 2); - assert_eq!(supervisor.alive_count(), 2, "both load alive"); - - // First dispatch: fuel-bomb burns through its budget + traps. - // The example module dispatches normally on the same block. The - // bomb is now dead. - let block = nexum::host::types::Block { - chain_id: 1, - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - }; - let dispatched = supervisor.dispatch_block(block.clone()).await; - assert_eq!( - dispatched, 1, - "example module received the dispatch even though fuel-bomb trapped", - ); - assert_eq!(supervisor.alive_count(), 1, "only the example is alive"); - - // Second dispatch: only the example accepts; the dead bomb is - // skipped by the dispatch fast-path. - let dispatched_again = supervisor.dispatch_block(block).await; - assert_eq!(dispatched_again, 1); - assert_eq!(supervisor.alive_count(), 1); -} - -#[tokio::test] -async fn resource_limit_memory_bomb_traps_and_marks_module_dead() { - let Some(wasm) = module_wasm_or_skip("memory-bomb") else { - return; - }; - let mut supervisor = boot_fixture(&wasm, "modules/fixtures/memory-bomb/module.toml").await; - assert_eq!(supervisor.module_count(), 1); - assert_eq!(supervisor.alive_count(), 1); - - // memory-bomb's on_event allocates 128 MiB which exceeds the - // 64 MiB DEFAULT_MEMORY_LIMIT; wasmtime rejects the memory.grow - // and propagates a trap. - let block = nexum::host::types::Block { - chain_id: 1, - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - }; - let dispatched = supervisor.dispatch_block(block.clone()).await; - assert_eq!(dispatched, 0); - assert_eq!(supervisor.alive_count(), 0); - - let dispatched_again = supervisor.dispatch_block(block).await; - assert_eq!(dispatched_again, 0); -} - -// ── Supervisor auto-restart with exponential backoff ─────── -// -// flaky-bomb traps on the first N events (via wasm `unreachable!`) -// and recovers on event N+1. Exercises the full restart lifecycle: -// -// 1. Dispatch 1: trap -> alive=false, failure_count=1, next_attempt=+1s. -// 2. Immediate redispatch: skipped (next_attempt in the future). -// 3. After 1.1s: alive flipped back on, dispatch retried. -// 4. With fail_first_n=1, the second attempt succeeds -> failure_count -// resets to 0, next_attempt = None. -// -// Asserts the schedule shape end-to-end with real wall-clock. - -#[tokio::test] -async fn restart_flaky_module_recovers_after_backoff() { - let Some(wasm) = module_wasm_or_skip("flaky-bomb") else { - return; - }; - - let dir = tempfile::tempdir().unwrap(); - let manifest = dir.path().join("module.toml"); - // fail_first_n = 1 so the module traps once and recovers on the - // second dispatch attempt. Keeps the test wall-clock under 2 s. - std::fs::write( - &manifest, - r#" -[module] -name = "flaky-bomb" - -[capabilities] -required = ["logging", "local-store"] - -[[subscription]] -kind = "block" -chain_id = 1 - -[config] -fail_first_n = "1" -"#, - ) - .unwrap(); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - let components = test_components(store); - let limits = crate::engine_config::ModuleLimits::default(); - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single"); - assert_eq!(supervisor.alive_count(), 1); - - let block = nexum::host::types::Block { - chain_id: 1, - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - }; - - // Dispatch 1: trap. Module marked dead with a +1s backoff. - let dispatched = supervisor.dispatch_block(block.clone()).await; - assert_eq!(dispatched, 0, "first dispatch trapped, no module accepted"); - assert_eq!(supervisor.alive_count(), 0, "module marked dead"); - - // Immediate redispatch (under the 1s backoff): still skipped. - let dispatched_immediate = supervisor.dispatch_block(block.clone()).await; - assert_eq!( - dispatched_immediate, 0, - "in-backoff module not eligible for redispatch yet", - ); - assert_eq!(supervisor.alive_count(), 0); - - // Wait for the 1s backoff window to elapse (+ a small fudge for - // scheduler jitter). - tokio::time::sleep(std::time::Duration::from_millis(1100)).await; - - // Dispatch 3: now eligible. fail_first_n=1 was satisfied on - // dispatch 1, so this attempt succeeds. The supervisor flips - // alive back on, dispatch lands, failure_count resets. - let dispatched_after_backoff = supervisor.dispatch_block(block.clone()).await; - assert_eq!( - dispatched_after_backoff, 1, - "module recovered after the backoff window", - ); - assert_eq!(supervisor.alive_count(), 1, "recovered + alive"); - - // Dispatch 4: steady-state, no backoff in play. Module is happy. - let dispatched_steady = supervisor.dispatch_block(block).await; - assert_eq!(dispatched_steady, 1); -} - -// ── Poison-pill quarantine ────────────────────────────────── -// -// fuel-bomb traps on every dispatch. With a -// tight poison policy (3 failures / 60 s) we can observe the -// supervisor escalate from "retry" to "permanent quarantine" inside -// ~4 s of wall clock: -// -// trap 1: failure_count=1, next_attempt=+1s -// sleep 1.1s -// trap 2: failure_count=2, next_attempt=+2s -// sleep 2.1s -// trap 3: failure_count=3 -> POISONED. Recent failures hit the -// window threshold; the supervisor stops attempting -// restarts entirely. Subsequent dispatches skip the -// module silently. -// -// Tests assert each transition + the post-quarantine no-op semantic. - -#[tokio::test] -async fn poison_pill_quarantines_module_after_threshold() { - let Some(wasm) = module_wasm_or_skip("fuel-bomb") else { - return; - }; - let manifest = production_module_toml("modules/fixtures/fuel-bomb/module.toml"); - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - let components = test_components(store); - - // Tight policy: 3 failures in 60 s -> quarantine. Keeps the - // test wall-clock under 4 s. Set through `[limits.poison]`. - let limits = crate::engine_config::ModuleLimits { - poison: crate::engine_config::PoisonLimitsSection { - max_failures: Some(3), - window_secs: Some(60), - }, - ..Default::default() - }; - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single"); - - assert_eq!(supervisor.module_count(), 1); - assert_eq!(supervisor.alive_count(), 1); - assert_eq!(supervisor.poisoned_count(), 0); - - let block = nexum::host::types::Block { - chain_id: 1, - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - }; - - // Trap 1. - let dispatched = supervisor.dispatch_block(block.clone()).await; - assert_eq!(dispatched, 0); - assert_eq!(supervisor.alive_count(), 0); - assert_eq!(supervisor.poisoned_count(), 0, "1 trap < threshold"); - tokio::time::sleep(std::time::Duration::from_millis(1_100)).await; - - // Trap 2. - let dispatched = supervisor.dispatch_block(block.clone()).await; - assert_eq!(dispatched, 0); - assert_eq!(supervisor.poisoned_count(), 0, "2 traps < threshold"); - tokio::time::sleep(std::time::Duration::from_millis(2_100)).await; - - // Trap 3 -> POISONED. - let dispatched = supervisor.dispatch_block(block.clone()).await; - assert_eq!(dispatched, 0); - assert_eq!( - supervisor.poisoned_count(), - 1, - "3 traps inside window -> module quarantined", - ); - - // Post-quarantine: immediately re-dispatch. A poisoned module - // is excluded regardless of how much time has passed; the - // backoff timer is no longer load-bearing. We do NOT wait for - // the would-be next_attempt because the test just needs to - // observe the "skipped silently" semantic, not the timing. - let dispatched = supervisor.dispatch_block(block).await; - assert_eq!( - dispatched, 0, - "poisoned module excluded from dispatch forever", - ); - assert_eq!(supervisor.poisoned_count(), 1); -} - -// ── Log pipeline ───────────────────────────────────────────── -// -// The typed pipeline captures from three points: the -// nexum:host/logging glue (HostInterface), the per-store -// stdout/stderr pipes (Stdout/Stderr), and the supervisor death -// path (Panic). These E2E tests prove a real run leaves retrievable -// records and that a dying run leaves a Panic record, both read back -// through the embedder-facing LogPipeline handle. Stdout/Stderr line -// splitting is covered at the unit level on the StdioStream writer. - -/// Components plus a retained clone of the log pipeline so a test can -/// read runs and records back after dispatch. -fn components_with_logs( - store: crate::host::local_store_redb::LocalStore, -) -> (Components, crate::host::logs::LogPipeline) { - let logs = crate::test_utils::in_memory_logs(); - let components = Components { - chain: ProviderPool::empty(), - store, - ext: (), - logs: logs.clone(), - remote: crate::host::remote_store_bee::RemoteStore::disabled(), - }; - (components, logs) -} - -/// Ported to the [`TestRuntime`] harness: it replaces the hand-built -/// `boot_single` plus manual `dispatch_block` ceremony with an inline-manifest -/// launch, an injected header, and a polled log read, while holding the same -/// coverage. The example module logs via the host logging glue at init and on -/// the block, so its run holds retrievable HostInterface records after one -/// dispatch. -#[tokio::test] -async fn host_interface_records_are_retrievable_after_a_run() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - - let mut rt = crate::test_utils::TestRuntime::builder(wasm) - .manifest_inline( - r#" -[module] -name = "example" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "block" -chain_id = 1 -"#, - ) - .launch() - .await - .expect("launch example over the harness"); - - let mut header: alloy_rpc_types_eth::Header = alloy_rpc_types_eth::Header::default(); - header.inner.number = 19_000_000; - rt.push_block(header); - - // The polled log read doubles as the dispatch barrier: the on_event line - // only lands once the event loop has dispatched the injected block. - rt.wait_for_log("example", "block 19000000") - .await - .expect("the on_event log line lands after dispatch"); - - let runs = rt.logs().list_runs("example"); - assert_eq!(runs.len(), 1, "one run recorded for the example module"); - let run = runs[0].run.clone(); - assert_eq!(run.seq, 0, "the first run is sequence 0"); - let page = rt.logs().read(&run, 0); - assert!(!page.records.is_empty(), "run left retrievable records"); - assert!( - page.records - .iter() - .all(|r| r.source == LogSource::HostInterface), - "the example module logs only through the host interface", - ); - assert!( - page.records - .iter() - .any(|r| r.message.contains("block 19000000")), - "the on_event log line is retained", - ); - - rt.shutdown(); - rt.wait().await.expect("clean shutdown"); -} - -#[tokio::test] -async fn dying_run_leaves_a_panic_record() { - let Some(wasm) = module_wasm_or_skip("fuel-bomb") else { - return; - }; - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - let (components, logs) = components_with_logs(store); - let manifest = fixture_module_toml("modules/fixtures/fuel-bomb/module.toml"); - let limits = ModuleLimits::default(); - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single"); - - let block = nexum::host::types::Block { - chain_id: 1, - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - }; - // fuel-bomb traps on the first event; the supervisor synthesizes a - // Panic record on the dead run. - assert_eq!( - supervisor.dispatch_block(block).await, - 0, - "the bomb trapped" - ); - - let runs = logs.list_runs("fuel-bomb"); - assert_eq!(runs.len(), 1); - let page = logs.read(&runs[0].run, 0); - let panic = page - .records - .iter() - .find(|r| r.source == LogSource::Panic) - .expect("a panic record on the dead run"); - assert_eq!(panic.level, Level::ERROR); - assert!(panic.message.contains("terminated")); - assert_eq!( - panic.message.lines().count(), - 1, - "the panic record carries the trap's root cause, not the frame list", - ); -} - -#[tokio::test] -async fn facade_panic_leaves_stderr_host_interface_and_panic_records() { - let Some(wasm) = module_wasm_or_skip("panic-bomb") else { - return; - }; - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - let (components, logs) = components_with_logs(store); - let manifest = fixture_module_toml("modules/fixtures/panic-bomb/module.toml"); - let limits = ModuleLimits::default(); - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single"); - - let block = nexum::host::types::Block { - chain_id: 1, - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - }; - assert_eq!( - supervisor.dispatch_block(block).await, - 0, - "the bomb panicked" - ); - - // The facade panic hook writes to stderr and reports over the host - // logging call before the trap surfaces, and the supervisor - // synthesizes the death record: one dead run, three capture points. - let runs = logs.list_runs("panic-bomb"); - assert_eq!(runs.len(), 1); - let page = logs.read(&runs[0].run, 0); - let find = |source: LogSource, needle: &str| { - page.records - .iter() - .find(|r| r.source == source && r.message.contains(needle)) - }; - let stderr = find(LogSource::Stderr, "detonated").expect("the hook's stderr line was captured"); - assert_eq!(stderr.level, Level::WARN, "stderr copy is warn"); - let host = - find(LogSource::HostInterface, "detonated").expect("the hook's sink call was captured"); - assert_eq!(host.level, Level::ERROR, "sink copy is error"); - let death = - find(LogSource::Panic, "terminated").expect("the supervisor synthesized the death record"); - assert_eq!(death.level, Level::ERROR, "death record is error"); -} - -// ── Multi-chain isolation ─────────────────────────────────── -// -// The supervisor's dispatch path is per-chain: `dispatch_block(block)` -// walks every module but only invokes those whose -// `[[subscription]] kind = "block"` matches `block.chain_id`. A -// module on chain A receives nothing when a chain-B block arrives, -// and vice versa. Combined with the per-module restart / poison -// state, this gives the engine multi-chain isolation by -// construction: a poisoned module on one chain cannot starve -// modules on any other chain. -// -// The WS reconnect tasks add the upstream symmetry: each -// chain owns its own subscription task + backoff timer, so a chain-A -// WS drop never blocks chain-B events. - -#[tokio::test] -async fn multi_chain_dispatch_isolates_modules_by_chain() { - // Two example modules on two different chains. Confirm dispatch - // on chain A reaches only the chain-A module and vice versa. - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - - let dir = tempfile::tempdir().unwrap(); - let chain_a_manifest = dir.path().join("a.toml"); - let chain_b_manifest = dir.path().join("b.toml"); - std::fs::write( - &chain_a_manifest, - r#" -[module] -name = "module-a" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "block" -chain_id = 1 -"#, - ) - .unwrap(); - std::fs::write( - &chain_b_manifest, - r#" -[module] -name = "module-b" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "block" -chain_id = 100 -"#, - ) - .unwrap(); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - - let engine_cfg = crate::engine_config::EngineConfig { - engine: crate::engine_config::EngineSection { - state_dir: dir.path().to_path_buf(), - log_level: "info".into(), - metrics: crate::engine_config::MetricsSection::default(), - ..Default::default() - }, - limits: crate::engine_config::ModuleLimits::default(), - chains: std::collections::HashMap::new(), - extensions: std::collections::HashMap::new(), - remote_store: None, - modules: vec![ - crate::engine_config::ModuleEntry { - path: wasm.clone(), - manifest: Some(chain_a_manifest), - }, - crate::engine_config::ModuleEntry { - path: wasm, - manifest: Some(chain_b_manifest), - }, - ], - adapters: Vec::new(), - }; - - let mut supervisor = Supervisor::boot( - &engine, - &linker, - &engine_cfg, - &components, - &core_extensions(), - None, - ) - .await - .expect("boot"); - assert_eq!(supervisor.module_count(), 2); - assert_eq!(supervisor.alive_count(), 2); - - let block_a = nexum::host::types::Block { - chain_id: 1, - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - }; - let block_b = nexum::host::types::Block { - chain_id: 100, - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - }; - - // Chain A block reaches only module-a. - let dispatched = supervisor.dispatch_block(block_a).await; - assert_eq!(dispatched, 1, "only module-a subscribed to chain 1"); - assert_eq!(supervisor.alive_count(), 2); - - // Chain B block reaches only module-b. - let dispatched = supervisor.dispatch_block(block_b).await; - assert_eq!(dispatched, 1, "only module-b subscribed to chain 100"); - assert_eq!(supervisor.alive_count(), 2); -} - -/// Acceptance criterion for the per-handler dispatch rate limit: a -/// source flooding one module is throttled at the dispatch boundary -/// (over-rate events dropped) while a second module on another chain -/// still gets every dispatch. Two healthy example modules; a tiny -/// `[limits.dispatch]` (burst = 2, refill = 1/s) so the flood drains -/// the first module's bucket almost immediately. -#[tokio::test] -async fn dispatch_rate_limit_throttles_a_flood_without_starving_others() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - - let dir = tempfile::tempdir().unwrap(); - let flood_manifest = dir.path().join("flood.toml"); - let calm_manifest = dir.path().join("calm.toml"); - std::fs::write( - &flood_manifest, - r#" -[module] -name = "flood" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "block" -chain_id = 1 -"#, - ) - .unwrap(); - std::fs::write( - &calm_manifest, - r#" -[module] -name = "calm" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "block" -chain_id = 100 -"#, - ) - .unwrap(); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - - let engine_cfg = crate::engine_config::EngineConfig { - engine: crate::engine_config::EngineSection { - state_dir: dir.path().to_path_buf(), - log_level: "info".into(), - metrics: crate::engine_config::MetricsSection::default(), - ..Default::default() - }, - limits: crate::engine_config::ModuleLimits { - dispatch: crate::engine_config::DispatchLimitsSection { - burst: Some(2), - refill_per_sec: Some(1), - }, - ..Default::default() - }, - chains: std::collections::HashMap::new(), - extensions: std::collections::HashMap::new(), - remote_store: None, - modules: vec![ - crate::engine_config::ModuleEntry { - path: wasm.clone(), - manifest: Some(flood_manifest), - }, - crate::engine_config::ModuleEntry { - path: wasm, - manifest: Some(calm_manifest), - }, - ], - adapters: Vec::new(), - }; - - let mut supervisor = Supervisor::boot( - &engine, - &linker, - &engine_cfg, - &components, - &core_extensions(), - None, - ) - .await - .expect("boot"); - assert_eq!(supervisor.alive_count(), 2); - - // Flood chain 1 with far more blocks than the burst allowance. The - // loop runs in well under a second, so refill (1 token/s) adds at - // most one or two tokens: the flood module is dispatched only a - // handful of times and the rest are dropped. - const FLOOD: u64 = 20; - let mut flood_dispatched = 0; - for number in 0..FLOOD { - flood_dispatched += supervisor - .dispatch_block(nexum::host::types::Block { - chain_id: 1, - number, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - }) - .await; - } - assert!( - flood_dispatched >= 2, - "the burst allowance ({flood_dispatched}) must clear before throttling", - ); - assert!( - flood_dispatched < FLOOD as usize, - "the flood must be throttled: {flood_dispatched} of {FLOOD} got through", - ); - - // The calm module on chain 100 has its own untouched bucket, so a - // block on its chain still dispatches even though the flood module - // is being throttled. This is the per-module fairness guarantee. - let calm_dispatched = supervisor - .dispatch_block(nexum::host::types::Block { - chain_id: 100, - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - }) - .await; - assert_eq!( - calm_dispatched, 1, - "the calm module is served in full - a flood on another module never starves it", - ); - - // Neither module died: rate limiting is a benign drop, not a fault. - assert_eq!( - supervisor.alive_count(), - 2, - "rate limiting must not kill modules" - ); - assert_eq!(supervisor.poisoned_count(), 0); -} - -#[tokio::test] -async fn multi_chain_poisoned_module_does_not_affect_other_chains() { - // fuel-bomb (always-traps) on chain 1, example (healthy) on - // chain 100. Trap the bomb a few times with a tight poison - // policy so it gets quarantined; verify the example keeps - // dispatching on chain 100 throughout. - let Some(bomb_wasm) = module_wasm_or_skip("fuel-bomb") else { - return; - }; - let Some(example_wasm) = example_wasm_or_skip() else { - return; - }; - - let dir = tempfile::tempdir().unwrap(); - let example_manifest = dir.path().join("example.toml"); - std::fs::write( - &example_manifest, - r#" -[module] -name = "example" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "block" -chain_id = 100 -"#, - ) - .unwrap(); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - - let engine_cfg = crate::engine_config::EngineConfig { - engine: crate::engine_config::EngineSection { - state_dir: dir.path().to_path_buf(), - log_level: "info".into(), - metrics: crate::engine_config::MetricsSection::default(), - ..Default::default() - }, - // Tight policy: 2 failures in 60 s -> quarantine, set through - // `[limits.poison]`. - limits: crate::engine_config::ModuleLimits { - poison: crate::engine_config::PoisonLimitsSection { - max_failures: Some(2), - window_secs: Some(60), - }, - ..Default::default() - }, - chains: std::collections::HashMap::new(), - extensions: std::collections::HashMap::new(), - remote_store: None, - modules: vec![ - crate::engine_config::ModuleEntry { - path: bomb_wasm, - manifest: Some(fixture_module_toml( - "modules/fixtures/fuel-bomb/module.toml", - )), - }, - crate::engine_config::ModuleEntry { - path: example_wasm, - manifest: Some(example_manifest), - }, - ], - adapters: Vec::new(), - }; - - let mut supervisor = Supervisor::boot( - &engine, - &linker, - &engine_cfg, - &components, - &core_extensions(), - None, - ) - .await - .expect("boot"); - assert_eq!(supervisor.module_count(), 2); - assert_eq!(supervisor.alive_count(), 2); - - let block_bomb_chain = nexum::host::types::Block { - chain_id: 1, // fuel-bomb's manifest declares chain 1 - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - }; - let block_healthy_chain = nexum::host::types::Block { - chain_id: 100, - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - }; - - // Trap #1 on the bomb's chain: bomb dies, example untouched. - supervisor.dispatch_block(block_bomb_chain.clone()).await; - assert_eq!(supervisor.poisoned_count(), 0); - - // Example keeps dispatching on its own chain - confirm before - // the bomb hits the poison threshold. - let dispatched_b = supervisor.dispatch_block(block_healthy_chain.clone()).await; - assert_eq!(dispatched_b, 1, "module-b receives chain-100 blocks"); - - // Wait out the bomb's backoff so trap #2 can land. - tokio::time::sleep(std::time::Duration::from_millis(1_100)).await; - supervisor.dispatch_block(block_bomb_chain).await; - assert_eq!( - supervisor.poisoned_count(), - 1, - "bomb quarantined at 2 failures", - ); - - // POST-poison: bomb stays dead, example still healthy. - let dispatched_after = supervisor.dispatch_block(block_healthy_chain).await; - assert_eq!( - dispatched_after, 1, - "chain-100 module unaffected by chain-1 poison", - ); - assert_eq!(supervisor.alive_count(), 1, "only example is alive"); - assert_eq!(supervisor.poisoned_count(), 1); -} - -// ── build_alloy_filter ──────────────────────────────────────────────── - -#[test] -fn alloy_filter_with_address_and_topic() { - let addr = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110"; - let topic = "0x237e158222e3e6968b72b9db0d8043aacf074ad9f650f0d1606b4d82ee432c00"; - let filter = build_alloy_filter(Some(addr), Some(topic)).unwrap(); - // Check address is set (alloy Filter doesn't expose a simple getter, - // but we can verify the filter serialises the address field). - let serialised = serde_json::to_value(&filter).unwrap(); - let addr_field = serialised - .get("address") - .unwrap() - .to_string() - .to_lowercase(); - assert!(addr_field.contains(&addr.to_lowercase()[2..])); // strip 0x -} - -#[test] -fn alloy_filter_no_address_no_topic() { - let filter = build_alloy_filter(None, None).unwrap(); - let serialised = serde_json::to_value(&filter).unwrap(); - // Address and topics should be absent or null. - assert!( - serialised.get("address").is_none() - || serialised["address"].is_null() - || serialised["address"] == serde_json::json!([]) - ); -} - -#[test] -fn alloy_filter_rejects_bad_address() { - let err = build_alloy_filter(Some("not-an-address"), None); - assert!(err.is_err()); -} - -#[test] -fn alloy_filter_rejects_bad_topic() { - let addr = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110"; - let err = build_alloy_filter(Some(addr), Some("not-a-topic")); - assert!(err.is_err()); -} - -/// A mined log carries every block-scoped field; the host projection must -/// preserve each one so the guest rebuilds the native alloy log losslessly. -#[test] -fn project_chain_log_preserves_mined_log() { - use alloy_primitives::{Address, B256, Bytes}; - - let address = Address::repeat_byte(0x11); - let topics = vec![B256::repeat_byte(0x22), B256::repeat_byte(0x33)]; - let data = Bytes::from(vec![0xde, 0xad, 0xbe, 0xef]); - let inner = alloy_primitives::Log::new_unchecked(address, topics.clone(), data.clone()); - - let log = alloy_rpc_types_eth::Log { - inner, - block_hash: Some(B256::repeat_byte(0x44)), - block_number: Some(0x1234), - block_timestamp: Some(0x5678), - transaction_hash: Some(B256::repeat_byte(0x55)), - transaction_index: Some(7), - log_index: Some(9), - removed: true, - }; - - let projected = nexum::host::types::ChainLog::from(&log); - - assert_eq!(projected.address, address.as_slice().to_vec()); - assert_eq!( - projected.topics, - topics - .iter() - .map(|t| t.as_slice().to_vec()) - .collect::>(), - ); - assert_eq!(projected.data, data.to_vec()); - assert_eq!( - projected.block_hash.as_deref(), - Some(B256::repeat_byte(0x44).as_slice()), - ); - assert_eq!(projected.block_number, Some(0x1234)); - assert_eq!(projected.block_timestamp, Some(0x5678)); - assert_eq!( - projected.transaction_hash.as_deref(), - Some(B256::repeat_byte(0x55).as_slice()), - ); - assert_eq!(projected.transaction_index, Some(7)); - assert_eq!(projected.log_index, Some(9)); - assert!(projected.removed); -} - -/// A pending log has no block-scoped fields; the projection must leave each -/// one `None` rather than collapsing an absent value onto a zero default. -#[test] -fn project_chain_log_leaves_pending_fields_none() { - use alloy_primitives::{Address, Bytes}; - - let inner = - alloy_primitives::Log::new_unchecked(Address::repeat_byte(0xab), Vec::new(), Bytes::new()); - let log = alloy_rpc_types_eth::Log { - inner, - block_hash: None, - block_number: None, - block_timestamp: None, - transaction_hash: None, - transaction_index: None, - log_index: None, - removed: false, - }; - - let projected = nexum::host::types::ChainLog::from(&log); - - assert!(projected.block_hash.is_none()); - assert!(projected.block_number.is_none()); - assert!(projected.block_timestamp.is_none()); - assert!(projected.transaction_hash.is_none()); - assert!(projected.transaction_index.is_none()); - assert!(projected.log_index.is_none()); - assert!(projected.topics.is_empty()); - assert!(projected.data.is_empty()); - assert!(!projected.removed); -} - -#[test] -fn chainlog_cursor_key_is_stable_and_case_insensitive() { - // The durable key must be reproducible across restarts (unlike the - // alloy `Filter` hash, which uses a process-randomized HashSet) and - // must normalise hex case. - let a = chainlog_cursor_key(Chain::from_id(1), Some("0xAbC"), Some("0xDeF")); - let b = chainlog_cursor_key(Chain::from_id(1), Some("0xabc"), Some("0xdef")); - assert_eq!(a, b, "hex case must not change the key"); - assert!( - a.starts_with("chainlog_cursor:"), - "key carries the prefix: {a}" - ); -} - -#[test] -fn chainlog_cursor_key_differs_by_each_input() { - let base = chainlog_cursor_key(Chain::from_id(1), Some("0xabc"), Some("0xdef")); - assert_ne!( - base, - chainlog_cursor_key(Chain::from_id(10), Some("0xabc"), Some("0xdef")), - "chain id is part of the key", - ); - assert_ne!( - base, - chainlog_cursor_key(Chain::from_id(1), Some("0x999"), Some("0xdef")), - "address is part of the key", - ); - assert_ne!( - base, - chainlog_cursor_key(Chain::from_id(1), Some("0xabc"), None), - "topic presence changes the key", - ); - assert_ne!( - base, - chainlog_cursor_key(Chain::from_id(1), None, Some("0xdef")), - "address presence changes the key", - ); -} - -// ── provider boot gating ────────────────────────────────────────────── - -/// A stub extension registering the `acme-adapter` provider kind behind a -/// unit service, so the boot-gate tests exercise the generic kind loop -/// without a real provider component. -struct AcmeService; -impl crate::host::extension::HostService for AcmeService {} - -struct AcmeKind; - -#[async_trait::async_trait] -impl ProviderKind for AcmeKind { - fn kind(&self) -> &'static str { - "acme-adapter" - } - - fn link( - &self, - _linker: &mut Linker>, - ) -> anyhow::Result<()> { - Ok(()) - } - - async fn install( - &self, - _instance: ProviderInstance<'_, crate::test_utils::MockTypes>, - _service: &Arc, - ) -> anyhow::Result { - Ok(Installed::Live) - } -} - -struct AcmeExtension; - -impl Extension for AcmeExtension { - fn namespace(&self) -> &'static str { - "acme" - } - - fn capabilities(&self) -> manifest::NamespaceCaps { - manifest::NamespaceCaps { - prefix: "test:acme/", - ifaces: &[], - } - } - - fn link( - &self, - _linker: &mut Linker>, - ) -> anyhow::Result<()> { - Ok(()) - } - - fn service(&self) -> Option> { - Some(Arc::new(AcmeService)) - } - - fn provider(&self) -> Option>> { - Some(Box::new(AcmeKind)) - } -} - -/// The stub extension set registering the `acme-adapter` kind. -fn acme_extensions() -> Vec>> { - vec![Arc::new(AcmeExtension)] -} - -/// The module-kind discriminator gates the provider load path: an -/// `[[adapters]]` entry whose manifest is (or defaults to) an event-module -/// is rejected before instantiation with a message naming the registered -/// kinds. -#[tokio::test] -async fn boot_rejects_provider_whose_manifest_is_an_event_module() { - let engine = make_wasmtime_engine(); - let components = crate::test_utils::mock_components(); - let extensions = acme_extensions(); - let linker = - crate::supervisor::build_linker::(&engine, &extensions) - .expect("build_linker"); - - let dir = tempfile::tempdir().expect("tempdir"); - let manifest = dir.path().join("module.toml"); - std::fs::write( - &manifest, - "[module]\nname = \"acme\"\nkind = \"event-module\"\n", - ) - .expect("write manifest"); - - let config = EngineConfig { - adapters: vec![crate::engine_config::AdapterEntry { - path: dir.path().join("acme.wasm"), - manifest: Some(manifest), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - ..Default::default() - }; - - let err = - match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { - Ok(_) => panic!("event-module manifest in an [[adapters]] slot must be rejected"), - Err(err) => err, - }; - let msg = format!("{err:#}"); - assert!( - msg.contains("acme-adapter"), - "the kind gate names the registered kinds: {msg}", - ); -} - -/// A kind spelling no extension registered is refused at boot with a -/// message naming the registered kinds. -#[tokio::test] -async fn boot_rejects_an_unregistered_provider_kind() { - let engine = make_wasmtime_engine(); - let components = crate::test_utils::mock_components(); - let extensions = acme_extensions(); - let linker = - crate::supervisor::build_linker::(&engine, &extensions) - .expect("build_linker"); - - let dir = tempfile::tempdir().expect("tempdir"); - let manifest = dir.path().join("module.toml"); - std::fs::write(&manifest, "[module]\nname = \"bad\"\nkind = \"gadget\"\n") - .expect("write manifest"); - - let config = EngineConfig { - adapters: vec![crate::engine_config::AdapterEntry { - path: dir.path().join("gadget.wasm"), - manifest: Some(manifest), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - ..Default::default() - }; - - let err = - match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { - Ok(_) => panic!("an unregistered provider kind must be refused"), - Err(err) => err, - }; - let msg = format!("{err:#}"); - assert!( - msg.contains("unregistered provider kind gadget") && msg.contains("acme-adapter"), - "the refusal names the unknown spelling and the registered kinds: {msg}", - ); -} - -/// A registered kind clears the discriminator; boot then reaches the -/// compile step and fails only because the referenced wasm is absent. This -/// proves the discriminator routed the entry to the provider load path -/// rather than rejecting it on kind. -#[tokio::test] -async fn boot_admits_a_registered_provider_kind_past_the_kind_gate() { - let engine = make_wasmtime_engine(); - let components = crate::test_utils::mock_components(); - let extensions = acme_extensions(); - let linker = - crate::supervisor::build_linker::(&engine, &extensions) - .expect("build_linker"); - - let dir = tempfile::tempdir().expect("tempdir"); - let manifest = dir.path().join("module.toml"); - std::fs::write( - &manifest, - "[module]\nname = \"acme\"\nkind = \"acme-adapter\"\n\n\ - [capabilities]\nrequired = [\"chain\"]\n", - ) - .expect("write manifest"); - - let config = EngineConfig { - adapters: vec![crate::engine_config::AdapterEntry { - path: dir.path().join("missing-acme.wasm"), - manifest: Some(manifest), - http_allow: vec!["api.acme.example".into()], - messaging_topics: vec!["/nexum/1/acme-orders/proto".into()], - }], - ..Default::default() - }; - - let err = - match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { - Ok(_) => panic!("absent provider wasm must fail the compile step"), - Err(err) => err, - }; - let msg = format!("{err:#}"); - assert!( - msg.contains("compile") || msg.contains("missing-acme"), - "boot reached the compile step past the kind gate: {msg}", - ); - assert!( - !msg.contains("requires a module.toml"), - "the kind gate passed rather than rejecting: {msg}", - ); -} - -/// A module subscribing to an extension kind no wired extension declares -/// is refused at boot, preserving the unknown-kind fail-fast. -#[tokio::test] -async fn boot_refuses_an_undeclared_extension_subscription_kind() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let dir = tempfile::tempdir().expect("tempdir"); - let manifest = dir.path().join("module.toml"); - std::fs::write( - &manifest, - r#" -[module] -name = "example" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "acme-status" -"#, - ) - .expect("write manifest"); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - let limits = ModuleLimits::default(); - - let result = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await; - let err = result - .err() - .expect("an undeclared extension subscription kind must refuse boot"); - let msg = format!("{err:#}"); - assert!( - msg.contains("unknown event kind acme-status"), - "the refusal names the kind: {msg}", - ); -} diff --git a/nexum/crates/nexum-runtime/src/test_utils/builders.rs b/nexum/crates/nexum-runtime/src/test_utils/builders.rs deleted file mode 100644 index 529f229a..00000000 --- a/nexum/crates/nexum-runtime/src/test_utils/builders.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Pass-through [`ComponentBuilder`] wrapping a pre-built backend, so a test -//! hands a programmed mock straight into the public builder path. - -use crate::host::component::{BuilderContext, ComponentBuilder}; - -/// A [`ComponentBuilder`] that yields a pre-built backend, ignoring the build -/// context. Wrap any mock instance (chain, store, or extension payload) to -/// compose it through [`RuntimeBuilder::with_components`]. -/// -/// [`RuntimeBuilder::with_components`]: crate::builder::TypedBuilder::with_components -pub struct Prebuilt(pub T); - -impl ComponentBuilder for Prebuilt { - type Output = T; - - async fn build(self, _ctx: &BuilderContext<'_>) -> anyhow::Result { - Ok(self.0) - } -} diff --git a/nexum/crates/nexum-runtime/src/test_utils/chain.rs b/nexum/crates/nexum-runtime/src/test_utils/chain.rs deleted file mode 100644 index a1deeeae..00000000 --- a/nexum/crates/nexum-runtime/src/test_utils/chain.rs +++ /dev/null @@ -1,300 +0,0 @@ -//! In-process [`ChainProvider`] fake: programmable JSON-RPC responses, -//! recorded calls, and block / chain-log streams driven from the test. - -use std::collections::HashMap; -use std::future::Future; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use alloy_chains::Chain; -use alloy_rpc_types_eth::{Filter, Header, Log}; -use futures::StreamExt as _; -use futures::channel::mpsc::{self, UnboundedSender}; - -use crate::host::component::{ChainMethod, ChainProvider}; -use crate::host::provider_pool::{BlockStream, CanonicalLogStream, ProviderError}; - -/// One dispatched [`ChainProvider::request`], captured in call order. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RecordedRequest { - /// Target chain. - pub chain: Chain, - /// Requested read-surface method. - pub method: ChainMethod, - /// Raw JSON params array the caller passed. - pub params_json: String, -} - -type BlockItem = Result; -type LogItem = Result; - -/// One subscription kind's channel pair. The receiver is taken by the first -/// subscribe call. -/// -/// A concurrent second subscribe (with no close in between) finds the -/// receiver already taken and parks on a pending stream, so a reconnect loop -/// does not busy-spin against a live subscriber. -/// -/// A subscribe after [`close`](Self::close) is the reconnect path and is -/// distinct: close ends the open stream and re-arms the slot with a fresh -/// channel, so the next subscribe (the event loop's reconnect after backoff) -/// gets a real stream and resumes delivery of subsequently sent items, -/// mirroring a provider that reconnects after a dropped connection. -struct StreamSlot { - tx: UnboundedSender, - rx: Option>, -} - -impl StreamSlot { - fn new() -> Self { - let (tx, rx) = mpsc::unbounded(); - Self { tx, rx: Some(rx) } - } - - fn send(&self, item: T) { - let _ = self.tx.unbounded_send(item); - } - - fn close(&mut self) { - // Close is the reconnect drop: the already-taken receiver drains its - // buffered items then ends. If the receiver was never taken, the - // reassignment below drops it and those items are lost, so that is a - // misuse worth catching in debug builds. - debug_assert!( - self.rx.is_none(), - "close on a slot whose receiver was never taken; buffered items are lost", - ); - self.tx.close_channel(); - *self = Self::new(); - } - - fn take(&mut self) -> Option> { - self.rx.take() - } -} - -struct Inner { - // (method wire name, exact params) -> response body. - exact: HashMap<(&'static str, String), String>, - // method wire name -> response body for any params. - wildcard: HashMap<&'static str, String>, - recorded: Vec, - blocks: StreamSlot, - logs: StreamSlot, - // Head returned by `block_number` (the poller's start block). - head_block: u64, - // One-shot delay applied to the next `request` call, consumed when - // that call begins. Models a provider that parks a request (a hung - // node, a server that never answers). Consumed before the sleep, so a - // caller that drops the request future mid-park still clears it: the - // following request answers promptly. - next_request_delay: Option, -} - -/// Mock chain backend. Program `request` responses with [`on_method`] / -/// [`on_request`], drive subscriptions with [`push_block`] / -/// [`push_chain_log`], script transport failures with [`push_block_err`] / -/// [`push_chain_log_err`], end a stream with [`close_block_stream`] / -/// [`close_chain_log_stream`], and read back dispatched calls with -/// [`recorded_requests`]. Cheap `Arc` clone shares one backing state, so a -/// test keeps a clone to program and assert while another clone lives inside -/// the runtime assembly. -/// -/// [`on_method`]: MockChainProvider::on_method -/// [`on_request`]: MockChainProvider::on_request -/// [`push_block`]: MockChainProvider::push_block -/// [`push_chain_log`]: MockChainProvider::push_chain_log -/// [`push_block_err`]: MockChainProvider::push_block_err -/// [`push_chain_log_err`]: MockChainProvider::push_chain_log_err -/// [`close_block_stream`]: MockChainProvider::close_block_stream -/// [`close_chain_log_stream`]: MockChainProvider::close_chain_log_stream -/// [`recorded_requests`]: MockChainProvider::recorded_requests -#[derive(Clone)] -pub struct MockChainProvider { - inner: Arc>, -} - -impl Default for MockChainProvider { - fn default() -> Self { - Self::new() - } -} - -impl MockChainProvider { - /// Fresh mock with no programmed responses and empty streams. - pub fn new() -> Self { - Self { - inner: Arc::new(Mutex::new(Inner { - exact: HashMap::new(), - wildcard: HashMap::new(), - recorded: Vec::new(), - blocks: StreamSlot::new(), - logs: StreamSlot::new(), - head_block: 0, - next_request_delay: None, - })), - } - } - - /// Program the response body for `method` with any params. - pub fn on_method(&self, method: ChainMethod, response: impl Into) -> &Self { - self.lock() - .wildcard - .insert(method.as_str(), response.into()); - self - } - - /// Program the response body for an exact `(method, params_json)` pair. - /// Takes precedence over a [`on_method`](Self::on_method) wildcard. - pub fn on_request( - &self, - method: ChainMethod, - params_json: impl Into, - response: impl Into, - ) -> &Self { - self.lock() - .exact - .insert((method.as_str(), params_json.into()), response.into()); - self - } - - /// Deliver a block header to the open block subscription. Items sent - /// while no subscription is open buffer and drain into the next one. - pub fn push_block(&self, header: Header) { - self.lock().blocks.send(Ok(header)); - } - - /// Deliver a log to the open chain-log subscription. - pub fn push_chain_log(&self, log: Log) { - self.lock().logs.send(Ok(log)); - } - - /// Deliver an error item to the open block subscription, so a - /// reconnect-and-backoff loop on the [`BlockStream`] contract can be - /// exercised against the fake. - pub fn push_block_err(&self, err: ProviderError) { - self.lock().blocks.send(Err(err)); - } - - /// Deliver an error item to the open chain-log subscription. - pub fn push_chain_log_err(&self, err: ProviderError) { - self.lock().logs.send(Err(err)); - } - - /// End the block subscription, modelling a dropped upstream connection: - /// buffered items drain, then the stream terminates (yields `None`). The - /// slot re-arms, so a later `subscribe_blocks` (the event loop's - /// reconnect after backoff) resumes delivery of subsequently pushed - /// items, as a real provider does once its connection is back. - pub fn close_block_stream(&self) { - self.lock().blocks.close(); - } - - /// End the chain-log subscription the same way as - /// [`close_block_stream`](Self::close_block_stream). - pub fn close_chain_log_stream(&self) { - self.lock().logs.close(); - } - - /// Park the next [`ChainProvider::request`] for `delay` before it - /// resolves, modelling a hung node or a server that never answers. - /// One-shot: the delay is consumed when that request begins, so a - /// caller that drops the request future mid-park (e.g. a dispatch that - /// hits its wall-clock deadline) leaves the following request prompt. - pub fn delay_next_request(&self, delay: Duration) -> &Self { - self.lock().next_request_delay = Some(delay); - self - } - - /// Every [`ChainProvider::request`] dispatched so far, in call order. - pub fn recorded_requests(&self) -> Vec { - self.lock().recorded.clone() - } - - fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { - self.inner.lock().expect("mock chain mutex poisoned") - } -} - -impl ChainProvider for MockChainProvider { - fn subscribe_blocks( - &self, - _chain: Chain, - ) -> impl Future> + Send { - let inner = self.inner.clone(); - async move { - let stream: BlockStream = match inner.lock().expect("mock chain mutex").blocks.take() { - Some(rx) => Box::pin(rx), - None => Box::pin(futures::stream::pending::()), - }; - Ok(stream) - } - } - - fn block_number( - &self, - _chain: Chain, - ) -> impl Future> + Send { - let inner = self.inner.clone(); - async move { Ok(inner.lock().expect("mock chain mutex").head_block) } - } - - fn watch_chain_logs( - &self, - _chain: Chain, - _filter: Filter, - _start_block: u64, - ) -> Result { - // The programmable `logs` slot yields individual logs; project - // each into a single-log canonical batch so the poller-shaped - // stream contract (`Vec` per block) is satisfied without - // reworking every test that pushes logs one at a time. - let stream: CanonicalLogStream = - match self.inner.lock().expect("mock chain mutex").logs.take() { - Some(rx) => Box::pin(rx.map(|item| item.map(|log| vec![log]))), - None => Box::pin(futures::stream::pending::, ProviderError>>()), - }; - Ok(stream) - } - - fn request( - &self, - chain: Chain, - method: ChainMethod, - params_json: String, - ) -> impl Future> + Send { - let inner = self.inner.clone(); - async move { - // Record the call and take any one-shot park delay, then drop - // the guard before awaiting: a std `Mutex` must not be held - // across an await, and taking the delay here (not after the - // sleep) is what makes it survive a dropped future. - let delay = { - let mut guard = inner.lock().expect("mock chain mutex"); - guard.recorded.push(RecordedRequest { - chain, - method, - params_json: params_json.clone(), - }); - guard.next_request_delay.take() - }; - if let Some(delay) = delay { - tokio::time::sleep(delay).await; - } - let guard = inner.lock().expect("mock chain mutex"); - let name = method.as_str(); - if let Some(body) = guard.exact.get(&(name, params_json)) { - Ok(body.clone()) - } else if let Some(body) = guard.wildcard.get(name) { - Ok(body.clone()) - } else { - // No response programmed: mirror the empty pool's shape so a - // caller sees a normal provider error rather than a panic. - // Caveat: this conflates "chain present but method not - // scripted" with "chain absent", so a test must not read - // UnknownChain here as a chain-presence signal. - Err(ProviderError::UnknownChain(chain)) - } - } - } -} diff --git a/nexum/crates/nexum-runtime/src/test_utils/clock.rs b/nexum/crates/nexum-runtime/src/test_utils/clock.rs deleted file mode 100644 index fcda4919..00000000 --- a/nexum/crates/nexum-runtime/src/test_utils/clock.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! A manually-driven WASI clock for deterministic guest time in tests. - -use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use wasmtime_wasi::{HostMonotonicClock, HostWallClock}; - -use crate::supervisor::WasiClockOverride; - -/// A shared, manually-advanced clock source. -/// -/// Cloning yields another handle onto the same instant: install one clone as -/// the store's [`WasiClockOverride`] and drive the other from the test. -/// [`set`](Self::set) pins wall time; [`advance`](Self::advance) moves both the -/// wall and monotonic sources forward. Guest-visible only; it does not touch -/// the host wall-clock a `RunId` stamps its start with. -#[derive(Clone)] -pub struct ManualClock { - inner: Arc>, -} - -/// The two time sources a manual clock tracks. -struct Instant { - /// Wall time as a duration since the Unix epoch. - wall: Duration, - /// Monotonic time as nanoseconds since clock creation. - monotonic: u64, -} - -impl Default for ManualClock { - fn default() -> Self { - Self::new() - } -} - -impl ManualClock { - /// Start at the Unix epoch with a zero monotonic reading. - pub fn new() -> Self { - Self { - inner: Arc::new(Mutex::new(Instant { - wall: Duration::ZERO, - monotonic: 0, - })), - } - } - - /// Pin wall time to `time`, leaving the monotonic reading untouched. - /// Times before the Unix epoch clamp to it. Because it does not move - /// monotonic, a `set` after an `advance` can put wall time behind the - /// monotonic source; the two only stay in step under `advance`. - pub fn set(&self, time: SystemTime) { - let wall = time.duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO); - self.locked().wall = wall; - } - - /// Move both the wall and monotonic sources forward by `by`. - pub fn advance(&self, by: Duration) { - let nanos = u64::try_from(by.as_nanos()).unwrap_or(u64::MAX); - let mut state = self.locked(); - state.wall = state.wall.saturating_add(by); - state.monotonic = state.monotonic.saturating_add(nanos); - } - - /// Build a [`WasiClockOverride`] backed by this clock for both the wall and - /// monotonic sources. The two `Arc`s wrap separate `clone`s of the same - /// `ManualClock`, which share one inner `Arc>`, so both handles - /// read and drive the same time. Swapping a `clone` for a fresh - /// `ManualClock::new()` would split that state and silently break the - /// override. - pub fn as_override(&self) -> WasiClockOverride { - WasiClockOverride::new(Arc::new(self.clone()), Arc::new(self.clone())) - } - - fn locked(&self) -> std::sync::MutexGuard<'_, Instant> { - self.inner.lock().unwrap_or_else(|e| e.into_inner()) - } -} - -impl HostWallClock for ManualClock { - fn resolution(&self) -> Duration { - Duration::from_nanos(1) - } - - fn now(&self) -> Duration { - self.locked().wall - } -} - -impl HostMonotonicClock for ManualClock { - fn resolution(&self) -> u64 { - 1 - } - - fn now(&self) -> u64 { - self.locked().monotonic - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn set_pins_wall_time_and_leaves_monotonic() { - let clock = ManualClock::new(); - let target = UNIX_EPOCH + Duration::from_secs(1_700_000_000); - clock.set(target); - assert_eq!( - HostWallClock::now(&clock), - Duration::from_secs(1_700_000_000) - ); - assert_eq!(HostMonotonicClock::now(&clock), 0); - } - - #[test] - fn advance_moves_both_sources() { - let clock = ManualClock::new(); - clock.set(UNIX_EPOCH + Duration::from_secs(100)); - clock.advance(Duration::from_secs(5)); - assert_eq!(HostWallClock::now(&clock), Duration::from_secs(105)); - assert_eq!( - HostMonotonicClock::now(&clock), - Duration::from_secs(5).as_nanos() as u64 - ); - } - - #[test] - fn clones_share_one_instant() { - let a = ManualClock::new(); - let b = a.clone(); - a.advance(Duration::from_millis(250)); - assert_eq!(HostMonotonicClock::now(&b), 250_000_000); - } - - #[test] - fn pre_epoch_time_clamps_to_zero() { - let clock = ManualClock::new(); - clock.set(UNIX_EPOCH - Duration::from_secs(10)); - assert_eq!(HostWallClock::now(&clock), Duration::ZERO); - } -} diff --git a/nexum/crates/nexum-runtime/src/test_utils/harness.rs b/nexum/crates/nexum-runtime/src/test_utils/harness.rs deleted file mode 100644 index 41eb760e..00000000 --- a/nexum/crates/nexum-runtime/src/test_utils/harness.rs +++ /dev/null @@ -1,907 +0,0 @@ -//! In-process test harness: launch one module over the mock assembly and -//! drive it from a test, with no supervisor ceremony. -//! -//! [`TestRuntime`] wraps the public builder path over [`MockTypes`]: it opens -//! a module from a wasm path plus a manifest (a path, or inline TOML written -//! to a temp file), installs a manually-driven [`ManualClock`], and returns a -//! handle bundling the running [`RuntimeHandle`] with the retained mock -//! handles. A test programs chain responses and injects block headers or chain -//! logs through [`chain`](TestRuntime::chain), advances guest time through -//! [`clock`](TestRuntime::clock), reads what a module wrote through -//! [`store`](TestRuntime::store), and reads runs and log pages through -//! [`logs`](TestRuntime::logs), then [`shutdown`](TestRuntime::shutdown) and -//! [`wait`](TestRuntime::wait). -//! -//! Events dispatch on the spawned event-loop task, so a test injects an event -//! and then awaits an observable effect; -//! [`wait_for_log`](TestRuntime::wait_for_log) polls the log pipeline for one. -//! -//! The extension slot is the lattice type parameter: pass an `Ext` payload -//! and any [`Extension`]s through -//! [`builder_with_ext`](TestRuntime::builder_with_ext) to drive an extension -//! crate's backend through the same harness. - -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use alloy_rpc_types_eth::{Header, Log}; - -use super::clock::ManualClock; -use super::{MockChainProvider, MockStateStore, MockTypes, Prebuilt}; -use crate::builder::{RuntimeBuilder, RuntimeHandle}; -use crate::engine_config::{EngineConfig, ModuleLimits}; -use crate::host::component::ComponentsBuilder; -use crate::host::extension::Extension; -use crate::host::logs::{LogPipeline, LogRecord}; - -/// Where the module manifest comes from. -enum ManifestSource { - /// No manifest; the loader falls back to a sibling `module.toml`. - None, - /// An existing manifest file. - Path(PathBuf), - /// Manifest TOML written to a temp file at launch. - Inline(String), -} - -/// Builder for a [`TestRuntime`]. Program the mock backends through -/// [`chain`](Self::chain) / [`store`](Self::store) / [`clock`](Self::clock) -/// before [`launch`](Self::launch); the launched handle shares the same -/// backends. -pub struct TestRuntimeBuilder -where - E: Clone + Send + Sync + 'static, -{ - wasm: PathBuf, - manifest: ManifestSource, - extensions: Vec>>>, - ext: E, - limits: ModuleLimits, - chain: MockChainProvider, - store: MockStateStore, - clock: ManualClock, -} - -impl TestRuntime<()> { - /// Start a harness for the module at `wasm`, with an empty extension slot. - pub fn builder(wasm: impl Into) -> TestRuntimeBuilder<()> { - TestRuntime::builder_with_ext(wasm, ()) - } -} - -impl TestRuntime { - /// Start a harness whose lattice binds `ext` as the extension payload. - /// Pair with [`extension`](TestRuntimeBuilder::extension) to register the - /// extension's linker hook and capability namespace. - pub fn builder_with_ext(wasm: impl Into, ext: E) -> TestRuntimeBuilder { - TestRuntimeBuilder { - wasm: wasm.into(), - manifest: ManifestSource::None, - extensions: Vec::new(), - ext, - limits: ModuleLimits::default(), - chain: MockChainProvider::new(), - store: MockStateStore::new(), - clock: ManualClock::new(), - } - } -} - -impl TestRuntimeBuilder { - /// Load the manifest from an existing file. - pub fn manifest_path(mut self, path: impl Into) -> Self { - self.manifest = ManifestSource::Path(path.into()); - self - } - - /// Write `toml` to a temp file at launch and load the module from it. - pub fn manifest_inline(mut self, toml: impl Into) -> Self { - self.manifest = ManifestSource::Inline(toml.into()); - self - } - - /// Register an extension. - pub fn extension(mut self, extension: Arc>>) -> Self { - self.extensions.push(extension); - self - } - - /// Register several extensions at once. - pub fn extensions( - mut self, - extensions: impl IntoIterator>>>, - ) -> Self { - self.extensions.extend(extensions); - self - } - - /// Replace the `[limits]` the launch resolves: fuel, memory, outbound - /// HTTP, log retention, and the poison-pill thresholds. Defaults to the - /// production defaults. - pub fn limits(mut self, limits: ModuleLimits) -> Self { - self.limits = limits; - self - } - - /// The mock chain backend, for programming request responses before - /// launch. The launched handle shares this instance. - pub fn chain(&self) -> &MockChainProvider { - &self.chain - } - - /// The mock state store. The launched handle shares this instance. - pub fn store(&self) -> &MockStateStore { - &self.store - } - - /// The manual clock installed as the per-store WASI clock override. - pub fn clock(&self) -> &ManualClock { - &self.clock - } - - /// Open the module and start the runtime. Composes entirely through the - /// public builder path over [`MockTypes`]. - pub async fn launch(self) -> anyhow::Result> { - // A temp directory roots any inline manifest and stands in as the - // (unused, in-memory backends) state directory. - let tmp = tempfile::tempdir()?; - - let manifest = match self.manifest { - ManifestSource::None => None, - ManifestSource::Path(path) => Some(path), - ManifestSource::Inline(toml) => { - let path = tmp.path().join("module.toml"); - std::fs::write(&path, toml)?; - Some(path) - } - }; - - let mut config = EngineConfig::default(); - config.engine.state_dir = tmp.path().to_path_buf(); - config.limits = self.limits; - - let handle = RuntimeBuilder::new(&config) - .with_types::>() - .with_extensions(self.extensions) - .with_module_source(Some(self.wasm), manifest) - .with_wasi_clocks(self.clock.as_override()) - .with_components(ComponentsBuilder::new( - Prebuilt(self.chain.clone()), - Prebuilt(self.store.clone()), - Prebuilt(self.ext.clone()), - )) - .with_add_ons(&[]) - .launch() - .await?; - - Ok(TestRuntime { - handle, - chain: self.chain, - store: self.store, - clock: self.clock, - ext: self.ext, - _tmp: tmp, - }) - } -} - -/// A launched in-process runtime over the mock assembly. Holds the running -/// [`RuntimeHandle`] and the retained mock handles; dropping it fires the -/// shutdown trigger. -pub struct TestRuntime { - handle: RuntimeHandle, - chain: MockChainProvider, - store: MockStateStore, - clock: ManualClock, - ext: E, - // Holds any inline manifest for the lifetime of the harness; dropped - // when the `TestRuntime` is dropped (or consumed by `wait`). - _tmp: tempfile::TempDir, -} - -impl TestRuntime { - /// The mock chain backend: program request responses, inject block - /// headers with [`push_block`](Self::push_block), and inject logs with - /// [`push_chain_log`](Self::push_chain_log). - pub fn chain(&self) -> &MockChainProvider { - &self.chain - } - - /// The mock state store, for asserting on what a module wrote. - pub fn store(&self) -> &MockStateStore { - &self.store - } - - /// The manual clock driving guest-visible time. - pub fn clock(&self) -> &ManualClock { - &self.clock - } - - /// The extension payload bound into the lattice ext slot. - pub fn ext(&self) -> &E { - &self.ext - } - - /// The shared log pipeline: read runs and log pages here. - pub fn logs(&self) -> &LogPipeline { - self.handle.logs() - } - - /// Deliver a block header to the module's open block subscription. - pub fn push_block(&self, header: Header) { - self.chain.push_block(header); - } - - /// Deliver a log to the module's open chain-log subscription. - pub fn push_chain_log(&self, log: Log) { - self.chain.push_chain_log(log); - } - - /// Await a `module` log record whose message contains `needle`, returning - /// it. Driven by log-append notifications rather than a timer, so it - /// resolves as soon as the dispatched event's record lands; the 5s bound - /// is a failure backstop, not the cadence. Use it to await an injected - /// event's effect: the record only lands once the event-loop task has - /// dispatched the event. - pub async fn wait_for_log(&self, module: &str, needle: &str) -> anyhow::Result { - let logs = self.logs(); - let appended = logs.appended(); - let matched = async { - loop { - // Arm the waiter before reading so an append landing between - // the read and the await wakes us rather than being lost. - let notified = appended.notified(); - tokio::pin!(notified); - notified.as_mut().enable(); - if let Some(record) = logs.list_runs(module).into_iter().find_map(|meta| { - logs.read(&meta.run, 0) - .records - .into_iter() - .find(|record| record.message.contains(needle)) - }) { - return record; - } - notified.await; - } - }; - tokio::time::timeout(Duration::from_secs(5), matched) - .await - .map_err(|_| anyhow::anyhow!("no {module} log record matched {needle:?} within 5s")) - } - - /// Signal the event loop to stop; the in-flight dispatch finishes first. - pub fn shutdown(&mut self) { - self.handle.shutdown(); - } - - /// Await the event loop's completion after a [`shutdown`](Self::shutdown). - pub async fn wait(self) -> anyhow::Result<()> { - self.handle.wait().await - } -} - -#[cfg(test)] -mod tests { - use std::path::{Path, PathBuf}; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - use super::*; - use crate::host::extension::Extension; - use crate::manifest::NamespaceCaps; - - /// The pre-built module wasm named `file`, or `None` (with a skip note) - /// when the fixture is not built. - fn module_wasm_or_skip(file: &str) -> Option { - let wasm = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .and_then(Path::parent) - .expect("workspace root") - .join("target/wasm32-wasip2/release") - .join(file); - if wasm.exists() { - Some(wasm) - } else { - eprintln!( - "SKIP: {} not found - run the `just ci` wasm build to enable the harness E2E tests", - wasm.display() - ); - None - } - } - - /// The pre-built example module, or `None` (with a skip note) when the - /// wasm fixture is not built. - fn example_wasm_or_skip() -> Option { - module_wasm_or_skip("example.wasm") - } - - /// A block-only manifest for the example module on `chain_id`. - fn block_manifest(name: &str, chain_id: u64) -> String { - format!( - r#" -[module] -name = "{name}" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "block" -chain_id = {chain_id} -"# - ) - } - - /// A chain-log manifest for the example module on `chain_id`, with no - /// address or topic filter so any pushed log matches. - fn chain_log_manifest(name: &str, chain_id: u64) -> String { - format!( - r#" -[module] -name = "{name}" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "chain-log" -chain_id = {chain_id} -"# - ) - } - - /// A header carrying just the block number the event loop projects onto - /// the dispatched block. - fn header_numbered(number: u64) -> Header { - let mut header: Header = Header::default(); - header.inner.number = number; - header - } - - /// End-to-end through the harness: launch the example module from an - /// inline manifest, inject a block header, and read the module's log line - /// back off the pipeline. Locks the harness ergonomics against the boot - /// plus dispatch plus log-read path. - #[tokio::test] - async fn harness_launches_dispatches_and_reads_logs() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - - let mut rt = TestRuntime::builder(wasm) - .manifest_inline(block_manifest("example", 1)) - .launch() - .await - .expect("launch example over the harness"); - - rt.push_block(header_numbered(19_000_000)); - let record = rt - .wait_for_log("example", "block 19000000") - .await - .expect("the on_event log line lands after dispatch"); - assert_eq!( - record.source, - crate::host::logs::LogSource::HostInterface, - "the example module logs through the host interface", - ); - - rt.shutdown(); - rt.wait().await.expect("clean shutdown"); - } - - /// End-to-end through the harness on the chain-log leg: launch the - /// example module with a `chain-log` subscription, inject a log, and read - /// the module's log line back. Locks the log-stream path the way - /// [`harness_launches_dispatches_and_reads_logs`] locks the block path. - #[tokio::test] - async fn harness_dispatches_chain_logs() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - - let mut rt = TestRuntime::builder(wasm) - .manifest_inline(chain_log_manifest("example", 1)) - .launch() - .await - .expect("launch example on the chain-log leg"); - - rt.push_chain_log(Log::default()); - rt.wait_for_log("example", "received 1 chain-log entries") - .await - .expect("the chain-log line lands after dispatch"); - - rt.shutdown(); - rt.wait().await.expect("clean shutdown"); - } - - /// The extension slot threads through the same harness: a trivial - /// extension (no-op linker hook, empty capability namespace) and an ext - /// payload compose through the mock lattice, the module boots and - /// dispatches, and the harness hands the payload back. - #[tokio::test] - async fn harness_threads_an_extension_and_ext_payload() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - - struct CountingExtension(Arc); - - impl Extension>> for CountingExtension { - fn namespace(&self) -> &'static str { - "test" - } - fn capabilities(&self) -> NamespaceCaps { - NamespaceCaps { - prefix: "test:ext/", - ifaces: &[], - } - } - fn link( - &self, - _linker: &mut wasmtime::component::Linker< - crate::host::state::HostState>>, - >, - ) -> anyhow::Result<()> { - self.0.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - } - - let calls = Arc::new(AtomicUsize::new(0)); - let extension = Arc::new(CountingExtension(calls.clone())); - - let mut rt = TestRuntime::builder_with_ext(wasm, calls.clone()) - .extension(extension) - .manifest_inline(block_manifest("example", 1)) - .launch() - .await - .expect("launch with a trivial extension"); - - // The extension's linker hook ran during boot, and the payload the - // harness threaded is the one it hands back. - assert!( - calls.load(Ordering::SeqCst) >= 1, - "the extension linker hook ran at boot", - ); - assert!(Arc::ptr_eq(rt.ext(), &calls), "the ext payload is retained"); - - rt.push_block(header_numbered(21_000_000)); - rt.wait_for_log("example", "block 21000000") - .await - .expect("the module dispatched under the extension-bearing lattice"); - - rt.shutdown(); - rt.wait().await.expect("clean shutdown"); - } - - /// [`TestRuntimeBuilder::limits`] reaches the launch: with a one-byte - /// log ring the run keeps only its newest record, so the init line is - /// evicted once the block line lands. - #[tokio::test] - async fn harness_threads_module_limits() { - use crate::engine_config::LogLimitsSection; - - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - - let mut rt = TestRuntime::builder(wasm) - .manifest_inline(block_manifest("example", 1)) - .limits(ModuleLimits { - logs: LogLimitsSection { - bytes_per_run: Some(1), - runs_retained: None, - }, - ..Default::default() - }) - .launch() - .await - .expect("launch example with tight log limits"); - - rt.push_block(header_numbered(19_000_000)); - rt.wait_for_log("example", "block 19000000") - .await - .expect("the on_event log line lands after dispatch"); - - let runs = rt.logs().list_runs("example"); - assert_eq!(runs.len(), 1, "one run recorded"); - let page = rt.logs().read(&runs[0].run, 0); - assert_eq!( - page.records.len(), - 1, - "the one-byte ring keeps only the newest record", - ); - assert!(page.records[0].message.contains("block 19000000")); - - rt.shutdown(); - rt.wait().await.expect("clean shutdown"); - } - - /// The module-author flow end to end on the chain-request leg: program - /// the mock chain's `eth_call` response, launch the price-alert module, - /// inject a block, and read the module's alert line back. The programmed - /// oracle answer sits above the configured threshold, so the module logs - /// its TRIGGERED line. - #[tokio::test] - async fn harness_serves_chain_requests_to_the_module() { - use crate::host::component::ChainMethod; - - let Some(wasm) = module_wasm_or_skip("price_alert.wasm") else { - return; - }; - - /// One 32-byte ABI word as zero-padded hex. - fn word(v: u128) -> String { - format!("{v:064x}") - } - // latestRoundData() -> (roundId, answer, startedAt, updatedAt, - // answeredInRound), answer = 3000 * 10^8, above the 2500.00 - // threshold below. - let result = format!( - "\"0x{}{}{}{}{}\"", - word(1), - word(300_000_000_000), - word(0), - word(0), - word(1), - ); - - let builder = TestRuntime::builder(wasm).manifest_inline( - r#" -[module] -name = "price-alert" - -[capabilities] -required = ["logging", "chain"] - -[[subscription]] -kind = "block" -chain_id = 1 - -[config] -oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" -decimals = "8" -threshold = "2500.00" -direction = "above" -"#, - ); - builder.chain().on_method(ChainMethod::EthCall, result); - - let mut rt = builder - .launch() - .await - .expect("launch price-alert over the harness"); - - rt.push_block(header_numbered(19_000_000)); - rt.wait_for_log("price-alert", "TRIGGERED") - .await - .expect("the alert line lands after the oracle read"); - - let requests = rt.chain().recorded_requests(); - assert!( - requests.iter().any(|r| { - matches!(r.method, ChainMethod::EthCall) - && r.params_json - .contains("0x694aa1769357215de4fac081bf1f309adc325306") - }), - "the module's eth_call reached the mock, got: {requests:?}", - ); - - rt.shutdown(); - rt.wait().await.expect("clean shutdown"); - } - - /// Both block and chain-log events are dispatched to a live module in the - /// same session: the `biased` select in `run()` delivers both event kinds - /// without starvation. Addresses the ordering guarantee from issue #56. - #[tokio::test] - async fn harness_delivers_block_and_chain_log_events_without_starvation() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - - let mut rt = TestRuntime::builder(wasm) - .manifest_inline( - r#" -[module] -name = "example" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "block" -chain_id = 1 - -[[subscription]] -kind = "chain-log" -chain_id = 1 -"#, - ) - .launch() - .await - .expect("launch example subscribed to both blocks and chain-logs"); - - // Both events are queued before either is awaited, so the biased - // select genuinely arbitrates between two ready streams — a - // sequential push→wait→push→wait would never create contention. - rt.push_block(header_numbered(42)); - rt.push_chain_log(Log::default()); - - rt.wait_for_log("example", "block 42 on chain") - .await - .expect("block event dispatched"); - rt.wait_for_log("example", "received 1 chain-log entries") - .await - .expect("chain-log event dispatched — neither event kind starved the other"); - - rt.shutdown(); - rt.wait().await.expect("clean shutdown"); - } - - /// Blocks pushed in order arrive at the module in the same order — - /// the per-chain stream, the select, and the dispatch path preserve - /// delivery order. Issue #56's ordering guarantee, asserted on the - /// module's own log records rather than inferred from termination. - #[tokio::test] - async fn harness_delivers_blocks_in_push_order() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - - let mut rt = TestRuntime::builder(wasm) - .manifest_inline(block_manifest("example", 1)) - .launch() - .await - .expect("launch example over the harness"); - - rt.push_block(header_numbered(7)); - rt.push_block(header_numbered(8)); - rt.push_block(header_numbered(9)); - - // The last block's log line proves all three dispatches completed. - rt.wait_for_log("example", "block 9 on chain") - .await - .expect("final block dispatched"); - - // Recover the per-block log lines in record order and assert the - // sequence matches the push order exactly. - let logs = rt.logs(); - let numbers: Vec = logs - .list_runs("example") - .into_iter() - .flat_map(|meta| logs.read(&meta.run, 0).records) - .filter_map(|record| { - let rest = record.message.strip_prefix("block ")?; - rest.split(' ').next()?.parse().ok() - }) - .collect(); - assert_eq!( - numbers, - vec![7, 8, 9], - "blocks must be dispatched in push order", - ); - - rt.shutdown(); - rt.wait().await.expect("clean shutdown"); - } - - /// Shutdown signalled while a dispatch is pending never destroys - /// completed work: the dispatch path sits outside the shutdown select, - /// so a block that was picked up finishes its wasmtime call and its - /// log record survives `wait()`. The test first proves the dispatch - /// completed (log line present), then shuts down and re-reads the same - /// record after the engine is fully torn down — if teardown dropped or - /// truncated completed work, the second read fails. Issue #58. - #[tokio::test] - async fn harness_shutdown_preserves_completed_dispatch() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - - let mut rt = TestRuntime::builder(wasm) - .manifest_inline(block_manifest("example", 1)) - .launch() - .await - .expect("launch example over the harness"); - - rt.push_block(header_numbered(1)); - rt.wait_for_log("example", "block 1 on chain") - .await - .expect("dispatch completed before shutdown"); - - let logs = rt.logs().clone(); - rt.shutdown(); - rt.wait().await.expect("no panic or corruption on shutdown"); - - let survived = logs.list_runs("example").into_iter().any(|meta| { - logs.read(&meta.run, 0) - .records - .iter() - .any(|r| r.message.contains("block 1 on chain")) - }); - assert!( - survived, - "the completed dispatch's log record must survive engine teardown", - ); - } - - /// `[limits.chain].response_body_max_bytes` is enforced on the real - /// `chain::request` path, end to end: the configured cap reaches - /// `HostState`, an over-cap node response is rejected before the guest - /// copy, and the module observes the typed `invalid-input` fault - /// instead of the body. Guards the wiring the unit tests on - /// `check_response_cap` cannot see (issue #154). - #[tokio::test] - async fn harness_enforces_chain_response_cap_on_the_request_path() { - use crate::engine_config::ChainLimitsSection; - use crate::host::component::ChainMethod; - - let Some(wasm) = module_wasm_or_skip("price_alert.wasm") else { - return; - }; - - // A syntactically valid oracle answer, ~330 bytes - far over the - // 16-byte cap below, so the module must never see it. - fn word(v: u128) -> String { - format!("{v:064x}") - } - let result = format!( - "\"0x{}{}{}{}{}\"", - word(1), - word(300_000_000_000), - word(0), - word(0), - word(1), - ); - - let builder = TestRuntime::builder(wasm) - .manifest_inline( - r#" -[module] -name = "price-alert" - -[capabilities] -required = ["logging", "chain"] - -[[subscription]] -kind = "block" -chain_id = 1 - -[config] -oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" -decimals = "8" -threshold = "2500.00" -direction = "above" -"#, - ) - .limits(ModuleLimits { - chain: ChainLimitsSection { - response_body_max_bytes: Some(16), - }, - ..Default::default() - }); - builder.chain().on_method(ChainMethod::EthCall, result); - - let mut rt = builder - .launch() - .await - .expect("launch price-alert with a 16-byte chain response cap"); - - rt.push_block(header_numbered(19_000_000)); - let record = rt - .wait_for_log("price-alert", "exceeds the configured cap") - .await - .expect("the module logs the guest-visible cap fault"); - assert!( - record.message.contains("eth_call failed"), - "the cap surfaces as a failed eth_call, got: {}", - record.message, - ); - - // The module never saw the oracle answer, so it must not trigger. - let runs = rt.logs().list_runs("price-alert"); - let triggered = runs.into_iter().any(|meta| { - rt.logs() - .read(&meta.run, 0) - .records - .iter() - .any(|r| r.message.contains("TRIGGERED")) - }); - assert!(!triggered, "an over-cap response must never reach classify"); - - rt.shutdown(); - rt.wait().await.expect("clean shutdown"); - } - - /// A dropped block stream is not the end of dispatch: the event loop's - /// reconnect task reopens the subscription after backoff and the - /// re-armed mock resumes delivery, matching a real provider that comes - /// back after a connection drop. - #[tokio::test] - async fn harness_resumes_dispatch_after_a_dropped_block_stream() { - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - - let mut rt = TestRuntime::builder(wasm) - .manifest_inline(block_manifest("example", 1)) - .launch() - .await - .expect("launch example over the harness"); - - rt.push_block(header_numbered(41)); - rt.wait_for_log("example", "block 41 on chain") - .await - .expect("the pre-drop block dispatches"); - - rt.chain().close_block_stream(); - rt.push_block(header_numbered(42)); - rt.wait_for_log("example", "block 42 on chain") - .await - .expect("dispatch resumes once the reconnect task reopens the stream"); - - rt.shutdown(); - rt.wait().await.expect("clean shutdown"); - } - - /// The guest observes the `WasiClockOverride` end to end: pin the harness - /// clock to a known instant, boot the clock-reader fixture under it, and - /// dispatch a block. The fixture reads `wasi:clocks/wall-clock` through - /// `std` and logs the wall time as whole seconds, so the logged value - /// equalling the pinned instant (and not the ambient host clock) proves - /// the override reaches the guest, not just the host boot path. - #[tokio::test] - async fn harness_guest_observes_the_clock_override() { - use std::time::{Duration, UNIX_EPOCH}; - - let Some(wasm) = module_wasm_or_skip("clock_reader.wasm") else { - return; - }; - - // A round instant far from the ambient clock: a stale ambient read - // would land in the 1.7-billion-plus range of the present, so an - // exact match on this value can only come from the override. - const PINNED_SECS: u64 = 1_700_000_000; - - let builder = TestRuntime::builder(wasm).manifest_inline(block_manifest("clock-reader", 1)); - builder - .clock() - .set(UNIX_EPOCH + Duration::from_secs(PINNED_SECS)); - - let mut rt = builder - .launch() - .await - .expect("launch clock-reader over the harness"); - - rt.push_block(header_numbered(19_000_000)); - let record = rt - .wait_for_log("clock-reader", &format!("clock wall {PINNED_SECS}")) - .await - .expect("the guest logs its wall-clock reading after dispatch"); - - // The line is a host-interface log carrying exactly the pinned - // seconds, parsed back to guard against a substring false positive. - assert_eq!( - record.source, - crate::host::logs::LogSource::HostInterface, - "the fixture logs through the host interface", - ); - let logged: u64 = record - .message - .rsplit(' ') - .next() - .and_then(|s| s.parse().ok()) - .expect("the log line ends in the wall-clock seconds"); - assert_eq!( - logged, PINNED_SECS, - "the guest read the overridden wall clock, not the ambient host clock", - ); - - rt.shutdown(); - rt.wait().await.expect("clean shutdown"); - } -} diff --git a/nexum/crates/nexum-runtime/src/test_utils/mod.rs b/nexum/crates/nexum-runtime/src/test_utils/mod.rs deleted file mode 100644 index 3f075b54..00000000 --- a/nexum/crates/nexum-runtime/src/test_utils/mod.rs +++ /dev/null @@ -1,294 +0,0 @@ -//! Engine-side mock backends for launching an in-process runtime entirely on -//! fakes. -//! -//! [`MockChainProvider`] and [`MockStateStore`] implement the component seam -//! traits with no network and no disk; [`Prebuilt`] wraps a pre-built instance -//! as a [`ComponentBuilder`](crate::host::component::ComponentBuilder); and -//! [`MockTypes`] is the domain-free lattice that ties them together. The -//! assembly composes through the public builder path: -//! -//! ```no_run -//! # use nexum_runtime::builder::RuntimeBuilder; -//! # use nexum_runtime::engine_config::EngineConfig; -//! # use nexum_runtime::host::component::ComponentsBuilder; -//! # use nexum_runtime::test_utils::{MockChainProvider, MockStateStore, MockTypes, Prebuilt}; -//! # async fn demo(config: &EngineConfig) -> anyhow::Result<()> { -//! let chain = MockChainProvider::new(); -//! let store = MockStateStore::new(); -//! let _handle = RuntimeBuilder::new(config) -//! .with_types::() -//! .with_components(ComponentsBuilder::new( -//! Prebuilt(chain.clone()), -//! Prebuilt(store.clone()), -//! (), -//! )) -//! .with_add_ons(&[]) -//! .launch() -//! .await?; -//! # Ok(()) -//! # } -//! ``` -//! -//! The caller keeps its own clones of `chain` / `store` to program responses -//! and assert on what a module wrote. - -mod builders; -mod chain; -pub mod clock; -pub mod harness; -mod store; -mod types; - -pub use builders::Prebuilt; -pub use chain::{MockChainProvider, RecordedRequest}; -pub use harness::{TestRuntime, TestRuntimeBuilder}; -pub use store::{MockStateHandle, MockStateStore}; -pub use types::MockTypes; - -use crate::engine_config::ModuleLimits; -use crate::host::component::Components; -use crate::host::logs::LogPipeline; - -/// A fresh in-memory [`LogPipeline`] at the default retention limits, the -/// one fake test bundles share so the construction lives in a single place. -pub(crate) fn in_memory_logs() -> LogPipeline { - LogPipeline::in_memory(ModuleLimits::default().logs()) -} - -/// A [`Components`] bundle over fresh mock backends, ready for -/// [`Supervisor::boot`](crate::supervisor::Supervisor::boot). -pub fn mock_components() -> Components { - mock_components_from(MockChainProvider::new(), MockStateStore::new()) -} - -/// A [`Components`] bundle over the given mock backends, with an empty -/// extension slot and an in-memory log pipeline. Pass clones the caller -/// retains for programming and assertion. -pub fn mock_components_from( - chain: MockChainProvider, - store: MockStateStore, -) -> Components { - Components { - chain, - store, - ext: (), - logs: in_memory_logs(), - remote: crate::host::remote_store_bee::RemoteStore::disabled(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_chains::Chain; - use futures::StreamExt as _; - - use crate::builder::RuntimeBuilder; - use crate::engine_config::EngineConfig; - use crate::host::component::{ - ChainMethod, ChainProvider, ComponentsBuilder, StateHandle, StateStore, - }; - use crate::host::provider_pool::ProviderError; - - /// M0 acceptance: a custom component set launched entirely through the - /// public builder, on fakes, with no CLI, no disk, and no network. The - /// launch reaches supervisor boot and bails only because the default - /// config declares no modules, which proves the mock backends composed - /// and the build path ran end to end. - #[tokio::test] - async fn m0_custom_component_set_launches_through_the_public_builder() { - let chain = MockChainProvider::new(); - chain.on_method(ChainMethod::EthBlockNumber, "\"0x10\""); - let store = MockStateStore::new(); - - let config = EngineConfig::default(); - let err = match RuntimeBuilder::new(&config) - .with_types::() - .with_components(ComponentsBuilder::new( - Prebuilt(chain.clone()), - Prebuilt(store), - (), - )) - .with_add_ons(&[]) - .launch() - .await - { - Ok(_) => panic!("default config declares no modules; launch must bail"), - Err(err) => err, - }; - assert!(err.to_string().contains("no modules to run"), "{err}"); - - // The fake actually serves and records, independent of the launch. - let body = ChainProvider::request( - &chain, - Chain::from_id(1), - ChainMethod::EthBlockNumber, - "[]".into(), - ) - .await - .expect("programmed response"); - assert_eq!(body, "\"0x10\""); - assert_eq!(chain.recorded_requests().len(), 1); - } - - /// The exact `(method, params)` programming wins over a method wildcard, - /// and an unprogrammed method surfaces a provider error. - #[tokio::test] - async fn request_routing_prefers_exact_then_wildcard() { - let chain = MockChainProvider::new(); - chain - .on_method(ChainMethod::EthCall, "\"wild\"") - .on_request(ChainMethod::EthCall, "[\"0x1\"]", "\"exact\""); - - let exact = ChainProvider::request( - &chain, - Chain::from_id(1), - ChainMethod::EthCall, - "[\"0x1\"]".into(), - ) - .await - .expect("exact"); - assert_eq!(exact, "\"exact\""); - - let wild = - ChainProvider::request(&chain, Chain::from_id(1), ChainMethod::EthCall, "[]".into()) - .await - .expect("wildcard"); - assert_eq!(wild, "\"wild\""); - - let err = ChainProvider::request( - &chain, - Chain::from_id(1), - ChainMethod::EthChainId, - "[]".into(), - ) - .await - .expect_err("unprogrammed method has no response"); - assert!( - matches!(err, ProviderError::UnknownChain(c) if c == Chain::from_id(1)), - "unprogrammed method surfaces UnknownChain, got: {err:?}", - ); - } - - /// A pushed header reaches the open block subscription. - #[tokio::test] - async fn subscribe_blocks_yields_pushed_headers() { - let chain = MockChainProvider::new(); - let mut stream = ChainProvider::subscribe_blocks(&chain, Chain::from_id(1)) - .await - .expect("block stream"); - chain.push_block(alloy_rpc_types_eth::Header::default()); - let item = stream.next().await.expect("one item"); - assert!(item.is_ok(), "pushed header arrives as Ok"); - } - - /// A scripted error surfaces as an `Err` item on the block stream, and - /// closing the stream terminates it so a reconnect loop sees the end. - #[tokio::test] - async fn block_stream_scripts_errors_and_end() { - let chain = MockChainProvider::new(); - let mut stream = ChainProvider::subscribe_blocks(&chain, Chain::from_id(1)) - .await - .expect("block stream"); - - chain.push_block_err(ProviderError::UnknownChain(Chain::from_id(1))); - let err = stream.next().await.expect("one item"); - assert!( - matches!(err, Err(ProviderError::UnknownChain(_))), - "scripted error arrives as Err, got: {err:?}", - ); - - chain.push_block(alloy_rpc_types_eth::Header::default()); - chain.close_block_stream(); - assert!(stream.next().await.is_some(), "buffered item drains first"); - assert!( - stream.next().await.is_none(), - "closed stream terminates after draining", - ); - } - - /// After a close, a fresh `subscribe_blocks` (the event loop's reconnect - /// path) yields the items pushed since, so the fake keeps the real - /// provider's drop-then-reconnect delivery contract. - #[tokio::test] - async fn closed_block_stream_rearms_for_the_reconnect_subscribe() { - let chain = MockChainProvider::new(); - let mut stream = ChainProvider::subscribe_blocks(&chain, Chain::from_id(1)) - .await - .expect("block stream"); - chain.close_block_stream(); - assert!(stream.next().await.is_none(), "closed stream terminates"); - - chain.push_block(alloy_rpc_types_eth::Header::default()); - let mut reopened = ChainProvider::subscribe_blocks(&chain, Chain::from_id(1)) - .await - .expect("reopened block stream"); - let item = reopened.next().await.expect("post-close push arrives"); - assert!(item.is_ok(), "pushed header arrives on the reopened stream"); - } - - /// The chain-log poller stream carries scripted errors and terminates on - /// close, mirroring the block leg. Each pushed log arrives as a one-log - /// canonical batch. - #[tokio::test] - async fn chain_log_stream_scripts_errors_and_end() { - let chain = MockChainProvider::new(); - let mut stream = - ChainProvider::watch_chain_logs(&chain, Chain::from_id(1), Default::default(), 0) - .expect("chain-log poller stream"); - - chain.push_chain_log_err(ProviderError::UnknownChain(Chain::from_id(1))); - let err = stream.next().await.expect("one item"); - assert!( - matches!(err, Err(ProviderError::UnknownChain(_))), - "{err:?}" - ); - - chain.close_chain_log_stream(); - assert!( - stream.next().await.is_none(), - "closed chain-log stream terminates", - ); - - // The slot re-arms: a post-close push arrives on the reopened stream. - chain.push_chain_log(alloy_rpc_types_eth::Log::default()); - let mut reopened = - ChainProvider::watch_chain_logs(&chain, Chain::from_id(1), Default::default(), 0) - .expect("reopened chain-log poller stream"); - let batch = reopened - .next() - .await - .expect("post-close push arrives") - .expect("pushed log arrives as an Ok batch"); - assert_eq!( - batch.len(), - 1, - "single pushed log arrives as a one-log batch" - ); - } - - /// The store round-trips values, isolates namespaces, lists by prefix, and - /// rejects the empty namespace. - #[test] - fn store_roundtrips_and_isolates_namespaces() { - let store = MockStateStore::new(); - let a = store.module("mod-a").expect("namespace a"); - let b = store.module("mod-b").expect("namespace b"); - - a.set("k", b"va").expect("set a"); - b.set("k", b"vb").expect("set b"); - assert_eq!(a.get("k").expect("get a").as_deref(), Some(&b"va"[..])); - assert_eq!(b.get("k").expect("get b").as_deref(), Some(&b"vb"[..])); - - a.set("k2", b"x").expect("set a k2"); - assert_eq!( - a.list_keys("k").expect("list a"), - vec!["k".to_owned(), "k2".to_owned()], - ); - - a.delete("k").expect("delete a k"); - assert!(a.get("k").expect("get a k").is_none()); - - assert!(store.module("").is_err(), "empty namespace rejected"); - } -} diff --git a/nexum/crates/nexum-runtime/src/test_utils/store.rs b/nexum/crates/nexum-runtime/src/test_utils/store.rs deleted file mode 100644 index 7d8be449..00000000 --- a/nexum/crates/nexum-runtime/src/test_utils/store.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! In-memory [`StateStore`] fake: per-namespace `HashMap`, no redb, no disk. - -// StorageError embeds redb error types; same allowance as the seam it mirrors. -#![allow(clippy::result_large_err)] - -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use crate::host::component::{StateHandle, StateStore}; -use crate::host::local_store_redb::StorageError; - -type Namespaces = HashMap>>; - -/// Process-lifetime in-memory store keyed by namespace then key. Cheap `Arc` -/// clone shares one backing map, so a test keeps a clone to assert on what a -/// module wrote. -#[derive(Clone, Default)] -pub struct MockStateStore { - namespaces: Arc>, -} - -impl MockStateStore { - /// Fresh empty store. - pub fn new() -> Self { - Self::default() - } -} - -/// Per-module handle over the shared map, scoped to one namespace. -#[derive(Clone)] -pub struct MockStateHandle { - namespaces: Arc>, - namespace: String, - quota_bytes: Option, -} - -impl StateStore for MockStateStore { - type Handle = MockStateHandle; - - fn module(&self, namespace: &str) -> Result { - // Reject the empty namespace so the handle always has a real prefix, - // matching the redb-backed store. - if namespace.is_empty() { - return Err(StorageError::InvalidNamespace( - "module namespace must not be empty".into(), - )); - } - Ok(MockStateHandle { - namespaces: Arc::clone(&self.namespaces), - namespace: namespace.to_owned(), - quota_bytes: None, - }) - } -} - -impl MockStateHandle { - fn lock(&self) -> std::sync::MutexGuard<'_, Namespaces> { - self.namespaces.lock().expect("mock store mutex poisoned") - } -} - -impl StateHandle for MockStateHandle { - fn with_quota(mut self, quota_bytes: u64) -> Self { - self.quota_bytes = Some(quota_bytes); - self - } - - fn get(&self, key: &str) -> Result>, StorageError> { - Ok(self - .lock() - .get(&self.namespace) - .and_then(|m| m.get(key)) - .cloned()) - } - - fn set(&self, key: &str, value: &[u8]) -> Result<(), StorageError> { - let mut map = self.lock(); - let ns = map.entry(self.namespace.clone()).or_default(); - if let Some(quota) = self.quota_bytes { - let entry = (key.len() + value.len()) as u64; - let old = ns - .get(key) - .map(|v| (key.len() + v.len()) as u64) - .unwrap_or(0); - let used: u64 = ns.iter().map(|(k, v)| (k.len() + v.len()) as u64).sum(); - let projected = used.saturating_sub(old) + entry; - if projected > quota { - return Err(StorageError::QuotaExceeded { - needed: projected, - quota, - }); - } - } - ns.insert(key.to_owned(), value.to_vec()); - Ok(()) - } - - fn delete(&self, key: &str) -> Result<(), StorageError> { - if let Some(m) = self.lock().get_mut(&self.namespace) { - m.remove(key); - } - Ok(()) - } - - fn list_keys(&self, prefix: &str) -> Result, StorageError> { - let map = self.lock(); - let mut keys: Vec = map - .get(&self.namespace) - .into_iter() - .flat_map(|m| m.keys()) - .filter(|k| k.starts_with(prefix)) - .cloned() - .collect(); - // Sorted for deterministic enumeration, matching the redb B-tree order. - keys.sort(); - Ok(keys) - } -} diff --git a/nexum/crates/nexum-runtime/src/test_utils/types.rs b/nexum/crates/nexum-runtime/src/test_utils/types.rs deleted file mode 100644 index 4180b328..00000000 --- a/nexum/crates/nexum-runtime/src/test_utils/types.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! The [`RuntimeTypes`] lattice over the in-process mocks. - -use std::marker::PhantomData; - -use crate::host::component::RuntimeTypes; -use crate::test_utils::{MockChainProvider, MockStateStore}; - -/// Lattice binding the mock backends. The extension slot is the type -/// parameter `E`, defaulting to the empty payload (`()`) so an assembly with -/// no extensions composes exactly as a domain-free lattice. An extension -/// crate binds its own `Ext` payload through the same mocks by naming -/// `MockTypes`. -/// -/// This is a type-level marker: it is only ever named, never constructed, so -/// it derives no traits and is zero-sized at runtime. -pub struct MockTypes(PhantomData E>); - -impl crate::sealed::SealedRuntimeTypes for MockTypes {} - -impl RuntimeTypes for MockTypes { - type Chain = MockChainProvider; - type Store = MockStateStore; - type Ext = E; -} diff --git a/nexum/crates/nexum-sdk-test/Cargo.toml b/nexum/crates/nexum-sdk-test/Cargo.toml deleted file mode 100644 index 0b1fe713..00000000 --- a/nexum/crates/nexum-sdk-test/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "nexum-sdk-test" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "In-memory host mocks for nexum module unit tests. Implements nexum_sdk::host::{ChainHost, LocalStoreHost, LoggingHost}." - -[lib] -# Plain library, host-only - module Cargo.toml lists this under -# [dev-dependencies] so it never ships in the wasm bundle. - -[dependencies] -nexum-sdk = { path = "../nexum-sdk" } -tracing.workspace = true diff --git a/nexum/crates/nexum-sdk-test/src/lib.rs b/nexum/crates/nexum-sdk-test/src/lib.rs deleted file mode 100644 index 1da359e2..00000000 --- a/nexum/crates/nexum-sdk-test/src/lib.rs +++ /dev/null @@ -1,1582 +0,0 @@ -//! # nexum-sdk-test -//! -//! In-memory implementations of the [`nexum_sdk::host`] traits -//! plus assertion helpers, so a module can write integration -//! tests for its strategy logic without `wit-bindgen`, `wasmtime`, or -//! a network round-trip. -//! -//! ## Usage -//! -//! Add as a dev-dep on the module crate: -//! -//! ```toml -//! [dev-dependencies] -//! nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } -//! ``` -//! -//! Structure the module's strategy function around the host traits: -//! -//! ```rust,ignore -//! pub fn handle_block( -//! host: &H, -//! chain_id: u64, -//! block_number: u64, -//! ) -> Result<(), nexum_sdk::host::Fault> { -//! // ... -//! let res = host.request(chain_id, "eth_call", "[]")?; -//! host.set("last_block", &block_number.to_le_bytes())?; -//! host.log(nexum_sdk::Level::INFO, "saw block"); -//! Ok(()) -//! } -//! ``` -//! -//! Test against [`MockHost`]: -//! -//! ```rust -//! // Glob-import the host traits so the method shortcuts resolve. -//! use nexum_sdk::host::*; -//! use nexum_sdk_test::MockHost; -//! -//! let host = MockHost::new(); -//! host.chain.respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); -//! -//! // Call the strategy directly: -//! assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); -//! -//! // Inspect: -//! assert_eq!(host.chain.calls().len(), 1); -//! ``` -//! -//! ## Adapting from wit-bindgen -//! -//! The traits report failures as [`nexum_sdk::host::Fault`] rather than -//! the `Fault` `wit_bindgen::generate!` emits per-module. A module -//! bridges with a trivial converter on its own crate boundary - see the -//! tutorial for the exact shape. -//! -//! Domain test crates compose these mocks with their own scripted -//! venue transports on the `videre:venue/client` seam. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![warn(missing_docs)] - -use std::cell::{Cell, RefCell}; -use std::collections::{BTreeMap, HashMap}; -use std::fmt::{self, Write as _}; -use std::rc::Rc; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; - -use nexum_sdk::Level; -use nexum_sdk::host::{ - ChainError, ChainHost, Fault, IdentityHost, LocalStoreHost, LoggingHost, Message, - MessagingHost, RemoteStoreHost, -}; -use nexum_sdk::prelude::{Address, B256, Signature, keccak256}; -use tracing::field::{Field, Visit}; -use tracing::level_filters::LevelFilter; -use tracing::span::{Attributes, Id, Record}; -use tracing::{Event, Metadata, Subscriber}; - -/// Composed in-memory host. Each field exposes the per-trait mock so -/// tests can program responses and assert on calls. -#[derive(Default)] -pub struct MockHost { - /// `nexum:host/chain` mock. - pub chain: MockChain, - /// `nexum:host/identity` mock. - pub identity: MockIdentity, - /// `nexum:host/local-store` mock. - pub store: MockLocalStore, - /// `nexum:host/remote-store` mock. - pub remote_store: MockRemoteStore, - /// `nexum:host/messaging` mock. - pub messaging: MockMessaging, - /// `nexum:host/logging` mock. - pub logging: MockLogging, -} - -impl MockHost { - /// Fresh empty host. Equivalent to `Default::default`. - pub fn new() -> Self { - Self::default() - } -} - -impl ChainHost for MockHost { - fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { - self.chain.request(chain_id, method, params) - } -} - -impl LocalStoreHost for MockHost { - fn get(&self, key: &str) -> Result>, Fault> { - self.store.get(key) - } - fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { - self.store.set(key, value) - } - fn delete(&self, key: &str) -> Result<(), Fault> { - self.store.delete(key) - } - fn list_keys(&self, prefix: &str) -> Result, Fault> { - self.store.list_keys(prefix) - } - fn contains(&self, key: &str) -> Result { - self.store.contains(key) - } - fn len(&self, key: &str) -> Result, Fault> { - // Qualified: the mock's inherent `len` counts rows. - LocalStoreHost::len(&self.store, key) - } - fn count(&self, prefix: &str) -> Result { - self.store.count(prefix) - } -} - -impl IdentityHost for MockHost { - fn accounts(&self) -> Result, Fault> { - self.identity.accounts() - } - fn sign(&self, account: Address, message: &[u8]) -> Result { - self.identity.sign(account, message) - } - fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result { - self.identity.sign_typed_data(account, typed_data) - } -} - -impl RemoteStoreHost for MockHost { - fn upload(&self, data: &[u8]) -> Result { - self.remote_store.upload(data) - } - fn download(&self, reference: B256) -> Result, Fault> { - self.remote_store.download(reference) - } - fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault> { - self.remote_store.read_feed(owner, topic) - } - fn write_feed(&self, topic: B256, data: &[u8]) -> Result { - self.remote_store.write_feed(topic, data) - } -} - -impl MessagingHost for MockHost { - fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { - self.messaging.publish(content_topic, payload) - } - fn query( - &self, - content_topic: &str, - start_time: Option, - end_time: Option, - limit: Option, - ) -> Result, Fault> { - self.messaging - .query(content_topic, start_time, end_time, limit) - } -} - -impl LoggingHost for MockHost { - fn log(&self, level: Level, message: &str) { - self.logging.log(level, message); - } -} - -// ---------------------------------------------------------------- chain - -/// In-memory [`ChainHost`] backed by a `(method, params)` -> response -/// map. Records every call so tests can assert dispatch shape. -#[derive(Default)] -pub struct MockChain { - responses: RefCell>>, - calls: RefCell>, -} - -/// One recorded [`MockChain::request`] invocation. -#[derive(Clone, Debug)] -pub struct ChainCall { - /// EVM chain id the guest passed. - pub chain_id: u64, - /// JSON-RPC method name. - pub method: String, - /// JSON-encoded params array (verbatim). - pub params: String, -} - -impl MockChain { - /// Program a response for the `(method, params)` pair. Overwrites - /// any prior entry. - pub fn respond_to( - &self, - method: impl Into, - params: impl Into, - result: Result, - ) { - self.responses - .borrow_mut() - .insert((method.into(), params.into()), result); - } - - /// All calls received, in arrival order. - pub fn calls(&self) -> Vec { - self.calls.borrow().clone() - } - - /// Last call received, if any. - pub fn last_call(&self) -> Option { - self.calls.borrow().last().cloned() - } - - /// Total call count. - pub fn call_count(&self) -> usize { - self.calls.borrow().len() - } -} - -impl ChainHost for MockChain { - fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { - self.calls.borrow_mut().push(ChainCall { - chain_id, - method: method.to_string(), - params: params.to_string(), - }); - self.responses - .borrow() - .get(&(method.to_string(), params.to_string())) - .cloned() - .unwrap_or_else(|| { - Err(ChainError::Fault(Fault::Unsupported(format!( - "MockChain: no response configured for {method} {params}" - )))) - }) - } -} - -// ---------------------------------------------------------------- identity - -/// One recorded [`MockIdentity`] signing invocation. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SignCall { - /// Account the guest asked to sign with. - pub account: Address, - /// What was signed. - pub payload: SignPayload, -} - -/// The payload of a [`SignCall`], per signing entry point. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum SignPayload { - /// A `sign` call: raw message bytes (`personal_sign` semantics). - Message(Vec), - /// A `sign_typed_data` call: the JSON-encoded EIP-712 payload. - TypedData(String), -} - -/// In-memory [`IdentityHost`]. Holds a programmable account roster and -/// one programmed signing outcome; records every signing call. With no -/// outcome programmed, signing fails as [`Fault::Unsupported`], the -/// stub-backend posture; an account outside the roster fails as -/// [`Fault::Denied`] before the programmed outcome applies. -#[derive(Default)] -pub struct MockIdentity { - accounts: RefCell>, - response: RefCell>>, - calls: RefCell>, -} - -impl MockIdentity { - /// Add an account to the roster [`accounts`](IdentityHost::accounts) - /// reports and signing admits. - pub fn add_account(&self, account: Address) { - self.accounts.borrow_mut().push(account); - } - - /// Program the outcome every subsequent signing call returns. - pub fn respond(&self, result: Result) { - *self.response.borrow_mut() = Some(result); - } - - /// All signing calls received, in arrival order. - pub fn calls(&self) -> Vec { - self.calls.borrow().clone() - } - - /// Last signing call received, if any. - pub fn last_call(&self) -> Option { - self.calls.borrow().last().cloned() - } - - /// Total signing call count. - pub fn call_count(&self) -> usize { - self.calls.borrow().len() - } - - fn dispatch(&self, account: Address, payload: SignPayload) -> Result { - self.calls.borrow_mut().push(SignCall { account, payload }); - if !self.accounts.borrow().contains(&account) { - return Err(Fault::Denied(format!( - "MockIdentity: account {account} is not held" - ))); - } - self.response.borrow().clone().unwrap_or_else(|| { - Err(Fault::Unsupported( - "MockIdentity: no signing outcome programmed".to_string(), - )) - }) - } -} - -impl IdentityHost for MockIdentity { - fn accounts(&self) -> Result, Fault> { - Ok(self.accounts.borrow().clone()) - } - - fn sign(&self, account: Address, message: &[u8]) -> Result { - self.dispatch(account, SignPayload::Message(message.to_vec())) - } - - fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result { - self.dispatch(account, SignPayload::TypedData(typed_data.to_owned())) - } -} - -// ---------------------------------------------------------------- messaging - -/// One recorded [`MessagingHost::publish`] invocation. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PublishRecord { - /// Content topic published to. - pub content_topic: String, - /// Payload bytes, verbatim. - pub payload: Vec, -} - -/// In-memory [`MessagingHost`]. Seeded messages answer queries, -/// publishes are recorded for assertion, and an optional topic scope -/// mirrors the host's `messaging_topics` grant. Seeded history and -/// published records are deliberately separate stores: a query answers -/// from what the test seeded, never from what the guest published. -#[derive(Default)] -pub struct MockMessaging { - history: RefCell>, - published: RefCell>, - scope: RefCell>>, - faults: RefCell>, -} - -impl MockMessaging { - /// Seed one message into the queryable history. - pub fn seed(&self, message: Message) { - self.history.borrow_mut().push(message); - } - - /// Seed a payload on `content_topic` at `timestamp` (ms since the - /// Unix epoch, UTC), with no sender. - pub fn seed_payload( - &self, - content_topic: impl Into, - payload: impl Into>, - timestamp: u64, - ) { - self.seed(Message { - content_topic: content_topic.into(), - payload: payload.into(), - timestamp, - sender: None, - }); - } - - /// Confine the mock to `topics`, playing the component's - /// `messaging_topics` grant with the host's matching: a topic is - /// admitted when it equals a grant entry or descends from one read - /// as a `/`-bounded path prefix; anything else fails as - /// [`Fault::Denied`]. An empty grant is unscoped, the host's module - /// default, as is an untouched mock. - pub fn scope_topics(&self, topics: impl IntoIterator>) { - *self.scope.borrow_mut() = Some(topics.into_iter().map(Into::into).collect()); - } - - /// Inject a fault for any operation on a topic starting with - /// `prefix`. Multiple patterns can be registered; the first - /// matching one fires. - pub fn fail_on(&self, prefix: impl Into, fault: Fault) { - self.faults.borrow_mut().push((prefix.into(), fault)); - } - - /// All publishes received, in arrival order. - pub fn published(&self) -> Vec { - self.published.borrow().clone() - } - - /// Last publish received, if any. - pub fn last_published(&self) -> Option { - self.published.borrow().last().cloned() - } - - /// Total publish count. - pub fn publish_count(&self) -> usize { - self.published.borrow().len() - } - - fn admit(&self, content_topic: &str) -> Result<(), Fault> { - for (prefix, fault) in self.faults.borrow().iter() { - if content_topic.starts_with(prefix.as_str()) { - return Err(fault.clone()); - } - } - if let Some(scope) = self.scope.borrow().as_ref() - && !topic_in_scope(content_topic, scope) - { - return Err(Fault::Denied(format!( - "MockMessaging: {content_topic} is outside the scoped topics" - ))); - } - Ok(()) - } -} - -/// The host's `messaging_topics` matching: an empty scope admits every -/// topic; otherwise a topic is admitted when it equals a scope entry or -/// descends from one read as a path prefix bounded at `/`, so a grant -/// never leaks into a longer sibling segment. -fn topic_in_scope(topic: &str, scope: &[String]) -> bool { - if scope.is_empty() { - return true; - } - scope.iter().any(|allowed| { - if topic == allowed { - return true; - } - let prefix = allowed.strip_suffix('/').unwrap_or(allowed); - topic - .strip_prefix(prefix) - .is_some_and(|rest| rest.starts_with('/')) - }) -} - -impl MessagingHost for MockMessaging { - fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { - self.admit(content_topic)?; - self.published.borrow_mut().push(PublishRecord { - content_topic: content_topic.to_owned(), - payload: payload.to_vec(), - }); - Ok(()) - } - - /// Answer from the seeded history: exact-topic matches whose - /// timestamp lies within the inclusive `start_time..=end_time` - /// window, in seed order. Seed order is delivery order, so a - /// `limit` keeps the newest matches: the tail. - fn query( - &self, - content_topic: &str, - start_time: Option, - end_time: Option, - limit: Option, - ) -> Result, Fault> { - self.admit(content_topic)?; - let mut matches: Vec = self - .history - .borrow() - .iter() - .filter(|message| { - message.content_topic == content_topic - && start_time.is_none_or(|start| message.timestamp >= start) - && end_time.is_none_or(|end| message.timestamp <= end) - }) - .cloned() - .collect(); - if let Some(limit) = limit { - let keep = usize::try_from(limit).unwrap_or(usize::MAX); - if matches.len() > keep { - matches.drain(..matches.len() - keep); - } - } - Ok(matches) - } -} - -// ---------------------------------------------------------------- remote-store - -/// In-memory [`RemoteStoreHost`]: content-addressed blobs plus mutable -/// `(owner, topic)` feeds. The mock addresses blobs by `keccak256` of -/// their content, so uploads are deterministic for assertion; the real -/// store's addressing scheme is the host's concern. Feed writes land -/// under the mock's own owner ([`set_owner`](Self::set_owner), -/// zero-address by default), mirroring the host signing feed updates -/// with its configured identity. -#[derive(Default)] -pub struct MockRemoteStore { - blobs: RefCell>>, - feeds: RefCell>>, - owner: Cell
, - fault: RefCell>, -} - -impl MockRemoteStore { - /// Set the owner feed writes land under. - pub fn set_owner(&self, owner: Address) { - self.owner.set(owner); - } - - /// Seed a blob without going through the trait; returns its - /// reference. - pub fn seed_blob(&self, data: impl Into>) -> B256 { - let data = data.into(); - let reference = keccak256(&data); - self.blobs.borrow_mut().insert(reference, data); - reference - } - - /// Seed another owner's feed for [`read_feed`](RemoteStoreHost::read_feed) - /// tests. - pub fn seed_feed(&self, owner: Address, topic: B256, data: impl Into>) { - self.feeds.borrow_mut().insert((owner, topic), data.into()); - } - - /// Inject a fault every subsequent operation returns. - pub fn fail_with(&self, fault: Fault) { - *self.fault.borrow_mut() = Some(fault); - } - - /// Number of stored blobs. - pub fn blob_count(&self) -> usize { - self.blobs.borrow().len() - } - - fn check_injected_fault(&self) -> Result<(), Fault> { - match self.fault.borrow().as_ref() { - Some(fault) => Err(fault.clone()), - None => Ok(()), - } - } -} - -impl RemoteStoreHost for MockRemoteStore { - fn upload(&self, data: &[u8]) -> Result { - self.check_injected_fault()?; - Ok(self.seed_blob(data)) - } - - fn download(&self, reference: B256) -> Result, Fault> { - self.check_injected_fault()?; - self.blobs - .borrow() - .get(&reference) - .cloned() - .ok_or_else(|| Fault::Unavailable(format!("MockRemoteStore: no blob at {reference}"))) - } - - fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault> { - self.check_injected_fault()?; - Ok(self.feeds.borrow().get(&(owner, topic)).cloned()) - } - - fn write_feed(&self, topic: B256, data: &[u8]) -> Result { - self.check_injected_fault()?; - let reference = self.seed_blob(data); - self.feeds - .borrow_mut() - .insert((self.owner.get(), topic), data.to_vec()); - Ok(reference) - } -} - -// ---------------------------------------------------------------- local-store - -/// In-memory [`LocalStoreHost`] mirroring the runtime store's shape: -/// namespaced views over one shared row map, plus store-wide entry -/// and byte limits. -/// -/// A fresh store is the root view. [`namespaced`](Self::namespaced) -/// derives a sibling view over the same backing rows - identical key -/// strings in different namespaces never collide, matching the host's -/// per-module key prefixing. Limits sit on the shared backing store, -/// so one namespace's writes can exhaust another's headroom exactly -/// as two modules share one database file. Fault injection via -/// [`fail_on`](Self::fail_on) stays per-view. -/// -/// # Fidelity vs the real `redb` store -/// -/// Two gaps remain (deferred to the `MockRuntime` refactor, #94): -/// - **No transaction semantics** - `redb` wraps each `on_event` in an -/// implicit write transaction (commit on `Ok`, rollback on trap); this -/// mock commits every `set` immediately. -/// - **No concurrent access** - the backing `RefCell` is single-threaded, -/// whereas `redb` uses MVCC. -#[derive(Default)] -pub struct MockLocalStore { - shared: Rc, - namespace: String, - /// Key patterns that trigger injected faults on any operation. - error_patterns: RefCell>, -} - -/// Backing rows and limits shared by every namespaced view. -#[derive(Default)] -struct SharedRows { - /// Rows keyed by `(namespace, key)`. - rows: RefCell>>, - /// Total stored bytes (key + value) across all namespaces. - bytes: Cell, - /// When set, `set` on a new key fails once the store holds this - /// many rows. - max_entries: Cell>, - /// When set, `set` fails once stored bytes would exceed this. - max_bytes: Cell>, -} - -impl MockLocalStore { - /// A view over the same backing rows under `namespace`. Views with - /// the same namespace alias the same data (two handles onto one - /// module store); different namespaces are fully isolated even for - /// identical key strings. - /// - /// # Panics - /// - /// On an empty namespace - the runtime rejects those too. - pub fn namespaced(&self, namespace: impl Into) -> MockLocalStore { - let namespace = namespace.into(); - assert!( - !namespace.is_empty(), - "MockLocalStore: namespace must not be empty", - ); - MockLocalStore { - shared: Rc::clone(&self.shared), - namespace, - error_patterns: RefCell::new(Vec::new()), - } - } - - /// Number of rows in this view's namespace. - pub fn len(&self) -> usize { - self.shared - .rows - .borrow() - .keys() - .filter(|(ns, _)| *ns == self.namespace) - .count() - } - - /// Whether this view's namespace holds no rows. - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - /// Direct read of this view's namespace for assertions - bypasses - /// the trait. - pub fn snapshot(&self) -> HashMap> { - self.shared - .rows - .borrow() - .iter() - .filter(|((ns, _), _)| *ns == self.namespace) - .map(|((_, key), value)| (key.clone(), value.clone())) - .collect() - } - - /// Cap the row count across every namespace. Once reached, `set` - /// on a new key fails; overwriting an existing key still succeeds. - pub fn set_max_entries(&self, limit: usize) { - self.shared.max_entries.set(Some(limit)); - } - - /// Cap total stored bytes (key + value, across every namespace). - /// A `set` that would push the total past the cap fails; deletes - /// and same-key overwrites release the bytes they displace. - pub fn set_max_bytes(&self, limit: usize) { - self.shared.max_bytes.set(Some(limit)); - } - - /// Inject a fault for any operation where the key starts with - /// `prefix`. Multiple patterns can be registered; the first - /// matching one fires. - pub fn fail_on(&self, prefix: impl Into, fault: Fault) { - self.error_patterns - .borrow_mut() - .push((prefix.into(), fault)); - } - - fn check_injected_error(&self, key: &str) -> Result<(), Fault> { - for (pattern, fault) in self.error_patterns.borrow().iter() { - if key.starts_with(pattern) { - return Err(fault.clone()); - } - } - Ok(()) - } -} - -impl LocalStoreHost for MockLocalStore { - fn get(&self, key: &str) -> Result>, Fault> { - self.check_injected_error(key)?; - Ok(self - .shared - .rows - .borrow() - .get(&(self.namespace.clone(), key.to_string())) - .cloned()) - } - fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { - self.check_injected_error(key)?; - let mut rows = self.shared.rows.borrow_mut(); - let compound = (self.namespace.clone(), key.to_string()); - let existing = rows.get(&compound).map(Vec::len); - if existing.is_none() - && let Some(limit) = self.shared.max_entries.get() - && rows.len() >= limit - { - return Err(Fault::Internal(format!( - "MockLocalStore: max entries ({limit}) reached" - ))); - } - // Same-key overwrites release the displaced bytes before the - // new row is charged. - let displaced = existing.map_or(0, |len| key.len() + len); - let total = self.shared.bytes.get() - displaced + key.len() + value.len(); - if let Some(budget) = self.shared.max_bytes.get() - && total > budget - { - return Err(Fault::Internal(format!( - "MockLocalStore: max bytes ({budget}) reached" - ))); - } - rows.insert(compound, value.to_vec()); - self.shared.bytes.set(total); - Ok(()) - } - fn delete(&self, key: &str) -> Result<(), Fault> { - self.check_injected_error(key)?; - if let Some(value) = self - .shared - .rows - .borrow_mut() - .remove(&(self.namespace.clone(), key.to_string())) - { - self.shared - .bytes - .set(self.shared.bytes.get() - key.len() - value.len()); - } - Ok(()) - } - fn list_keys(&self, prefix: &str) -> Result, Fault> { - self.check_injected_error(prefix)?; - let mut keys: Vec = self - .shared - .rows - .borrow() - .keys() - .filter(|(ns, key)| *ns == self.namespace && key.starts_with(prefix)) - .map(|(_, key)| key.clone()) - .collect(); - keys.sort(); - Ok(keys) - } - fn contains(&self, key: &str) -> Result { - self.check_injected_error(key)?; - Ok(self - .shared - .rows - .borrow() - .contains_key(&(self.namespace.clone(), key.to_string()))) - } - fn len(&self, key: &str) -> Result, Fault> { - self.check_injected_error(key)?; - Ok(self - .shared - .rows - .borrow() - .get(&(self.namespace.clone(), key.to_string())) - .map(|v| v.len() as u64)) - } - fn count(&self, prefix: &str) -> Result { - self.check_injected_error(prefix)?; - Ok(self - .shared - .rows - .borrow() - .keys() - .filter(|(ns, key)| *ns == self.namespace && key.starts_with(prefix)) - .count() as u64) - } -} - -// ---------------------------------------------------------------- logging - -/// One recorded log line. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct LogLine { - /// Severity the module passed. - pub level: Level, - /// Message body. - pub message: String, -} - -/// In-memory [`LoggingHost`] that buffers every emitted line. -#[derive(Default)] -pub struct MockLogging { - lines: RefCell>, -} - -impl MockLogging { - /// All buffered log lines, in emission order. - pub fn lines(&self) -> Vec { - self.lines.borrow().clone() - } - - /// `true` if any buffered line contains `needle` (substring match). - pub fn contains(&self, needle: &str) -> bool { - self.lines - .borrow() - .iter() - .any(|l| l.message.contains(needle)) - } - - /// Count of lines at `level`. - pub fn count_at(&self, level: Level) -> usize { - self.lines - .borrow() - .iter() - .filter(|l| l.level == level) - .count() - } -} - -impl LoggingHost for MockLogging { - fn log(&self, level: Level, message: &str) { - self.lines.borrow_mut().push(LogLine { - level, - message: message.to_string(), - }); - } -} - -// ---------------------------------------------------------------- tracing capture - -/// One tracing event captured pre-flattening. -#[derive(Clone, Debug, PartialEq)] -pub struct CapturedEvent { - /// Event severity. - pub level: Level, - /// Callsite target (module path by default). - pub target: String, - /// The `message` field; empty when the event carried none. - pub message: String, - /// Every non-message field, keyed by name. - pub fields: BTreeMap, -} - -/// A field value as tracing's `Visit` delivered it. -#[derive(Clone, Debug, PartialEq)] -pub enum FieldValue { - /// A `record_str` value. - Str(String), - /// A `record_u64` value. - U64(u64), - /// A `record_i64` value. - I64(i64), - /// A `record_bool` value. - Bool(bool), - /// A `record_debug` fallback (`?x`, `%x`, `f64`, ...), pre-rendered - /// with `{:?}`. - Debug(String), -} - -impl fmt::Display for FieldValue { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - FieldValue::Str(v) | FieldValue::Debug(v) => f.write_str(v), - FieldValue::U64(v) => write!(f, "{v}"), - FieldValue::I64(v) => write!(f, "{v}"), - FieldValue::Bool(v) => write!(f, "{v}"), - } - } -} - -impl CapturedEvent { - /// The value recorded for `name`, if the event carried it. - pub fn field(&self, name: &str) -> Option<&FieldValue> { - self.fields.get(name) - } - - /// Display-rendered field, for string comparisons. - pub fn field_str(&self, name: &str) -> Option { - self.fields.get(name).map(FieldValue::to_string) - } -} - -/// Events captured during [`capture_tracing`]. -pub struct CapturedEvents { - events: Arc>>, -} - -impl CapturedEvents { - /// Every captured event, in emission order. - pub fn events(&self) -> Vec { - self.events.lock().unwrap().clone() - } - - /// Whether no events were captured. - pub fn is_empty(&self) -> bool { - self.events.lock().unwrap().is_empty() - } - - /// Count of events at `level`. - pub fn count_at(&self, level: Level) -> usize { - self.events - .lock() - .unwrap() - .iter() - .filter(|e| e.level == level) - .count() - } - - /// Whether any captured event satisfies `pred`. - pub fn any(&self, pred: impl Fn(&CapturedEvent) -> bool) -> bool { - self.events.lock().unwrap().iter().any(pred) - } - - /// Exactly one matching event; panics with the full capture dump - /// otherwise. - pub fn expect_one(&self, pred: impl Fn(&CapturedEvent) -> bool) -> CapturedEvent { - let events = self.events.lock().unwrap(); - let matches: Vec<&CapturedEvent> = events.iter().filter(|e| pred(e)).collect(); - match matches.as_slice() { - [only] => (*only).clone(), - other => panic!( - "expected exactly one matching event, found {}; captured: {events:#?}", - other.len(), - ), - } - } -} - -type Buffer = Arc>>; - -std::thread_local! { - /// The capture buffer active on this thread, if any. `capture_tracing` - /// installs one for the duration of `f` and restores the prior slot on - /// return or unwind. - static ACTIVE_CAPTURE: RefCell> = const { RefCell::new(None) }; -} - -/// Restores the previous thread-local capture slot when a -/// `capture_tracing` call returns or unwinds. -struct CaptureGuard(Option); - -impl Drop for CaptureGuard { - fn drop(&mut self) { - ACTIVE_CAPTURE.with(|slot| *slot.borrow_mut() = self.0.take()); - } -} - -/// Events-only subscriber that records each event as a typed -/// [`CapturedEvent`] into the buffer active on the emitting thread, -/// dropping events when none is set. Spans are inert. -struct CaptureSubscriber { - next_id: AtomicU64, -} - -impl Subscriber for CaptureSubscriber { - fn enabled(&self, _metadata: &Metadata<'_>) -> bool { - true - } - - fn max_level_hint(&self) -> Option { - Some(LevelFilter::TRACE) - } - - fn new_span(&self, _span: &Attributes<'_>) -> Id { - // Spans are inert, but a valid non-zero id must be returned. - let raw = self.next_id.fetch_add(1, Ordering::Relaxed).wrapping_add(1); - Id::from_u64(raw.max(1)) - } - - fn record(&self, _span: &Id, _values: &Record<'_>) {} - - fn record_follows_from(&self, _span: &Id, _follows: &Id) {} - - fn event(&self, event: &Event<'_>) { - let mut visitor = FieldVisitor::default(); - event.record(&mut visitor); - let captured = CapturedEvent { - level: *event.metadata().level(), - target: event.metadata().target().to_owned(), - message: visitor.message, - fields: visitor.fields, - }; - ACTIVE_CAPTURE.with(|slot| { - if let Some(buffer) = slot.borrow().as_ref() { - buffer.lock().unwrap().push(captured); - } - }); - } - - fn enter(&self, _span: &Id) {} - - fn exit(&self, _span: &Id) {} -} - -/// Splits an event into its `message` field and a name-keyed map of the -/// rest, mirroring the facade's dispatch so captured values match the -/// rendered line field-for-field. -#[derive(Default)] -struct FieldVisitor { - message: String, - fields: BTreeMap, -} - -impl Visit for FieldVisitor { - fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { - if field.name() == "message" { - // tracing delivers `message` as the `format_args!` result, whose - // `Debug` renders unquoted; keep the raw text, do not re-quote it. - let _ = write!(self.message, "{value:?}"); - } else { - self.fields.insert( - field.name().to_owned(), - FieldValue::Debug(format!("{value:?}")), - ); - } - } - - fn record_str(&mut self, field: &Field, value: &str) { - if field.name() == "message" { - self.message.push_str(value); - } else { - self.fields - .insert(field.name().to_owned(), FieldValue::Str(value.to_owned())); - } - } - - fn record_u64(&mut self, field: &Field, value: u64) { - self.fields - .insert(field.name().to_owned(), FieldValue::U64(value)); - } - - fn record_i64(&mut self, field: &Field, value: i64) { - self.fields - .insert(field.name().to_owned(), FieldValue::I64(value)); - } - - fn record_bool(&mut self, field: &Field, value: bool) { - self.fields - .insert(field.name().to_owned(), FieldValue::Bool(value)); - } -} - -static INSTALL_ROUTING: std::sync::Once = std::sync::Once::new(); - -/// Run `f`, returning its value and every `tracing` event it emitted. -/// -/// Capture routes through a single process-global default subscriber -/// installed on first use, keyed to the emitting thread by a thread-local -/// buffer. A process-global default is required rather than a -/// `with_default` scoped one: `tracing` caches each callsite's `Interest` -/// the first time the callsite is hit, computed against whichever -/// dispatcher is current on that thread at that instant. Under parallel -/// tests a callsite exercised outside any capture (e.g. a sibling test -/// calling the same strategy function directly) registers against the -/// no-op default and is cached `never` for the rest of the process, -/// silently starving every later scoped capture of that event. Installing -/// the capture subscriber as the global default makes the cached interest -/// stable and capture independent of test scheduling. -pub fn capture_tracing(f: impl FnOnce() -> R) -> (R, CapturedEvents) { - INSTALL_ROUTING.call_once(|| { - let _ = tracing::subscriber::set_global_default(CaptureSubscriber { - next_id: AtomicU64::new(0), - }); - }); - - let events: Buffer = Arc::new(Mutex::new(Vec::new())); - let previous = ACTIVE_CAPTURE.with(|slot| slot.borrow_mut().replace(Arc::clone(&events))); - let _guard = CaptureGuard(previous); - let result = f(); - drop(_guard); - (result, CapturedEvents { events }) -} - -#[cfg(test)] -mod tests { - use nexum_sdk::prelude::U256; - - use super::*; - - #[test] - fn chain_records_calls_and_returns_programmed_response() { - let chain = MockChain::default(); - chain.respond_to("eth_blockNumber", "[]", Ok("\"0x1234\"".into())); - - assert_eq!( - chain.request(1, "eth_blockNumber", "[]").unwrap(), - "\"0x1234\"" - ); - assert_eq!(chain.call_count(), 1); - let last = chain.last_call().unwrap(); - assert_eq!(last.chain_id, 1); - assert_eq!(last.method, "eth_blockNumber"); - } - - #[test] - fn chain_unconfigured_method_returns_unsupported() { - let chain = MockChain::default(); - let err = chain.request(1, "eth_call", "[]").unwrap_err(); - let ChainError::Fault(Fault::Unsupported(msg)) = err else { - panic!("expected Unsupported fault, got {err:?}"); - }; - assert!(msg.contains("MockChain")); - assert_eq!(chain.call_count(), 1); - } - - #[test] - fn local_store_round_trips() { - let store = MockLocalStore::default(); - store.set("k", b"v").unwrap(); - assert_eq!(store.get("k").unwrap().as_deref(), Some(&b"v"[..])); - store.delete("k").unwrap(); - assert!(store.get("k").unwrap().is_none()); - } - - #[test] - fn local_store_list_keys_prefix_scan() { - let store = MockLocalStore::default(); - store.set("watch:a:1", b"").unwrap(); - store.set("watch:a:2", b"").unwrap(); - store.set("submitted:1", b"").unwrap(); - let keys = store.list_keys("watch:").unwrap(); - assert_eq!(keys, vec!["watch:a:1", "watch:a:2"]); - } - - #[test] - fn logging_captures_lines_and_filters_by_level() { - let log = MockLogging::default(); - log.log(Level::INFO, "hello"); - log.log(Level::WARN, "uh oh"); - log.log(Level::INFO, "still here"); - - assert_eq!(log.lines().len(), 3); - assert_eq!(log.count_at(Level::INFO), 2); - assert_eq!(log.count_at(Level::WARN), 1); - assert!(log.contains("uh oh")); - } - - #[test] - fn local_store_metadata_queries() { - let store = MockLocalStore::default(); - store.set("watch:a", b"abc").unwrap(); - store.set("watch:b", b"").unwrap(); - store.set("posted:1", b"x").unwrap(); - - assert!(store.contains("watch:a").unwrap()); - assert!(!store.contains("missing").unwrap()); - assert_eq!(LocalStoreHost::len(&store, "watch:a").unwrap(), Some(3)); - assert_eq!(LocalStoreHost::len(&store, "watch:b").unwrap(), Some(0)); - assert_eq!(LocalStoreHost::len(&store, "missing").unwrap(), None); - assert_eq!(store.count("watch:").unwrap(), 2); - assert_eq!(store.count("").unwrap(), 3); - - // Metadata queries stay namespace-scoped. - let other = store.namespaced("other"); - assert_eq!(other.count("").unwrap(), 0); - assert!(!other.contains("watch:a").unwrap()); - - // And respect fault injection. - store.fail_on("bad:", Fault::Internal("injected".into())); - assert!(store.contains("bad:k").is_err()); - assert!(LocalStoreHost::len(&store, "bad:k").is_err()); - assert!(store.count("bad:").is_err()); - } - - #[test] - fn local_store_error_injection() { - let store = MockLocalStore::default(); - store.fail_on("bad:", Fault::Internal("injected".into())); - // Non-matching keys work fine. - store.set("good:k", b"v").unwrap(); - assert_eq!(store.get("good:k").unwrap().as_deref(), Some(&b"v"[..])); - // Matching keys trigger the error. - assert!(store.set("bad:k", b"v").is_err()); - assert!(store.get("bad:k").is_err()); - assert!(store.delete("bad:k").is_err()); - assert!(store.list_keys("bad:").is_err()); - } - - #[test] - fn local_store_max_entries_enforced() { - let store = MockLocalStore::default(); - store.set_max_entries(2); - store.set("a", b"1").unwrap(); - store.set("b", b"2").unwrap(); - // Updating an existing key is OK even at the limit. - store.set("b", b"3").unwrap(); - // Adding a new key exceeds the limit. - let err = store.set("c", b"4").unwrap_err(); - assert!(matches!(err, Fault::Internal(ref m) if m.contains("max entries"))); - assert_eq!(store.len(), 2); - } - - #[test] - fn local_store_namespaces_isolate_identical_keys() { - let store = MockLocalStore::default(); - let other = store.namespaced("other-module"); - store.set("watch:a", b"mine").unwrap(); - other.set("watch:a", b"theirs").unwrap(); - - assert_eq!(store.get("watch:a").unwrap().as_deref(), Some(&b"mine"[..])); - assert_eq!( - other.get("watch:a").unwrap().as_deref(), - Some(&b"theirs"[..]), - ); - - // Scans, counts, and snapshots stay view-scoped. - assert_eq!(store.len(), 1); - assert_eq!(other.len(), 1); - assert_eq!(store.list_keys("").unwrap(), vec!["watch:a"]); - assert_eq!(store.snapshot().get("watch:a").unwrap(), b"mine"); - - // Deletes never reach across the namespace boundary. - other.delete("watch:a").unwrap(); - assert!(other.is_empty()); - assert_eq!(store.get("watch:a").unwrap().as_deref(), Some(&b"mine"[..])); - } - - #[test] - fn local_store_same_namespace_views_alias_the_same_rows() { - let store = MockLocalStore::default(); - let one = store.namespaced("mod"); - let two = store.namespaced("mod"); - one.set("k", b"v").unwrap(); - assert_eq!(two.get("k").unwrap().as_deref(), Some(&b"v"[..])); - } - - #[test] - #[should_panic(expected = "namespace must not be empty")] - fn local_store_empty_namespace_panics() { - let _ = MockLocalStore::default().namespaced(""); - } - - #[test] - fn local_store_entry_limit_spans_namespaces() { - let store = MockLocalStore::default(); - store.set_max_entries(2); - let other = store.namespaced("other-module"); - store.set("a", b"1").unwrap(); - other.set("b", b"2").unwrap(); - // The store is one shared file: a sibling namespace's rows - // consume the same headroom. - let err = store.set("c", b"3").unwrap_err(); - assert!(matches!(err, Fault::Internal(ref m) if m.contains("max entries"))); - } - - #[test] - fn local_store_byte_budget_enforced_and_released() { - let store = MockLocalStore::default(); - store.set_max_bytes(8); - store.set("abcd", b"1234").unwrap(); // 4 + 4 = 8, exactly at budget - let err = store.set("x", b"y").unwrap_err(); - assert!(matches!(err, Fault::Internal(ref m) if m.contains("max bytes"))); - - // A same-key overwrite releases the displaced value first. - store.set("abcd", b"12").unwrap(); - store.set("x", b"y").unwrap(); - - // Deleting releases the whole row's bytes. - store.delete("abcd").unwrap(); - store.set("ab", b"12").unwrap(); - assert_eq!(store.len(), 2); - } - - #[test] - fn identity_roster_and_programmed_outcome() { - let identity = MockIdentity::default(); - let account = Address::from([0xAA; 20]); - assert!(identity.accounts().unwrap().is_empty()); - identity.add_account(account); - assert_eq!(identity.accounts().unwrap(), vec![account]); - - // No outcome programmed: signing is unsupported, the stub posture. - let err = identity.sign(account, b"hello").unwrap_err(); - assert!(matches!(err, Fault::Unsupported(ref m) if m.contains("MockIdentity"))); - - let signature = Signature::new(U256::from(1), U256::from(2), false); - identity.respond(Ok(signature)); - assert_eq!(identity.sign(account, b"hello").unwrap(), signature); - assert_eq!(identity.sign_typed_data(account, "{}").unwrap(), signature); - - assert_eq!(identity.call_count(), 3); - assert_eq!( - identity.last_call().unwrap(), - SignCall { - account, - payload: SignPayload::TypedData("{}".to_owned()), - }, - ); - } - - #[test] - fn identity_denies_off_roster_accounts() { - let identity = MockIdentity::default(); - identity.respond(Ok(Signature::new(U256::from(1), U256::from(2), true))); - let err = identity.sign(Address::from([0xBB; 20]), b"x").unwrap_err(); - assert!(matches!(err, Fault::Denied(_))); - // The refused call is still recorded. - assert_eq!(identity.call_count(), 1); - } - - #[test] - fn messaging_records_publishes_and_answers_from_seeds() { - let messaging = MockMessaging::default(); - messaging.seed_payload("/acme/1/orders/proto", b"one".to_vec(), 10); - messaging.seed_payload("/acme/1/orders/proto", b"two".to_vec(), 20); - messaging.seed_payload("/acme/1/other/proto", b"noise".to_vec(), 15); - - messaging.publish("/acme/1/orders/proto", b"out").unwrap(); - assert_eq!(messaging.publish_count(), 1); - assert_eq!( - messaging.last_published().unwrap(), - PublishRecord { - content_topic: "/acme/1/orders/proto".to_owned(), - payload: b"out".to_vec(), - }, - ); - - // Publishes never leak into query results. - let all = messaging - .query("/acme/1/orders/proto", None, None, None) - .unwrap(); - assert_eq!(all.len(), 2); - assert_eq!(all[0].payload, b"one"); - assert_eq!(all[1].payload, b"two"); - } - - #[test] - fn messaging_query_applies_bounds_and_limit() { - let messaging = MockMessaging::default(); - for (payload, ts) in [(b"a", 10u64), (b"b", 20), (b"c", 30), (b"d", 40)] { - messaging.seed_payload("/t", payload.to_vec(), ts); - } - - let window = messaging.query("/t", Some(20), Some(30), None).unwrap(); - assert_eq!(window.len(), 2); - assert_eq!(window[0].payload, b"b"); - - // A limit keeps the newest matches: the tail of the window. - let limited = messaging.query("/t", None, None, Some(2)).unwrap(); - assert_eq!(limited.len(), 2); - assert_eq!(limited[0].payload, b"c"); - assert_eq!(limited[1].payload, b"d"); - } - - #[test] - fn messaging_scope_denies_off_grant_topics() { - let messaging = MockMessaging::default(); - messaging.scope_topics(["/acme/1/orders/proto"]); - - messaging.publish("/acme/1/orders/proto", b"ok").unwrap(); - let err = messaging.publish("/other", b"no").unwrap_err(); - assert!(matches!(err, Fault::Denied(_))); - let err = messaging.query("/other", None, None, None).unwrap_err(); - assert!(matches!(err, Fault::Denied(_))); - // The refused publish was never recorded. - assert_eq!(messaging.publish_count(), 1); - } - - #[test] - fn messaging_scope_matches_the_host_grant() { - // A prefix grant admits the family beneath it, bounded at `/`. - let messaging = MockMessaging::default(); - messaging.scope_topics(["/nexum/1/"]); - messaging - .publish("/nexum/1/acme-orders/proto", b"x") - .unwrap(); - messaging.publish("/nexum/1/twap/proto", b"x").unwrap(); - let err = messaging.publish("/nexum/2/acme/proto", b"x").unwrap_err(); - assert!(matches!(err, Fault::Denied(_))); - - // No trailing slash still bounds on the separator: a grant never - // leaks into a longer sibling segment. - let messaging = MockMessaging::default(); - messaging.scope_topics(["/nexum/1/acme"]); - messaging.publish("/nexum/1/acme", b"x").unwrap(); - messaging.publish("/nexum/1/acme/orders", b"x").unwrap(); - let err = messaging - .publish("/nexum/1/acme-orders/proto", b"x") - .unwrap_err(); - assert!(matches!(err, Fault::Denied(_))); - - // An empty grant is unscoped, the host's module default. - let messaging = MockMessaging::default(); - messaging.scope_topics(Vec::::new()); - messaging.publish("/anywhere/at/all", b"x").unwrap(); - } - - #[test] - fn messaging_fault_injection_fires_by_prefix() { - let messaging = MockMessaging::default(); - messaging.fail_on("/flaky", Fault::Timeout); - assert!(matches!( - messaging.publish("/flaky/topic", b"x").unwrap_err(), - Fault::Timeout, - )); - messaging.publish("/steady", b"x").unwrap(); - } - - #[test] - fn remote_store_round_trips_content_addressed_blobs() { - let store = MockRemoteStore::default(); - let reference = store.upload(b"chunk").unwrap(); - assert_eq!(reference, keccak256(b"chunk")); - assert_eq!(store.download(reference).unwrap(), b"chunk"); - assert_eq!(store.blob_count(), 1); - - let missing = store.download(B256::from([0xCC; 32])).unwrap_err(); - assert!(matches!(missing, Fault::Unavailable(ref m) if m.contains("MockRemoteStore"))); - } - - #[test] - fn remote_store_feeds_are_owner_scoped() { - let store = MockRemoteStore::default(); - let owner = Address::from([0xAA; 20]); - let topic = B256::from([0x11; 32]); - - // Writes land under the mock's own owner and stay downloadable. - store.set_owner(owner); - let reference = store.write_feed(topic, b"v1").unwrap(); - assert_eq!(store.download(reference).unwrap(), b"v1"); - assert_eq!( - store.read_feed(owner, topic).unwrap().as_deref(), - Some(&b"v1"[..]) - ); - - // Another owner's feed is a distinct slot. - let other = Address::from([0xBB; 20]); - assert_eq!(store.read_feed(other, topic).unwrap(), None); - store.seed_feed(other, topic, b"theirs"); - assert_eq!( - store.read_feed(other, topic).unwrap().as_deref(), - Some(&b"theirs"[..]), - ); - } - - #[test] - fn remote_store_fault_injection_covers_every_operation() { - let store = MockRemoteStore::default(); - store.fail_with(Fault::Timeout); - assert!(matches!(store.upload(b"x").unwrap_err(), Fault::Timeout)); - assert!(matches!( - store.download(B256::ZERO).unwrap_err(), - Fault::Timeout, - )); - assert!(matches!( - store.read_feed(Address::ZERO, B256::ZERO).unwrap_err(), - Fault::Timeout, - )); - assert!(matches!( - store.write_feed(B256::ZERO, b"x").unwrap_err(), - Fault::Timeout, - )); - } - - #[test] - fn mock_host_dispatches_through_supertrait() { - let host = MockHost::new(); - host.chain - .respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); - host.messaging.seed_payload("/t", b"m".to_vec(), 1); - - // Through the `Host` supertrait: all six seams on one value. - let _: &dyn nexum_sdk::host::Host = &host; - host.set("key", b"val").unwrap(); - assert_eq!(host.get("key").unwrap().as_deref(), Some(&b"val"[..])); - assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); - assert!(host.accounts().unwrap().is_empty()); - assert_eq!(host.query("/t", None, None, None).unwrap().len(), 1); - let reference = host.upload(b"blob").unwrap(); - assert_eq!(host.download(reference).unwrap(), b"blob"); - host.log(Level::INFO, "happy path"); - - assert_eq!(host.chain.call_count(), 1); - assert_eq!(host.logging.lines().len(), 1); - assert_eq!(host.store.len(), 1); - assert_eq!(host.remote_store.blob_count(), 1); - } - - #[test] - fn capture_message_only_event_has_empty_fields() { - let (_, logs) = capture_tracing(|| tracing::info!("hello")); - let events = logs.events(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].level, Level::INFO); - assert_eq!(events[0].message, "hello"); - assert!(events[0].fields.is_empty()); - } - - #[test] - fn capture_fields_land_as_typed_values() { - let (_, logs) = capture_tracing(|| { - tracing::warn!( - name = "eth", - count = 7u64, - signed = -3i64, - ready = true, - answer = ?Some(9), - "changed", - ); - }); - let ev = logs.expect_one(|e| e.level == Level::WARN); - assert_eq!(ev.message, "changed"); - assert_eq!(ev.field("name"), Some(&FieldValue::Str("eth".to_owned()))); - assert_eq!(ev.field("count"), Some(&FieldValue::U64(7))); - assert_eq!(ev.field("signed"), Some(&FieldValue::I64(-3))); - assert_eq!(ev.field("ready"), Some(&FieldValue::Bool(true))); - assert_eq!( - ev.field("answer"), - Some(&FieldValue::Debug("Some(9)".to_owned())), - ); - } - - #[test] - fn capture_display_recorded_value_lands_as_debug() { - let (_, logs) = capture_tracing(|| tracing::info!(x = %42u32, "shown")); - let ev = logs.expect_one(|e| e.message == "shown"); - assert!(matches!(ev.field("x"), Some(FieldValue::Debug(_)))); - assert_eq!(ev.field_str("x").as_deref(), Some("42")); - } - - #[test] - fn events_outside_capture_are_dropped() { - // Prime the global default via one capture, then emit outside any. - let (_, _) = capture_tracing(|| tracing::info!("primed")); - tracing::info!("orphan"); - let (_, logs) = capture_tracing(|| tracing::info!("inside")); - let events = logs.events(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].message, "inside"); - } - - #[test] - fn concurrent_captures_are_thread_isolated() { - use std::sync::Barrier; - let barrier = Arc::new(Barrier::new(2)); - let other = Arc::clone(&barrier); - let handle = std::thread::spawn(move || { - let (_, logs) = capture_tracing(|| { - other.wait(); - tracing::info!("thread-one"); - }); - logs.events() - }); - let (_, main_logs) = capture_tracing(|| { - barrier.wait(); - tracing::info!("thread-two"); - }); - let thread_events = handle.join().unwrap(); - - assert_eq!(main_logs.events().len(), 1); - assert_eq!(main_logs.events()[0].message, "thread-two"); - assert_eq!(thread_events.len(), 1); - assert_eq!(thread_events[0].message, "thread-one"); - } -} diff --git a/nexum/crates/nexum-sdk/Cargo.toml b/nexum/crates/nexum-sdk/Cargo.toml deleted file mode 100644 index 445c894a..00000000 --- a/nexum/crates/nexum-sdk/Cargo.toml +++ /dev/null @@ -1,73 +0,0 @@ -[package] -name = "nexum-sdk" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Guest-side SDK for nexum runtime modules: host-neutral helpers usable by any module, independent of the CoW domain layer." - -[lib] -# Plain library - modules link this and emit their own cdylib for the -# WASM Component. Building on the host target is also supported so the -# helpers are unit-testable without a wasm toolchain. - -[features] -# Standalone-debugging opt-in: also echo SDK tracing events to stderr. -# Off by default so the host logging interface stays the single source -# of SDK events; enabling it under the real host would duplicate every -# line through the host's own stdio capture. -stderr-echo = [] - -[dependencies] -# Re-exported as `nexum_sdk::module`; the proc-macro emits glue that -# calls back into this crate (`bind_host_via_wit_bindgen!`, the host -# trait seam, the tracing facade). -nexum-module-macros = { path = "../nexum-module-macros" } -# Decoder for the opaque status body an `intent-status` event carries; -# re-exported as `nexum_sdk::status_body`. -nexum-status-body = { path = "../nexum-status-body" } -alloy-primitives.workspace = true -# The `Log` type modules receive for chain-log events is alloy's own RPC log, -# assembled from the WIT record at the binding edge (see `events`). -alloy-rpc-types-eth.workspace = true -alloy-sol-types.workspace = true -# The provider seam: `HostTransport` speaks alloy's JSON-RPC packet -# vocabulary over `ChainHost::request`, and `provider()` fronts it with -# a `RootProvider`. Featureless, so no ws/ipc/reqwest transport reaches -# the wasm guest. -alloy-json-rpc.workspace = true -alloy-provider.workspace = true -alloy-rpc-client.workspace = true -alloy-transport.workspace = true -tower.workspace = true -# Standard HTTP request/response/method vocabulary; the SDK adds only -# the wasi:http-specific `fetch` seam on top. wstd re-exports the same -# `http` types, so a request passes through to the client unconverted. -http.workspace = true -# `raw_value` backs the transport's pass-through of host JSON into -# alloy's `Box` payload slots. -serde_json = { workspace = true, features = ["std", "raw_value"] } -strum.workspace = true -thiserror.workspace = true -# `tracing-core` backs the guest facade's subscriber plumbing; the -# `tracing` macros are what the SDK's own code emits through it (e.g. -# the keeper gate-corruption warning). -tracing.workspace = true -tracing-core.workspace = true - -[dev-dependencies] -proptest.workspace = true -# Dev-dependencies are excluded from Cargo's dependency-cycle check, so the -# nexum-sdk <- nexum-sdk-test dev-dep is a normal, supported arrangement: the -# keeper stores acceptance-test against the composed world-neutral MockHost. -# The keeper never touches the orderbook, so a CoW-layer mock would only drag -# the domain crates into this crate's dev graph. -nexum-sdk-test = { path = "../nexum-sdk-test" } -# Pins the strum-derived fault labels to the single-source vocabulary. -nexum-world = { path = "../nexum-world" } - -# The wasi:http client only links on the wasm guest target; host-side -# consumers (tests, backtest tooling) compile the `http` module's types -# without it. -[target.'cfg(all(target_arch = "wasm32", target_os = "wasi"))'.dependencies] -wstd.workspace = true diff --git a/nexum/crates/nexum-sdk/src/address.rs b/nexum/crates/nexum-sdk/src/address.rs deleted file mode 100644 index 2106e4a5..00000000 --- a/nexum/crates/nexum-sdk/src/address.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! EVM address parsing helpers. -//! -//! Multiple Shepherd modules need to read a `[config]` value such as -//! `addresses = "0xabc..., 0xdef..."` and surface a typed error when -//! one of the entries is malformed; the offline backtest harness -//! parses single `0x...` strings out of fixture JSON. Each module -//! previously rolled its own `AddressListParseError` / -//! `AddressParseError`. The shapes were near-identical; the audit -//! pass consolidates them here so future modules pick up the same -//! `Display` wording (operator-facing log strings stay stable) and -//! the same `#[non_exhaustive]` evolution guarantee. -//! -//! The list parser stays deliberately permissive about whitespace + -//! empty trailing segments to match the wording operators have grown -//! used to (a literal trailing comma in `engine.toml` should not -//! error). - -use alloy_primitives::Address; - -/// Typed errors returned by [`parse_address_list`] and -/// [`parse_address`]. Replaces the `Result<_, String>` and -/// per-module `AddressListParseError` / `AddressParseError` shapes -/// that previously lived in each strategy crate (rubric prohibits -/// stringly-typed errors). -/// -/// The Display impls preserve the exact wording the previous -/// formatters produced so any operator-facing log strings remain -/// stable across the JC5 consolidation. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum AddressParse { - /// One of the comma-separated entries failed to parse as an - /// EVM address, or a single-address input failed to parse. For - /// the single-address case the `index` is always `0`. - #[error("address #{index} ({raw:?}): {message}")] - InvalidAddress { - /// Zero-based position of the offending entry in the - /// comma-separated list (`0` for single-address parses). - index: usize, - /// The trimmed source string that failed to parse. - raw: String, - /// Human-readable parse-error detail from - /// `
::Err`. - message: String, - }, - /// The whole list was empty (or contained only whitespace + - /// empty segments). Only emitted by [`parse_address_list`]. - #[error("expected at least one address")] - Empty, -} - -/// Parse a comma-separated address list, stripping whitespace and -/// skipping empty segments (so a trailing `,` is not an error). -/// -/// Returns [`AddressParse::Empty`] if the input contains no -/// non-whitespace segment and [`AddressParse::InvalidAddress`] on -/// the first entry that does not parse as an EVM address. The -/// `index` reflects the zero-based position in the original -/// comma-separated list (i.e. it counts skipped empties), which -/// matches the wording the per-module errors used to surface. -pub fn parse_address_list(raw: &str) -> Result, AddressParse> { - let mut out = Vec::new(); - for (i, part) in raw.split(',').enumerate() { - let trimmed = part.trim(); - if trimmed.is_empty() { - continue; - } - let addr = trimmed - .parse::
() - .map_err(|e| AddressParse::InvalidAddress { - index: i, - raw: trimmed.to_owned(), - message: e.to_string(), - })?; - out.push(addr); - } - if out.is_empty() { - return Err(AddressParse::Empty); - } - Ok(out) -} - -/// Parse a single `0x...` (or bare-hex) address string into a -/// typed [`Address`]. Trims surrounding whitespace before -/// delegating to `
`; failures surface as -/// [`AddressParse::InvalidAddress`] with `index = 0`. -pub fn parse_address(raw: &str) -> Result { - let trimmed = raw.trim(); - trimmed - .parse::
() - .map_err(|e| AddressParse::InvalidAddress { - index: 0, - raw: trimmed.to_owned(), - message: e.to_string(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_primitives::address; - - #[test] - fn handles_whitespace_and_multiple() { - let raw = " 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 ,\ - 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; - let parsed = parse_address_list(raw).unwrap(); - assert_eq!(parsed.len(), 2); - assert_eq!( - parsed[0], - address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"), - ); - } - - #[test] - fn skips_empty_segments() { - let parsed = parse_address_list("0x70997970C51812dc3A010C7d01b50e0d17dc79C8,,").unwrap(); - assert_eq!(parsed.len(), 1); - } - - #[test] - fn rejects_empty_list() { - assert!(matches!(parse_address_list(""), Err(AddressParse::Empty))); - assert!(matches!( - parse_address_list(", ,"), - Err(AddressParse::Empty) - )); - } - - #[test] - fn rejects_malformed_entry() { - match parse_address_list("not-an-address") { - Err(AddressParse::InvalidAddress { index, raw, .. }) => { - assert_eq!(index, 0); - assert_eq!(raw, "not-an-address"); - } - other => panic!("expected InvalidAddress, got {other:?}"), - } - } - - #[test] - fn parse_address_accepts_canonical() { - let parsed = parse_address(" 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 ").unwrap(); - assert_eq!(parsed, address!("70997970C51812dc3A010C7d01b50e0d17dc79C8")); - } - - #[test] - fn parse_address_rejects_wrong_length() { - match parse_address("0xdeadbeef") { - Err(AddressParse::InvalidAddress { index, raw, .. }) => { - assert_eq!(index, 0); - assert_eq!(raw, "0xdeadbeef"); - } - other => panic!("expected InvalidAddress, got {other:?}"), - } - } -} diff --git a/nexum/crates/nexum-sdk/src/chain/chainlink.rs b/nexum/crates/nexum-sdk/src/chain/chainlink.rs deleted file mode 100644 index 57c49b70..00000000 --- a/nexum/crates/nexum-sdk/src/chain/chainlink.rs +++ /dev/null @@ -1,197 +0,0 @@ -//! Chainlink Aggregator V3 reader. -//! -//! [`read_latest_answer`] performs the full `eth_call → decode → -//! latestRoundData.answer` flow against a Chainlink AggregatorV3 -//! oracle. Returns `Some(answer)` on success or `None` on any host / -//! decode failure (logging the failure at Warn). Used by oracle-driven -//! example modules (price-alert, stop-loss) so they consume the SDK -//! instead of redefining the `AggregatorV3` ABI + read loop locally. -//! -//! The shape is deliberately `Option` rather than -//! `Result`: every observed caller treats a fetch -//! failure as "skip this block, try next one", and `Option` makes -//! that the only path without forcing a discard pattern at the call -//! site. - -use alloy_primitives::{Address, I256}; -use alloy_sol_types::{SolCall, sol}; - -use crate::Level; -use crate::chain::{eth_call_params, parse_eth_call_result}; -use crate::host::{ChainHost, LoggingHost}; - -sol! { - /// Chainlink AggregatorV3Interface - only the function the - /// SDK needs. - interface AggregatorV3 { - function latestRoundData() external view returns ( - uint80 roundId, - int256 answer, - uint256 startedAt, - uint256 updatedAt, - uint80 answeredInRound - ); - } -} - -/// Fetch the oracle's latest answer via `eth_call(latestRoundData)`. -/// -/// Returns `Some(answer)` on success. Logs a Warn (prefixed with -/// `domain`) and returns `None` on any of: -/// -/// - `host.request("eth_call", …)` returning `Err(ChainError)`; -/// - the JSON-RPC result not parsing as `0x`-prefixed hex bytes; -/// - the ABI decode failing. -/// -/// `domain` is embedded in the log line so a single host log stream -/// can disambiguate which module's oracle failed. -// Bounded on the two capabilities it exercises (chain + logging), not -// the full `Host` supertrait, so modules whose worlds omit local-store -// can still call it. -#[must_use] -pub fn read_latest_answer( - host: &H, - chain_id: u64, - oracle: Address, - domain: &str, -) -> Option { - let call_data = AggregatorV3::latestRoundDataCall {}.abi_encode(); - let params = eth_call_params(&oracle, &call_data); - let result_json = match host.request(chain_id, "eth_call", ¶ms) { - Ok(s) => s, - Err(err) => { - host.log( - Level::WARN, - &format!("{domain}: chainlink oracle eth_call failed: {err}"), - ); - return None; - } - }; - let bytes = match parse_eth_call_result(&result_json) { - Some(b) => b, - None => { - host.log( - Level::WARN, - &format!("{domain}: chainlink oracle: cannot decode result hex {result_json}"), - ); - return None; - } - }; - match AggregatorV3::latestRoundDataCall::abi_decode_returns(&bytes) { - Ok(decoded) => Some(decoded.answer), - Err(e) => { - host.log( - Level::WARN, - &format!("{domain}: chainlink oracle decode failed: {e}"), - ); - None - } - } -} - -#[cfg(test)] -mod tests { - //! `MockHost`-driven coverage of the read path. Encodes a synthetic - //! `latestRoundData` return into the `"0x..."` JSON the - //! `chain::request` mock returns, then asserts the helper - //! extracts the `answer` field. - - use super::*; - use crate::host::{ChainError, Fault}; - - // We need `nexum-sdk-test::MockHost` for these tests, but - // `nexum-sdk` cannot depend on `nexum-sdk-test` (it's the - // reverse). So we hand-roll a minimal Host impl here. - - struct StubHost { - response: std::cell::RefCell>, - log_lines: std::cell::RefCell>, - } - - impl StubHost { - fn new(response: R) -> Self { - Self { - response: std::cell::RefCell::new(Some(response)), - log_lines: std::cell::RefCell::new(Vec::new()), - } - } - } - - impl crate::host::LoggingHost for StubHost> { - fn log(&self, _level: Level, message: &str) { - self.log_lines.borrow_mut().push(message.to_owned()); - } - } - impl crate::host::ChainHost for StubHost> { - fn request( - &self, - _chain_id: u64, - _method: &str, - _params: &str, - ) -> Result { - self.response - .borrow_mut() - .take() - .expect("StubHost::request called more than once") - } - } - impl crate::host::LocalStoreHost for StubHost> { - fn get(&self, _key: &str) -> Result>, crate::host::Fault> { - unreachable!("not used in this test") - } - fn set(&self, _key: &str, _value: &[u8]) -> Result<(), crate::host::Fault> { - unreachable!("not used in this test") - } - fn delete(&self, _key: &str) -> Result<(), crate::host::Fault> { - unreachable!("not used in this test") - } - fn list_keys(&self, _prefix: &str) -> Result, crate::host::Fault> { - unreachable!("not used in this test") - } - } - fn encode_round(answer: i64) -> String { - let returns = AggregatorV3::latestRoundDataReturn { - roundId: alloy_primitives::aliases::U80::from(1u64), - answer: I256::try_from(answer).unwrap(), - startedAt: alloy_primitives::U256::from(0u64), - updatedAt: alloy_primitives::U256::from(0u64), - answeredInRound: alloy_primitives::aliases::U80::from(1u64), - }; - let bytes = AggregatorV3::latestRoundDataCall::abi_encode_returns(&returns); - format!("\"0x{}\"", alloy_primitives::hex::encode(&bytes)) - } - - const ORACLE: Address = alloy_primitives::address!("694AA1769357215DE4FAC081bf1f309aDC325306"); - - #[test] - fn returns_some_on_happy_path() { - let host = StubHost::new(Ok(encode_round(1_700_000_000_000))); - let v = read_latest_answer(&host, 11_155_111, ORACLE, "test-domain"); - assert_eq!(v, Some(I256::try_from(1_700_000_000_000_i64).unwrap())); - } - - #[test] - fn returns_none_and_logs_on_fault() { - let host = StubHost::new(Err(ChainError::Fault(Fault::Unavailable( - "rpc down".into(), - )))); - let v = read_latest_answer(&host, 11_155_111, ORACLE, "my-mod"); - assert!(v.is_none()); - let logs = host.log_lines.borrow(); - assert!(logs.iter().any(|l| l.contains("my-mod"))); - assert!(logs.iter().any(|l| l.contains("eth_call failed"))); - } - - #[test] - fn returns_none_on_garbage_hex() { - let host = StubHost::new(Ok("\"not-hex\"".to_owned())); - let v = read_latest_answer(&host, 11_155_111, ORACLE, "my-mod"); - assert!(v.is_none()); - assert!( - host.log_lines - .borrow() - .iter() - .any(|l| l.contains("cannot decode result hex")) - ); - } -} diff --git a/nexum/crates/nexum-sdk/src/chain/eth_call.rs b/nexum/crates/nexum-sdk/src/chain/eth_call.rs deleted file mode 100644 index 36dd41c1..00000000 --- a/nexum/crates/nexum-sdk/src/chain/eth_call.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! `eth_call` JSON helpers. - -use alloy_primitives::Address; - -/// Build the JSON params array for `eth_call`: `[{to, data}, "latest"]`. -/// -/// Returned as a `String` rather than `serde_json::Value` so the caller -/// can hand it straight to `chain::request(chain_id, "eth_call", &p)` -/// without re-serialising. -/// -/// # Example -/// -/// ``` -/// use nexum_sdk::chain::eth_call_params; -/// use nexum_sdk::prelude::Address; -/// -/// let to: Address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" -/// .parse() -/// .unwrap(); -/// let selector = [0xaa, 0xbb, 0xcc, 0xdd]; // 4-byte function selector -/// let params = eth_call_params(&to, &selector); -/// -/// assert!(params.contains("\"to\":\"0xfdafc9d1902f4e0b84f65f49f244b32b31013b74\"")); -/// assert!(params.contains("\"data\":\"0xaabbccdd\"")); -/// assert!(params.contains("\"latest\"")); -/// ``` -pub fn eth_call_params(to: &Address, data: &[u8]) -> String { - // Both fields are hex, which never needs JSON escaping, so the - // array is written directly instead of via a serde_json DOM. - let data_hex = alloy_primitives::hex::encode_prefixed(data); - format!(r#"[{{"to":"{to:#x}","data":"{data_hex}"}},"latest"]"#) -} - -/// Parse the raw JSON-RPC `result` field a host's `chain::request` -/// returns for an `eth_call`. The value is a JSON string holding hex -/// like `"0x1234..."`; strip the JSON quotes, strip the `0x` prefix, -/// and hex-decode. Returns `None` on shape mismatch. -/// -/// # Example -/// -/// ``` -/// use nexum_sdk::chain::parse_eth_call_result; -/// -/// // What the host typically returns for an eth_call result: a JSON -/// // string holding 0x-prefixed hex. -/// let raw = r#""0xdeadbeef""#; -/// assert_eq!( -/// parse_eth_call_result(raw), -/// Some(vec![0xde, 0xad, 0xbe, 0xef]), -/// ); -/// -/// // Shape mismatch (not JSON-quoted) -> None. -/// assert_eq!(parse_eth_call_result("not json"), None); -/// ``` -#[must_use] -pub fn parse_eth_call_result(result_json: &str) -> Option> { - // Borrowed deserialization: valid hex payloads never contain JSON - // escapes, and an escaped string would fail the hex decode anyway. - let s = serde_json::from_str::<&str>(result_json).ok()?; - let hex = s.strip_prefix("0x").unwrap_or(s); - alloy_primitives::hex::decode(hex).ok() -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_primitives::{address, hex}; - - #[test] - fn eth_call_params_shape() { - let to = address!("fdaFc9d1902f4e0b84f65F49f244b32b31013b74"); - let data = hex!("aabbcc").to_vec(); - let p = eth_call_params(&to, &data); - let parsed: serde_json::Value = serde_json::from_str(&p).unwrap(); - assert_eq!( - parsed[0]["to"], - "0xfdafc9d1902f4e0b84f65f49f244b32b31013b74" - ); - assert_eq!(parsed[0]["data"], "0xaabbcc"); - assert_eq!(parsed[1], "latest"); - } - - #[test] - fn parse_eth_call_result_decodes_hex_string() { - assert_eq!( - parse_eth_call_result(r#""0xdeadbeef""#), - Some(vec![0xde, 0xad, 0xbe, 0xef]), - ); - } - - #[test] - fn parse_eth_call_result_handles_empty_hex() { - assert_eq!(parse_eth_call_result(r#""0x""#), Some(vec![])); - } - - #[test] - fn parse_eth_call_result_rejects_non_json() { - assert_eq!(parse_eth_call_result("garbage"), None); - } -} diff --git a/nexum/crates/nexum-sdk/src/chain/id.rs b/nexum/crates/nexum-sdk/src/chain/id.rs deleted file mode 100644 index 598857c3..00000000 --- a/nexum/crates/nexum-sdk/src/chain/id.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! Zero-cost chain identity newtypes. - -use core::fmt; - -/// EIP-155 chain id, typed so a bare `u64` never crosses an SDK API. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct ChainId(u64); - -impl ChainId { - /// Wrap a raw EIP-155 id. - pub const fn new(id: u64) -> Self { - Self(id) - } - - /// The raw id, for the WIT edge. - pub const fn get(self) -> u64 { - self.0 - } -} - -impl From for ChainId { - fn from(id: u64) -> Self { - Self::new(id) - } -} - -impl From for u64 { - fn from(id: ChainId) -> Self { - id.get() - } -} - -impl fmt::Display for ChainId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} - -/// A chain a strategy targets, keyed by its [`ChainId`]. The type the -/// provider seam takes; events deliver a raw id, so `ev.chain_id.into()` -/// bridges at the handler edge. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Chain(ChainId); - -impl Chain { - /// Ethereum mainnet. - pub const MAINNET: Self = Self::from_id(1); - /// Gnosis Chain. - pub const GNOSIS: Self = Self::from_id(100); - /// Base. - pub const BASE: Self = Self::from_id(8_453); - /// Arbitrum One. - pub const ARBITRUM: Self = Self::from_id(42_161); - /// Sepolia testnet. - pub const SEPOLIA: Self = Self::from_id(11_155_111); - - /// Chain with the given raw EIP-155 id. - pub const fn from_id(id: u64) -> Self { - Self(ChainId::new(id)) - } - - /// The chain's id. - pub const fn id(self) -> ChainId { - self.0 - } -} - -impl From for Chain { - fn from(id: u64) -> Self { - Self::from_id(id) - } -} - -impl From for Chain { - fn from(id: ChainId) -> Self { - Self(id) - } -} - -impl From for ChainId { - fn from(chain: Chain) -> Self { - chain.id() - } -} - -impl From for u64 { - fn from(chain: Chain) -> Self { - chain.id().get() - } -} - -impl fmt::Display for Chain { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} - -#[cfg(test)] -mod tests { - use super::{Chain, ChainId}; - - #[test] - fn ids_round_trip() { - assert_eq!(u64::from(ChainId::new(100)), 100); - assert_eq!(ChainId::from(7u64).get(), 7); - assert_eq!(u64::from(Chain::from_id(42)), 42); - assert_eq!(Chain::from(ChainId::new(1)), Chain::MAINNET); - assert_eq!(ChainId::from(Chain::SEPOLIA).get(), 11_155_111); - } - - #[test] - fn named_chains_carry_canonical_ids() { - assert_eq!(u64::from(Chain::MAINNET), 1); - assert_eq!(u64::from(Chain::GNOSIS), 100); - assert_eq!(u64::from(Chain::BASE), 8_453); - assert_eq!(u64::from(Chain::ARBITRUM), 42_161); - assert_eq!(u64::from(Chain::SEPOLIA), 11_155_111); - } - - #[test] - fn display_is_the_raw_id() { - assert_eq!(Chain::GNOSIS.to_string(), "100"); - assert_eq!(ChainId::new(1).to_string(), "1"); - } -} diff --git a/nexum/crates/nexum-sdk/src/chain/method.rs b/nexum/crates/nexum-sdk/src/chain/method.rs deleted file mode 100644 index 58ecfae6..00000000 --- a/nexum/crates/nexum-sdk/src/chain/method.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! The typed JSON-RPC method surface, guest side. - -use strum::{EnumString, IntoStaticStr}; - -/// The permitted JSON-RPC read surface as a closed type, mirroring the -/// runtime's `ChainMethod` case for case. Signing and mutating methods -/// have no variant, so they cannot be represented and never cross the -/// WIT edge; [`HostTransport`](super::HostTransport) rejects anything -/// outside this set before calling the host. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, IntoStaticStr)] -#[non_exhaustive] -pub enum ChainMethod { - /// `eth_blockNumber`. - #[strum(serialize = "eth_blockNumber")] - EthBlockNumber, - /// `eth_call`. - #[strum(serialize = "eth_call")] - EthCall, - /// `eth_chainId`. - #[strum(serialize = "eth_chainId")] - EthChainId, - /// `eth_estimateGas`. - #[strum(serialize = "eth_estimateGas")] - EthEstimateGas, - /// `eth_feeHistory`. - #[strum(serialize = "eth_feeHistory")] - EthFeeHistory, - /// `eth_gasPrice`. - #[strum(serialize = "eth_gasPrice")] - EthGasPrice, - /// `eth_maxPriorityFeePerGas`. - #[strum(serialize = "eth_maxPriorityFeePerGas")] - EthMaxPriorityFeePerGas, - /// `eth_getBalance`. - #[strum(serialize = "eth_getBalance")] - EthGetBalance, - /// `eth_getBlockByHash`. - #[strum(serialize = "eth_getBlockByHash")] - EthGetBlockByHash, - /// `eth_getBlockByNumber`. - #[strum(serialize = "eth_getBlockByNumber")] - EthGetBlockByNumber, - /// `eth_getBlockReceipts`. - #[strum(serialize = "eth_getBlockReceipts")] - EthGetBlockReceipts, - /// `eth_getCode`. - #[strum(serialize = "eth_getCode")] - EthGetCode, - /// `eth_getLogs`. - #[strum(serialize = "eth_getLogs")] - EthGetLogs, - /// `eth_getProof`. - #[strum(serialize = "eth_getProof")] - EthGetProof, - /// `eth_getStorageAt`. - #[strum(serialize = "eth_getStorageAt")] - EthGetStorageAt, - /// `eth_getTransactionByHash`. - #[strum(serialize = "eth_getTransactionByHash")] - EthGetTransactionByHash, - /// `eth_getTransactionCount`. - #[strum(serialize = "eth_getTransactionCount")] - EthGetTransactionCount, - /// `eth_getTransactionReceipt`. - #[strum(serialize = "eth_getTransactionReceipt")] - EthGetTransactionReceipt, - /// `net_version`. - #[strum(serialize = "net_version")] - NetVersion, -} - -impl ChainMethod { - /// The wire method name. `&'static` because the set is closed. - pub fn as_str(self) -> &'static str { - self.into() - } -} - -#[cfg(test)] -mod tests { - use super::ChainMethod; - - #[test] - fn read_surface_methods_parse() { - for m in [ - "eth_call", - "eth_blockNumber", - "eth_getBalance", - "eth_getLogs", - "eth_getTransactionReceipt", - "net_version", - ] { - assert!(ChainMethod::try_from(m).is_ok(), "{m} should parse"); - } - } - - #[test] - fn signing_and_mutating_methods_have_no_variant() { - for m in [ - "eth_sign", - "eth_signTransaction", - "eth_sendTransaction", - "eth_sendRawTransaction", - "eth_accounts", - "personal_sign", - "admin_peers", - "debug_traceCall", - "", - ] { - assert!(ChainMethod::try_from(m).is_err(), "{m} must be rejected"); - } - } - - #[test] - fn as_str_round_trips_the_wire_name() { - assert_eq!(ChainMethod::EthCall.as_str(), "eth_call"); - assert_eq!( - ChainMethod::try_from(ChainMethod::EthGetBalance.as_str()), - Ok(ChainMethod::EthGetBalance), - ); - } -} diff --git a/nexum/crates/nexum-sdk/src/chain/mod.rs b/nexum/crates/nexum-sdk/src/chain/mod.rs deleted file mode 100644 index e10c8808..00000000 --- a/nexum/crates/nexum-sdk/src/chain/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Chain access for guest strategies. -//! -//! Typed identity ([`Chain`], [`ChainId`]), the closed JSON-RPC read -//! surface ([`ChainMethod`]), and the alloy provider seam: a -//! [`HostTransport`] over `ChainHost::request` fronted by -//! [`ProviderHost::provider`], driven with [`block_on`]. Plus the -//! `eth_call` JSON plumbing helpers for modules that keep their own -//! `chain::request` shim. - -pub mod chainlink; -pub mod eth_call; -pub mod id; -pub mod method; -pub mod provider; -pub mod transport; - -pub use eth_call::{eth_call_params, parse_eth_call_result}; -pub use id::{Chain, ChainId}; -pub use method::ChainMethod; -pub use provider::{ProviderHost, block_on}; -pub use transport::HostTransport; diff --git a/nexum/crates/nexum-sdk/src/chain/provider.rs b/nexum/crates/nexum-sdk/src/chain/provider.rs deleted file mode 100644 index a46d4f8c..00000000 --- a/nexum/crates/nexum-sdk/src/chain/provider.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! `host.provider(chain)`: an alloy `Provider` over the chain host. - -use std::future::{Future, IntoFuture}; -use std::pin::pin; -use std::task::{Context, Poll, Waker}; - -use alloy_provider::RootProvider; -use alloy_rpc_client::RpcClient; - -use super::{Chain, HostTransport}; -use crate::host::ChainHost; - -/// Mints an alloy [`Provider`](alloy_provider::Provider) over -/// [`ChainHost::request`], so a strategy calls typed provider methods -/// instead of hand-building JSON-RPC. Blanket-implemented for every -/// cloneable [`ChainHost`]; drive the returned futures with -/// [`block_on`]. -/// -/// ``` -/// use alloy_provider::Provider; -/// use nexum_sdk::chain::{Chain, ProviderHost, block_on}; -/// use nexum_sdk::host::{ChainError, ChainHost}; -/// -/// #[derive(Clone)] -/// struct StubHost; -/// impl ChainHost for StubHost { -/// fn request(&self, _: u64, _: &str, _: &str) -> Result { -/// Ok("\"0x2a\"".into()) -/// } -/// } -/// -/// let provider = StubHost.provider(Chain::MAINNET); -/// let block = block_on(provider.get_block_number()).unwrap(); -/// assert_eq!(block, 42); -/// ``` -pub trait ProviderHost: ChainHost + Clone + Send + Sync + Sized + 'static { - /// Provider for `chain`, routed through the host's RPC stack. - fn provider(&self, chain: Chain) -> RootProvider { - RootProvider::new(RpcClient::new( - HostTransport::new(self.clone(), chain), - false, - )) - } -} - -impl ProviderHost for H {} - -/// Drive a provider future to completion. Host-backed transports -/// resolve synchronously, so this is a poll loop, not a scheduler; a -/// future that awaits anything other than a host call will spin. -pub fn block_on(future: F) -> F::Output { - let mut future = pin!(future.into_future()); - let mut cx = Context::from_waker(Waker::noop()); - loop { - if let Poll::Ready(output) = future.as_mut().poll(&mut cx) { - return output; - } - } -} - -#[cfg(test)] -mod tests { - use alloy_primitives::{Bytes, address}; - use alloy_provider::Provider; - use alloy_rpc_types_eth::TransactionRequest; - - use super::{ProviderHost, block_on}; - use crate::chain::Chain; - use crate::host::{ChainError, ChainHost}; - - #[derive(Clone)] - struct StubHost; - - impl ChainHost for StubHost { - fn request( - &self, - chain_id: u64, - method: &str, - _params: &str, - ) -> Result { - assert_eq!(chain_id, 100); - match method { - "eth_blockNumber" => Ok("\"0x2a\"".into()), - "eth_call" => Ok("\"0x1234\"".into()), - other => panic!("unexpected method {other}"), - } - } - } - - #[test] - fn provider_reads_typed_values_through_the_host() { - let provider = StubHost.provider(Chain::GNOSIS); - let block = block_on(provider.get_block_number()).expect("block number"); - assert_eq!(block, 42); - } - - #[test] - fn provider_call_decodes_bytes() { - let provider = StubHost.provider(Chain::GNOSIS); - let tx = TransactionRequest::default() - .to(address!("0x9008D19f58AAbD9eD0D60971565AA8510560ab41")); - let out = block_on(provider.call(tx)).expect("eth_call"); - assert_eq!(out, Bytes::from(vec![0x12, 0x34])); - } - - #[test] - fn signing_methods_error_before_the_host() { - let provider = StubHost.provider(Chain::GNOSIS); - let err = block_on(provider.raw_request::<_, String>("eth_sendRawTransaction".into(), ())) - .expect_err("write method is rejected"); - let payload = err.as_error_resp().expect("json-rpc error response"); - assert_eq!(payload.code, -32601); - } - - #[test] - fn block_on_drives_plain_futures() { - assert_eq!(block_on(async { 7 }), 7); - } -} diff --git a/nexum/crates/nexum-sdk/src/chain/transport.rs b/nexum/crates/nexum-sdk/src/chain/transport.rs deleted file mode 100644 index 10c64974..00000000 --- a/nexum/crates/nexum-sdk/src/chain/transport.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! [`HostTransport`]: the alloy transport over [`ChainHost::request`]. - -use std::future::ready; -use std::task::{Context, Poll}; - -use alloy_json_rpc::{ - ErrorPayload, RequestPacket, Response, ResponsePacket, ResponsePayload, SerializedRequest, -}; -use alloy_transport::{TransportError, TransportErrorKind, TransportFut}; -use serde_json::value::RawValue; -use tower::Service; - -use super::{Chain, ChainMethod}; -use crate::host::{ChainError, ChainHost}; - -/// An alloy `Transport` routing JSON-RPC through the host's chain -/// interface. Dispatch is synchronous: the host blocks the guest until -/// the response is available, so every returned future is ready on its -/// first poll and [`block_on`](super::block_on) drives it for free. -/// -/// Methods outside the typed [`ChainMethod`] surface never reach the -/// host; they fail as a JSON-RPC `-32601` error response. A structured -/// node error comes back as the error payload (code, message, revert -/// bytes as `0x` hex); a host [`Fault`](crate::host::Fault) surfaces as -/// a custom transport error carrying the typed fault. -#[derive(Clone, Copy, Debug)] -pub struct HostTransport { - host: H, - chain: Chain, -} - -impl HostTransport -where - H: ChainHost + Clone + Send + Sync + 'static, -{ - /// Transport dispatching on `chain` through `host`. - pub const fn new(host: H, chain: Chain) -> Self { - Self { host, chain } - } - - fn dispatch(&self, packet: RequestPacket) -> Result { - match packet { - RequestPacket::Single(req) => Ok(ResponsePacket::Single(self.dispatch_single(&req)?)), - RequestPacket::Batch(reqs) => reqs - .iter() - .map(|req| self.dispatch_single(req)) - .collect::, _>>() - .map(ResponsePacket::Batch), - } - } - - fn dispatch_single(&self, req: &SerializedRequest) -> Result { - let Ok(method) = ChainMethod::try_from(req.method()) else { - return Ok(failure( - req, - ErrorPayload { - code: -32601, - message: format!( - "method outside the permitted read surface: {}", - req.method() - ) - .into(), - data: None, - }, - )); - }; - let params = req.params().map_or("[]", RawValue::get); - match self - .host - .request(self.chain.into(), method.as_str(), params) - { - Ok(result) => { - let payload = RawValue::from_string(result) - .map_err(|e| TransportError::deser_err(e, "host chain response"))?; - Ok(Response { - id: req.id().clone(), - payload: ResponsePayload::Success(payload), - }) - } - Err(ChainError::Rpc(rpc)) => Ok(failure( - req, - ErrorPayload { - code: rpc.code.into(), - message: rpc.message.into(), - data: rpc.data.and_then(|bytes| { - serde_json::value::to_raw_value(&alloy_primitives::hex::encode_prefixed( - bytes, - )) - .ok() - }), - }, - )), - Err(ChainError::Fault(fault)) => Err(TransportErrorKind::custom(fault)), - } - } -} - -fn failure(req: &SerializedRequest, payload: ErrorPayload) -> Response { - Response { - id: req.id().clone(), - payload: ResponsePayload::Failure(payload), - } -} - -impl Service for HostTransport -where - H: ChainHost + Clone + Send + Sync + 'static, -{ - type Response = ResponsePacket; - type Error = TransportError; - type Future = TransportFut<'static>; - - fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn call(&mut self, packet: RequestPacket) -> Self::Future { - let result = self.dispatch(packet); - Box::pin(ready(result)) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use alloy_json_rpc::{Id, Request, RequestPacket, ResponsePacket, ResponsePayload}; - use alloy_transport::TransportError; - use tower::Service; - - use super::HostTransport; - use crate::chain::{Chain, block_on}; - use crate::host::{ChainError, ChainHost, Fault, RpcError}; - - type StubFn = dyn Fn(u64, &str, &str) -> Result + Send + Sync; - - #[derive(Clone)] - struct Stub(Arc); - - impl Stub { - fn new( - f: impl Fn(u64, &str, &str) -> Result + Send + Sync + 'static, - ) -> Self { - Self(Arc::new(f)) - } - } - - impl ChainHost for Stub { - fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { - (self.0)(chain_id, method, params) - } - } - - fn single(method: &'static str) -> RequestPacket { - let req = Request::new(method, Id::Number(1), ()) - .serialize() - .expect("request serializes"); - RequestPacket::Single(req) - } - - fn call(transport: &mut HostTransport, packet: RequestPacket) -> super::Response { - let ResponsePacket::Single(resp) = - block_on(Service::call(transport, packet)).expect("transport dispatches") - else { - panic!("single request yields a single response"); - }; - resp - } - - #[test] - fn success_passes_host_json_through() { - let stub = Stub::new(|chain_id, method, params| { - assert_eq!(chain_id, 100); - assert_eq!(method, "eth_blockNumber"); - assert_eq!(params, "[]"); - Ok("\"0x2a\"".into()) - }); - let mut transport = HostTransport::new(stub, Chain::GNOSIS); - let resp = call(&mut transport, single("eth_blockNumber")); - let ResponsePayload::Success(payload) = resp.payload else { - panic!("expected success, got {resp:?}"); - }; - assert_eq!(payload.get(), "\"0x2a\""); - } - - #[test] - fn unlisted_method_never_reaches_the_host() { - let stub = Stub::new(|_, method, _| panic!("host must not see {method}")); - let mut transport = HostTransport::new(stub, Chain::MAINNET); - let resp = call(&mut transport, single("eth_sendRawTransaction")); - let ResponsePayload::Failure(err) = resp.payload else { - panic!("expected failure, got {resp:?}"); - }; - assert_eq!(err.code, -32601); - assert!(err.message.contains("eth_sendRawTransaction")); - } - - #[test] - fn rpc_error_surfaces_code_message_and_revert_hex() { - let stub = Stub::new(|_, _, _| { - Err(ChainError::Rpc(RpcError { - code: -32000, - message: "execution reverted".into(), - data: Some(vec![0x08, 0xc3, 0x79, 0xa0].into()), - })) - }); - let mut transport = HostTransport::new(stub, Chain::MAINNET); - let resp = call(&mut transport, single("eth_call")); - let ResponsePayload::Failure(err) = resp.payload else { - panic!("expected failure, got {resp:?}"); - }; - assert_eq!(err.code, -32000); - assert_eq!(err.message, "execution reverted"); - assert_eq!(err.data.expect("revert data").get(), "\"0x08c379a0\"",); - } - - #[test] - fn fault_becomes_a_typed_transport_error() { - let stub = Stub::new(|_, _, _| Err(ChainError::Fault(Fault::Timeout))); - let mut transport = HostTransport::new(stub, Chain::MAINNET); - let err = block_on(Service::call(&mut transport, single("eth_call"))) - .expect_err("fault propagates"); - let TransportError::Transport(kind) = err else { - panic!("expected transport kind, got {err:?}"); - }; - assert!(kind.to_string().contains("timeout")); - } - - #[test] - fn batches_dispatch_per_request() { - let stub = Stub::new(|_, method, _| match method { - "eth_blockNumber" => Ok("\"0x1\"".into()), - _ => Ok("\"0x64\"".into()), - }); - let mut transport = HostTransport::new(stub, Chain::MAINNET); - let reqs = vec![ - Request::new("eth_blockNumber", Id::Number(1), ()) - .serialize() - .expect("request serializes"), - Request::new("eth_chainId", Id::Number(2), ()) - .serialize() - .expect("request serializes"), - ]; - let ResponsePacket::Batch(resps) = - block_on(Service::call(&mut transport, RequestPacket::Batch(reqs))) - .expect("batch dispatches") - else { - panic!("batch request yields a batch response"); - }; - assert_eq!(resps.len(), 2); - } -} diff --git a/nexum/crates/nexum-sdk/src/config.rs b/nexum/crates/nexum-sdk/src/config.rs deleted file mode 100644 index fdfcfc9c..00000000 --- a/nexum/crates/nexum-sdk/src/config.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! Helpers for parsing the `Vec<(String, String)>` config entries a -//! module's `on_event` receives. -//! -//! Each entry is a `(key, value)` pair the runtime read from the -//! module's `[config]` table. Modules need three operations -//! repeatedly: required-key lookup, optional-key lookup, and decimal -//! parsing for thresholds / amounts. Hoisting these here keeps the -//! example modules consuming the SDK rather than re-implementing the -//! same loops around it (each copy in price-alert + stop-loss had -//! started to drift in error wording). - -use alloy_primitives::{I256, U256}; -use thiserror::Error; - -/// Why a config lookup or parse failed. -/// -/// Modules wrap this into a [`Fault::InvalidInput`] at the boundary. -/// The SDK type stays host-neutral so the same parser can be -/// unit-tested without `wasm32-wasip2`. -/// -/// [`Fault::InvalidInput`]: crate::host::Fault::InvalidInput -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum ConfigError { - /// The key was not present in the `entries` slice. - #[error("missing key {key:?}")] - MissingKey { - /// Config-table key the lookup was for. - key: String, - }, - /// The value at `key` did not parse as the expected shape. - #[error("parse {key:?}: {detail}")] - Parse { - /// Config-table key whose value failed to parse. - key: String, - /// Free-text parser detail. - detail: String, - }, - /// The value parsed but did not fit the target type's range. - #[error("range {key:?}: {detail}")] - Range { - /// Config-table key whose value overflowed. - key: String, - /// Free-text range detail. - detail: String, - }, -} - -/// Look up a required `(key, value)` entry in a config table. -/// -/// Returns `Err(MissingKey)` if the key is absent. The returned -/// `&str` borrows from `entries`. -pub fn get_required<'a>( - entries: &'a [(String, String)], - key: &str, -) -> Result<&'a str, ConfigError> { - entries - .iter() - .find(|(k, _)| k == key) - .map(|(_, v)| v.as_str()) - .ok_or_else(|| ConfigError::MissingKey { - key: key.to_owned(), - }) -} - -/// Look up an optional `(key, value)` entry. Returns `None` when -/// absent; never errors. -pub fn get_optional<'a>(entries: &'a [(String, String)], key: &str) -> Option<&'a str> { - entries - .iter() - .find(|(k, _)| k == key) - .map(|(_, v)| v.as_str()) -} - -/// Parse a signed fixed-point decimal string into an `I256` scaled by -/// `10**decimals`. -/// -/// - Short fractional parts are right-padded with zeros. -/// - Long fractional parts are truncated. -/// - A leading `-` is honoured. -/// - Empty input is rejected as a parse error. -/// - Non-digit characters (other than the leading sign and a single -/// `.`) are rejected. -/// -/// `key` is the config-table key the value came from; it is embedded -/// in the returned error so the caller can surface a useful message -/// without re-passing context. -pub fn scale_decimal(value: &str, decimals: u32, key: &str) -> Result { - let (sign, body) = if let Some(rest) = value.strip_prefix('-') { - (-1i32, rest) - } else { - (1, value) - }; - let (whole, frac) = match body.split_once('.') { - Some((w, f)) => (w, f), - None => (body, ""), - }; - if whole.is_empty() && frac.is_empty() { - return Err(ConfigError::Parse { - key: key.to_owned(), - detail: "empty".to_owned(), - }); - } - if !whole.chars().all(|c| c.is_ascii_digit()) || !frac.chars().all(|c| c.is_ascii_digit()) { - return Err(ConfigError::Parse { - key: key.to_owned(), - detail: format!("non-digit character in {value:?}"), - }); - } - let frac_len = frac.len() as u32; - let composed: String = if frac_len <= decimals { - let mut s = String::with_capacity(whole.len() + decimals as usize); - s.push_str(whole); - s.push_str(frac); - for _ in 0..(decimals - frac_len) { - s.push('0'); - } - s - } else { - let mut s = String::with_capacity(whole.len() + decimals as usize); - s.push_str(whole); - s.push_str(&frac[..decimals as usize]); - s - }; - let raw = if composed.is_empty() { "0" } else { &composed }; - let unsigned: U256 = raw.parse().map_err(|e| ConfigError::Parse { - key: key.to_owned(), - detail: format!("{e}"), - })?; - let signed = I256::try_from(unsigned).map_err(|e| ConfigError::Range { - key: key.to_owned(), - detail: format!("{e}"), - })?; - Ok(if sign < 0 { -signed } else { signed }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn entries(pairs: &[(&str, &str)]) -> Vec<(String, String)> { - pairs - .iter() - .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) - .collect() - } - - #[test] - fn get_required_finds_value() { - let cfg = entries(&[("a", "1"), ("b", "2")]); - assert_eq!(get_required(&cfg, "a").unwrap(), "1"); - assert_eq!(get_required(&cfg, "b").unwrap(), "2"); - } - - #[test] - fn get_required_missing_is_typed_error() { - let cfg = entries(&[("a", "1")]); - let err = get_required(&cfg, "b").unwrap_err(); - assert!(matches!(err, ConfigError::MissingKey { ref key } if key == "b")); - } - - #[test] - fn get_optional_returns_none_for_missing() { - let cfg = entries(&[("a", "1")]); - assert_eq!(get_optional(&cfg, "missing"), None); - assert_eq!(get_optional(&cfg, "a"), Some("1")); - } - - #[test] - fn scale_decimal_pads_short_fractional() { - // "2500.00" with 8 decimals -> 2500 * 1e8 = 250_000_000_000 - let v = scale_decimal("2500.00", 8, "trigger").unwrap(); - assert_eq!(v, I256::try_from(250_000_000_000_i128).unwrap()); - } - - #[test] - fn scale_decimal_truncates_long_fractional() { - // "1.123456789" with 4 decimals -> "11234" - let v = scale_decimal("1.123456789", 4, "trigger").unwrap(); - assert_eq!(v, I256::try_from(11234_i128).unwrap()); - } - - #[test] - fn scale_decimal_handles_no_decimal_point() { - let v = scale_decimal("42", 4, "x").unwrap(); - assert_eq!(v, I256::try_from(420_000_i128).unwrap()); - } - - #[test] - fn scale_decimal_handles_negative() { - let v = scale_decimal("-2.5", 2, "x").unwrap(); - assert_eq!(v, I256::try_from(-250_i128).unwrap()); - } - - #[test] - fn scale_decimal_rejects_empty() { - let err = scale_decimal("", 2, "x").unwrap_err(); - assert!( - matches!(err, ConfigError::Parse { ref key, .. } if key == "x"), - "got {err:?}" - ); - } - - #[test] - fn scale_decimal_rejects_garbage() { - let err = scale_decimal("not-a-number", 2, "x").unwrap_err(); - assert!( - matches!(err, ConfigError::Parse { ref key, .. } if key == "x"), - "got {err:?}" - ); - } -} diff --git a/nexum/crates/nexum-sdk/src/events.rs b/nexum/crates/nexum-sdk/src/events.rs deleted file mode 100644 index fb9cd0bc..00000000 --- a/nexum/crates/nexum-sdk/src/events.rs +++ /dev/null @@ -1,130 +0,0 @@ -//! Chain-log delivery at the guest WIT edge. -//! -//! Modules receive on-chain logs as the native [`Log`] (alloy's -//! `eth_getLogs` shape), not an SDK-invented view. The host packs each log -//! into the WIT `chain-log` record; [`ChainLogParts`] borrows that record's -//! raw fields and `From` rebuilds the alloy value. The per-module bind macro -//! emits the `From` glue that routes through this, so a strategy -//! holds `&[Log]` and decodes `sol!` events against [`Log::inner`]. - -use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, LogData}; - -/// The alloy RPC log delivered to modules for chain-log events. -pub use alloy_rpc_types_eth::Log; - -/// Borrowed raw fields of a WIT `chain-log` record, assembled into an alloy -/// [`Log`] via `From`. -/// -/// Fixed-width byte fields are right-aligned into their EVM word (20 bytes for -/// the address, 32 for topics and hashes). The host is the sole runtime and -/// the frames it emits are well-formed by construction, so an out-of-width -/// field is a host bug that traps loudly rather than being silently reshaped. -#[derive(Default)] -pub struct ChainLogParts<'a> { - /// 20-byte contract address. - pub address: &'a [u8], - /// Indexed topics, each a 32-byte word. - pub topics: &'a [Vec], - /// ABI-encoded non-indexed data. - pub data: &'a [u8], - /// Block hash; `None` for a pending log. - pub block_hash: Option<&'a [u8]>, - /// Block number; `None` for a pending log. - pub block_number: Option, - /// Block timestamp; `None` for a pending log. - pub block_timestamp: Option, - /// Transaction hash; `None` for a pending log. - pub transaction_hash: Option<&'a [u8]>, - /// Transaction index; `None` for a pending log. - pub transaction_index: Option, - /// Log index; `None` for a pending log. - pub log_index: Option, - /// Whether the log was removed by a reorg. - pub removed: bool, -} - -impl From> for Log { - fn from(p: ChainLogParts<'_>) -> Self { - Log { - inner: PrimitiveLog { - address: Address::left_padding_from(p.address), - // Topics arrive from an alloy provider `Log` (at most 4 by the - // EVM rule, enforced upstream) via the trusted host, so the - // unchecked constructor is sound. - data: LogData::new_unchecked( - p.topics - .iter() - .map(|t| B256::left_padding_from(t)) - .collect(), - Bytes::copy_from_slice(p.data), - ), - }, - block_hash: p.block_hash.map(B256::left_padding_from), - block_number: p.block_number, - block_timestamp: p.block_timestamp, - transaction_hash: p.transaction_hash.map(B256::left_padding_from), - transaction_index: p.transaction_index, - log_index: p.log_index, - removed: p.removed, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn assembles_full_mined_log() { - let addr = [0x11u8; 20]; - let topic = [0x22u8; 32]; - let hash = [0x33u8; 32]; - let log: Log = ChainLogParts { - address: &addr, - topics: &[topic.to_vec()], - data: &[1, 2, 3], - block_hash: Some(&hash), - block_number: Some(42), - block_timestamp: Some(1_700_000_000), - transaction_hash: Some(&hash), - transaction_index: Some(7), - log_index: Some(9), - removed: true, - } - .into(); - assert_eq!(log.address().as_slice(), addr); - assert_eq!(log.topics(), &[B256::from(topic)]); - assert_eq!(log.inner.data.data.as_ref(), &[1, 2, 3]); - assert_eq!(log.block_hash, Some(B256::from(hash))); - assert_eq!(log.block_number, Some(42)); - assert_eq!(log.block_timestamp, Some(1_700_000_000)); - assert_eq!(log.transaction_index, Some(7)); - assert_eq!(log.log_index, Some(9)); - assert!(log.removed); - } - - #[test] - fn pending_log_leaves_block_fields_absent() { - let log: Log = ChainLogParts { - address: &[0u8; 20], - ..Default::default() - } - .into(); - assert!(log.block_hash.is_none()); - assert!(log.block_number.is_none()); - assert!(log.transaction_hash.is_none()); - assert!(log.log_index.is_none()); - assert!(!log.removed); - } - - #[test] - fn undersized_word_is_left_padded() { - let log: Log = ChainLogParts { - address: &[0u8; 20], - topics: &[vec![0xab]], - ..Default::default() - } - .into(); - assert_eq!(log.topics(), &[B256::with_last_byte(0xab)]); - } -} diff --git a/nexum/crates/nexum-sdk/src/host.rs b/nexum/crates/nexum-sdk/src/host.rs deleted file mode 100644 index 9fc0cb68..00000000 --- a/nexum/crates/nexum-sdk/src/host.rs +++ /dev/null @@ -1,583 +0,0 @@ -//! Host traits - the seam between strategy logic and the wit-bindgen -//! shims a module generates per-cdylib. -//! -//! Each trait mirrors one nexum host interface: [`ChainHost`], -//! [`IdentityHost`], [`LocalStoreHost`], [`RemoteStoreHost`], -//! [`MessagingHost`], and [`LoggingHost`]. A module that wants -//! host-free unit tests writes its strategy logic against the -//! [`Host`] supertrait (all six) or the exact traits it exercises, -//! and lets `nexum-sdk-test` slot in the in-memory mocks. Domain SDKs -//! bound extra host interfaces on top with their own traits over the -//! same [`Fault`]. -//! -//! ## Why a separate `Fault` -//! -//! `wit_bindgen::generate!` emits a `Fault` type into each module's -//! own crate, so its identity is per-module. The SDK exposes [`Fault`] -//! (this module) with the same case shape, so modules wire a one-liner -//! converter between the two and the traits stay world-neutral, letting -//! the mocks compile without a wasm toolchain. See `nexum-sdk-test`'s -//! crate docs for the adapter pattern. - -use alloy_primitives::{Address, B256, Bytes, Signature}; -use strum::IntoStaticStr; -use tracing_core::Level; - -/// The cross-domain failure vocabulary richer host interfaces embed as -/// a case, mirrored from `nexum:host/types.fault`. Typed per-interface -/// errors wrap this shared payload-bearing set so a caller recovers the -/// structured cause without a stringly-typed ladder. -/// -/// `#[non_exhaustive]` forces downstream `match` sites to carry a wildcard -/// arm, so the WIT can grow a case without breaking them. -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum Fault { - /// Capability declared but not provisioned by the operator. - #[error("unsupported: {0}")] - Unsupported(String), - /// Capability temporarily unavailable (RPC down, etc). - #[error("unavailable: {0}")] - Unavailable(String), - /// Capability declined the request (auth, allowlist, …). - #[error("denied: {0}")] - Denied(String), - /// Rate-limited by an upstream service; may carry backoff guidance - /// when the host knows the retry window. - #[error("rate limited{}", .0.retry_after_ms.map_or_else(String::new, |ms| format!(", retry after {ms} ms")))] - RateLimited(RateLimit), - /// Operation took too long. - #[error("timeout")] - Timeout, - /// Caller-supplied input did not parse / validate. - #[error("invalid input: {0}")] - InvalidInput(String), - /// Catch-all for host-side bugs. - #[error("internal: {0}")] - Internal(String), -} - -/// Backoff guidance carried by [`Fault::RateLimited`], mirrored from -/// `nexum:host/types.rate-limit`. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)] -pub struct RateLimit { - /// Host's suggested wait before retrying, in milliseconds, when known. - pub retry_after_ms: Option, -} - -/// Sealing markers for [`Host`] and [`HostFault`]: implement alongside -/// the trait. -#[doc(hidden)] -pub mod sealed { - pub trait SealedHost {} - pub trait SealedHostFault {} -} - -impl sealed::SealedHost for T where - T: ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost -{ -} - -impl sealed::SealedHostFault for Fault {} -impl sealed::SealedHostFault for ChainError {} - -/// Recovers the shared [`Fault`] from a richer, per-interface error. -/// -/// Typed interface errors that embed a fault case implement this so a -/// caller can dispatch on the structured cause and pull a stable -/// snake_case [`label`](HostFault::label) for logs and metrics without -/// matching the outer type. -/// -/// Sealed: an error type opts in by also implementing the sealing -/// marker. -pub trait HostFault: sealed::SealedHostFault { - /// The embedded fault, when this value represents one. - fn fault(&self) -> Option<&Fault>; - /// Stable snake_case label for logs and metrics. - fn label(&self) -> &'static str; -} - -impl HostFault for Fault { - fn fault(&self) -> Option<&Fault> { - Some(self) - } - - fn label(&self) -> &'static str { - self.into() - } -} - -/// A structured JSON-RPC error response, mirrored from -/// `nexum:host/chain.rpc-error`. `code` is the node-reported numeric -/// (typically `-32000` for an `eth_call` revert). `data` is the decoded -/// `error.data` payload: the host hex-decodes the upstream JSON string -/// once, so a strategy receives the raw abi-encoded revert bytes and -/// can hand them straight to a revert decoder. -/// -/// This is a world-neutral mirror, not `alloy_json_rpc::ErrorPayload`: -/// that type widens `code` to `i64` and carries `data` as raw JSON, and -/// depending on it would drag the JSON-RPC client stack into every wasm -/// guest, which only ever sees the host-decoded bytes over WIT. -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] -#[error("rpc error {code}: {message}")] -pub struct RpcError { - /// JSON-RPC error code from the node. - pub code: i32, - /// Human-readable detail. - pub message: String, - /// Decoded `error.data` bytes, when the node returned a hex payload. - /// `Bytes` so a guest hands the host-decoded buffer to a revert - /// decoder without re-copying it. - pub data: Option, -} - -/// Failure of a `nexum:host/chain` call, mirrored from -/// `nexum:host/chain.chain-error`: either a shared host [`Fault`] -/// (transport down, timed out, denied, ...) or a structured JSON-RPC -/// [`RpcError`] carrying the node code and any decoded revert payload. -/// -/// [`HostFault`] recovers the embedded [`Fault`] (present only on the -/// `Fault` case) and a stable snake_case label for logs and metrics. -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] -#[non_exhaustive] -pub enum ChainError { - /// A shared host fault. - #[error(transparent)] - Fault(#[from] Fault), - /// A structured JSON-RPC error response. - #[error(transparent)] - Rpc(#[from] RpcError), -} - -impl HostFault for ChainError { - fn fault(&self) -> Option<&Fault> { - match self { - ChainError::Fault(f) => Some(f), - ChainError::Rpc(_) => None, - } - } - - fn label(&self) -> &'static str { - match self { - ChainError::Fault(f) => f.label(), - ChainError::Rpc(_) => "rpc", - } - } -} - -/// Fold a [`ChainError`] into the shared [`Fault`] a module returns -/// from `init` / `on_event`. The `fault` case passes through; a -/// structured JSON-RPC [`RpcError`] has no shared-vocabulary case, so -/// it becomes an [`Fault::Internal`] carrying the node code, message, -/// and any decoded revert bytes as a `0x` hex suffix. -impl From for Fault { - fn from(err: ChainError) -> Self { - match err { - ChainError::Fault(fault) => fault, - ChainError::Rpc(rpc) => { - let mut message = format!("rpc error {}: {}", rpc.code, rpc.message); - if let Some(data) = rpc.data { - message.push_str(" ("); - message.push_str(&alloy_primitives::hex::encode_prefixed(data)); - message.push(')'); - } - Fault::Internal(message) - } - } - } -} - -/// `nexum:host/chain` - raw JSON-RPC dispatch. -pub trait ChainHost { - /// Execute a JSON-RPC request against the given chain. The host - /// routes to its configured provider; the SDK does not care which - /// transport (HTTP / WebSocket / mock) implements the call. A - /// failure is a [`ChainError`]: a shared [`Fault`] or a structured - /// JSON-RPC [`RpcError`] carrying any decoded revert bytes. - fn request(&self, chain_id: u64, method: &str, params: &str) -> Result; -} - -/// `nexum:host/local-store` - per-module key-value persistence. -/// -/// The interface reports failures as a [`Fault`]: the interface is the -/// failure domain, so the case vocabulary alone carries the cause. A -/// strategy that aggregates store and chain calls into one [`Fault`] -/// return relies on the `From` fold for `?`. -pub trait LocalStoreHost { - /// Fetch a value. `Ok(None)` when the key is absent. - fn get(&self, key: &str) -> Result>, Fault>; - /// Insert or overwrite. - fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault>; - /// Delete. No-op if the key is absent. - fn delete(&self, key: &str) -> Result<(), Fault>; - /// Enumerate keys whose raw form starts with `prefix`. - fn list_keys(&self, prefix: &str) -> Result, Fault>; - /// Whether `key` exists. Default fetches the value; a backend - /// overrides when it can answer without. - fn contains(&self, key: &str) -> Result { - Ok(self.get(key)?.is_some()) - } - /// Value byte length, `Ok(None)` when absent. Default fetches the - /// value; on some backends this may be a scan. - fn len(&self, key: &str) -> Result, Fault> { - Ok(self.get(key)?.map(|v| v.len() as u64)) - } - /// Number of keys starting with `prefix`. Default materialises the - /// key list; on some backends this may be a scan. - fn count(&self, prefix: &str) -> Result { - Ok(self.list_keys(prefix)?.len() as u64) - } -} - -/// `nexum:host/logging` - structured runtime logs. -pub trait LoggingHost { - /// Emit a log line at the given [`Level`]. The bind macro maps it - /// onto the generated wire enum; the WIT edge is the only place a - /// non-`Level` severity type appears. - fn log(&self, level: Level, message: &str); -} - -/// `nexum:host/identity` - host-held accounts and signing. -pub trait IdentityHost { - /// Accounts the host is willing to sign for. Empty means no - /// signing capability. - fn accounts(&self) -> Result, Fault>; - /// Sign `message` with `personal_sign` semantics (the host - /// prepends the `"\x19Ethereum Signed Message:\n"` prefix). - fn sign(&self, account: Address, message: &[u8]) -> Result; - /// Sign a JSON-encoded EIP-712 payload. - fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result; -} - -/// One delivered message, mirrored from `nexum:host/types.message` so -/// the [`MessagingHost`] seam stays mockable without naming bindgen -/// types. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Message { - /// Content topic the message arrived on. - pub content_topic: String, - /// Opaque payload bytes. - pub payload: Vec, - /// Delivery timestamp, ms since the Unix epoch, UTC. - pub timestamp: u64, - /// Optional sender identity (protocol-dependent). - pub sender: Option>, -} - -/// `nexum:host/messaging` - publish to and query content topics. The -/// host confines both to the component's `messaging_topics` grant; an -/// off-scope topic fails as [`Fault::Denied`]. -pub trait MessagingHost { - /// Publish a payload to a content topic - /// (`////`). - fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault>; - /// Query historical messages on a topic, window bounded by the - /// optional `start_time` / `end_time` (ms since the Unix epoch, - /// UTC) and `limit`. - fn query( - &self, - content_topic: &str, - start_time: Option, - end_time: Option, - limit: Option, - ) -> Result, Fault>; -} - -/// `nexum:host/remote-store` - content-addressed blobs and mutable -/// feeds on the decentralized store. -pub trait RemoteStoreHost { - /// Upload raw data; returns its 32-byte content reference. - fn upload(&self, data: &[u8]) -> Result; - /// Download the data behind a content reference. - fn download(&self, reference: B256) -> Result, Fault>; - /// Latest value of the `(owner, topic)` mutable feed, when set. - fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault>; - /// Update the host-owned feed at `topic` (the host signs with its - /// configured identity); returns the new chunk's reference. - fn write_feed(&self, topic: B256, data: &[u8]) -> Result; -} - -/// Lift a host-returned account into an [`Address`]. The WIT edge -/// carries it as bytes; any length but 20 is a host-side bug, folded -/// to [`Fault::Internal`]. -pub fn account_from_wire(raw: &[u8]) -> Result { - Address::try_from(raw).map_err(|_| { - Fault::Internal(format!( - "identity returned a {}-byte account, expected 20", - raw.len() - )) - }) -} - -/// Lift a host-returned 65-byte `r || s || v` signature into a -/// [`Signature`]. A malformed buffer is a host-side bug, folded to -/// [`Fault::Internal`]. -pub fn signature_from_wire(raw: &[u8]) -> Result { - Signature::from_raw(raw) - .map_err(|e| Fault::Internal(format!("identity returned a malformed signature: {e}"))) -} - -/// Lift a host-returned content reference into a [`B256`]. Any length -/// but 32 is a host-side bug, folded to [`Fault::Internal`]. -pub fn reference_from_wire(raw: &[u8]) -> Result { - B256::try_from(raw).map_err(|_| { - Fault::Internal(format!( - "remote-store returned a {}-byte reference, expected 32", - raw.len() - )) - }) -} - -/// Supertrait that bundles all six core host interfaces. Modules that -/// want full host-free integration tests take `&impl Host` (or a -/// generic ``) in their strategy function; -/// `nexum-sdk-test::MockHost` is the in-memory implementation. -/// Strategies that exercise fewer interfaces bound exactly those -/// (`H: ChainHost + LoggingHost`, say) so their production adapter -/// only needs the capabilities the module declares; a domain -/// extension's host trait is bounded the same way (the CoW SDK's -/// `CowHost`). -/// -/// A blanket impl is provided for any type that implements all six -/// component traits, so callers do not have to add a redundant -/// `impl Host for MyHost {}`. -/// -/// # Example -/// -/// Strategy functions are generic over [`Host`]. Production code plugs -/// the per-module `WitBindgenHost` adapter (see `modules/examples/`); -/// unit tests plug `nexum_sdk_test::MockHost`. -/// -/// ``` -/// use nexum_sdk::Level; -/// use nexum_sdk::host::{ -/// ChainError, ChainHost, Fault, Host, IdentityHost, LocalStoreHost, LoggingHost, -/// Message, MessagingHost, RemoteStoreHost, -/// }; -/// # use nexum_sdk::prelude::{Address, B256, Signature}; -/// -/// /// Pure strategy logic - no wit-bindgen calls in here. -/// fn record_block(host: &H, chain_id: u64, key: &str) -> Result<(), Fault> { -/// host.log(Level::INFO, "recording block"); -/// host.set(key, b"")?; -/// let _block_number = host.request(chain_id, "eth_blockNumber", "[]")?; -/// Ok(()) -/// } -/// -/// // Minimal hand-rolled host so the doctest is self-contained. -/// // Real modules wire `nexum_sdk_test::MockHost` here. -/// # struct StubHost; -/// # impl ChainHost for StubHost { -/// # fn request(&self, _: u64, _: &str, _: &str) -> Result { -/// # Ok("\"0x0\"".into()) -/// # } -/// # } -/// # impl IdentityHost for StubHost { -/// # fn accounts(&self) -> Result, Fault> { Ok(vec![]) } -/// # fn sign(&self, _: Address, _: &[u8]) -> Result { -/// # Err(Fault::Unsupported("stub".into())) -/// # } -/// # fn sign_typed_data(&self, _: Address, _: &str) -> Result { -/// # Err(Fault::Unsupported("stub".into())) -/// # } -/// # } -/// # impl LocalStoreHost for StubHost { -/// # fn get(&self, _: &str) -> Result>, Fault> { Ok(None) } -/// # fn set(&self, _: &str, _: &[u8]) -> Result<(), Fault> { Ok(()) } -/// # fn delete(&self, _: &str) -> Result<(), Fault> { Ok(()) } -/// # fn list_keys(&self, _: &str) -> Result, Fault> { Ok(vec![]) } -/// # } -/// # impl RemoteStoreHost for StubHost { -/// # fn upload(&self, _: &[u8]) -> Result { -/// # Err(Fault::Unsupported("stub".into())) -/// # } -/// # fn download(&self, _: B256) -> Result, Fault> { -/// # Err(Fault::Unsupported("stub".into())) -/// # } -/// # fn read_feed(&self, _: Address, _: B256) -> Result>, Fault> { Ok(None) } -/// # fn write_feed(&self, _: B256, _: &[u8]) -> Result { -/// # Err(Fault::Unsupported("stub".into())) -/// # } -/// # } -/// # impl MessagingHost for StubHost { -/// # fn publish(&self, _: &str, _: &[u8]) -> Result<(), Fault> { Ok(()) } -/// # fn query( -/// # &self, -/// # _: &str, -/// # _: Option, -/// # _: Option, -/// # _: Option, -/// # ) -> Result, Fault> { -/// # Ok(vec![]) -/// # } -/// # } -/// # impl LoggingHost for StubHost { -/// # fn log(&self, _: Level, _: &str) {} -/// # } -/// record_block(&StubHost, 1, "block:42").unwrap(); -/// ``` -/// Sealed: the blanket impl is the only implementation. -pub trait Host: - sealed::SealedHost - + ChainHost - + IdentityHost - + LocalStoreHost - + RemoteStoreHost - + MessagingHost - + LoggingHost -{ -} -impl Host for T where - T: ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost -{ -} - -#[cfg(test)] -mod tests { - use alloy_primitives::{Address, B256, U256}; - - use super::{ - ChainError, Fault, HostFault, RateLimit, RpcError, account_from_wire, reference_from_wire, - signature_from_wire, - }; - - #[test] - fn local_store_metadata_defaults_derive_from_required_methods() { - use super::LocalStoreHost; - - /// Two fixed rows; only the four required methods are written. - struct TwoRows; - impl LocalStoreHost for TwoRows { - fn get(&self, key: &str) -> Result>, Fault> { - Ok(match key { - "a" => Some(b"abc".to_vec()), - "b" => Some(Vec::new()), - _ => None, - }) - } - fn set(&self, _: &str, _: &[u8]) -> Result<(), Fault> { - Ok(()) - } - fn delete(&self, _: &str) -> Result<(), Fault> { - Ok(()) - } - fn list_keys(&self, prefix: &str) -> Result, Fault> { - Ok(["a", "b"] - .iter() - .filter(|k| k.starts_with(prefix)) - .map(|k| (*k).to_owned()) - .collect()) - } - } - - assert!(TwoRows.contains("a").unwrap()); - assert!(!TwoRows.contains("missing").unwrap()); - assert_eq!(TwoRows.len("a").unwrap(), Some(3)); - assert_eq!(TwoRows.len("b").unwrap(), Some(0)); - assert_eq!(TwoRows.len("missing").unwrap(), None); - assert_eq!(TwoRows.count("").unwrap(), 2); - assert_eq!(TwoRows.count("a").unwrap(), 1); - assert_eq!(TwoRows.count("z").unwrap(), 0); - } - - #[test] - fn wire_lifts_accept_exact_lengths() { - let account = account_from_wire(&[0x11; 20]).unwrap(); - assert_eq!(account, Address::from([0x11; 20])); - - let reference = reference_from_wire(&[0x22; 32]).unwrap(); - assert_eq!(reference, B256::from([0x22; 32])); - - let raw = alloy_primitives::Signature::new(U256::from(1), U256::from(2), true).as_bytes(); - let signature = signature_from_wire(&raw).unwrap(); - assert_eq!(signature.r(), U256::from(1)); - assert_eq!(signature.s(), U256::from(2)); - assert!(signature.v()); - } - - #[test] - fn wire_lifts_fold_malformed_buffers_to_internal() { - for fault in [ - account_from_wire(&[0u8; 19]).unwrap_err(), - signature_from_wire(&[0u8; 64]).unwrap_err(), - reference_from_wire(&[0u8; 31]).unwrap_err(), - ] { - assert!(matches!(fault, Fault::Internal(_)), "got {fault:?}"); - } - } - - #[test] - fn fault_labels_match_the_single_source_vocabulary() { - use nexum_world::fault_labels as labels; - let cases: [(Fault, &str); 7] = [ - (Fault::Unsupported(String::new()), labels::UNSUPPORTED), - (Fault::Unavailable(String::new()), labels::UNAVAILABLE), - (Fault::Denied(String::new()), labels::DENIED), - ( - Fault::RateLimited(RateLimit::default()), - labels::RATE_LIMITED, - ), - (Fault::Timeout, labels::TIMEOUT), - (Fault::InvalidInput(String::new()), labels::INVALID_INPUT), - (Fault::Internal(String::new()), labels::INTERNAL), - ]; - for (fault, label) in cases { - assert_eq!(fault.label(), label); - assert_eq!(fault.fault(), Some(&fault)); - } - } - - #[test] - fn rate_limit_display_carries_the_retry_hint() { - let hinted = Fault::RateLimited(RateLimit { - retry_after_ms: Some(250), - }); - assert_eq!(hinted.to_string(), "rate limited, retry after 250 ms"); - assert_eq!( - Fault::RateLimited(RateLimit::default()).to_string(), - "rate limited" - ); - } - - #[test] - fn host_fault_is_object_safe() { - let boxed: Box = Box::new(Fault::Timeout); - assert_eq!(boxed.label(), "timeout"); - } - - #[test] - fn chain_error_recovers_embedded_fault() { - let fault = ChainError::Fault(Fault::Timeout); - assert_eq!(fault.fault(), Some(&Fault::Timeout)); - assert_eq!(fault.label(), "timeout"); - - let rpc = ChainError::Rpc(RpcError { - code: -32000, - message: "execution reverted".into(), - data: Some(vec![0xde, 0xad].into()), - }); - assert_eq!(rpc.fault(), None); - assert_eq!(rpc.label(), "rpc"); - } - - #[test] - fn chain_error_rpc_folds_to_internal_fault_with_hex_data() { - let fault = Fault::from(ChainError::Rpc(RpcError { - code: -32000, - message: "execution reverted".into(), - data: Some(vec![0x08, 0xc3, 0x79, 0xa0].into()), - })); - let Fault::Internal(message) = fault else { - panic!("rpc folds to internal, got {fault:?}"); - }; - assert!(message.contains("-32000")); - assert!(message.contains("0x08c379a0")); - } - - #[test] - fn chain_error_fault_folds_through_unchanged() { - let fault = Fault::from(ChainError::Fault(Fault::Unavailable("rpc down".into()))); - assert_eq!(fault, Fault::Unavailable("rpc down".into())); - } -} diff --git a/nexum/crates/nexum-sdk/src/http.rs b/nexum/crates/nexum-sdk/src/http.rs deleted file mode 100644 index 4e35b146..00000000 --- a/nexum/crates/nexum-sdk/src/http.rs +++ /dev/null @@ -1,253 +0,0 @@ -//! Outbound HTTP over wasi:http for guest modules. -//! -//! `fetch` performs one synchronous request through the host's -//! wasi:http outgoing handler. The host admits or denies every request -//! against the module's `[capabilities.http].allow` list before any -//! connection is made; a denial surfaces as [`FetchError::Denied`], so -//! modules can tell policy refusals from transport failures. -//! -//! Requests and responses are the standard [`http`] crate's -//! `Request>` and `Response>`; the SDK owns only what -//! wasi:http adds on top: the allowlist-aware [`FetchError`], the -//! per-phase [`FetchOptions`] timeouts, and the [`Fetch`] seam. -//! -//! [`Fetch`], [`FetchError`], and [`FetchOptions`] compile on every -//! target so strategy logic can be unit-tested host-side against the -//! seam; the `fetch` implementation itself only exists on -//! `wasm32-wasip2`. - -use core::time::Duration; - -use strum::IntoStaticStr; - -/// Per-phase timeout applied to each of connect, first byte, and -/// between bytes by [`FetchOptions::default`]. Keeps an event handler -/// from hanging on a stalled upstream. -pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); - -/// Per-phase wasi:http timeouts that have no home on [`http::Request`]. -/// -/// `Default` applies [`DEFAULT_TIMEOUT`] to every phase; plain -/// [`Fetch::fetch`] uses it, [`Fetch::fetch_with`] takes an override. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct FetchOptions { - /// Time allowed to establish the connection. - pub connect_timeout: Duration, - /// Time allowed for the first response byte after the request is - /// sent. - pub first_byte_timeout: Duration, - /// Time allowed between consecutive response body bytes. - pub between_bytes_timeout: Duration, -} - -impl Default for FetchOptions { - fn default() -> Self { - Self { - connect_timeout: DEFAULT_TIMEOUT, - first_byte_timeout: DEFAULT_TIMEOUT, - between_bytes_timeout: DEFAULT_TIMEOUT, - } - } -} - -/// Why a fetch failed, folded down from the wasi:http error codes. -/// -/// `IntoStaticStr` yields a snake_case label per variant for log and -/// metric fields. -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum FetchError { - /// The host's `[capabilities.http].allow` list refused the request - /// before any connection was made. - #[error("denied by the module's http allowlist")] - Denied, - /// The request never left the guest: malformed URL, method, or - /// header. - #[error("invalid request: {0}")] - InvalidRequest(String), - /// A configured timeout elapsed. - #[error("timeout: {0}")] - Timeout(String), - /// Connection or protocol failure after the allowlist admitted the - /// request. - #[error("transport failure: {0}")] - Transport(String), -} - -/// Seam between strategy logic and the wasi:http transport: -/// strategies take `&impl Fetch` and tests slot in a stub; module glue -/// passes [`WasiFetch`]. -pub trait Fetch { - /// Perform one request with `options`, blocking until the response - /// body is fully buffered. - fn fetch_with( - &self, - request: http::Request>, - options: FetchOptions, - ) -> Result>, FetchError>; - - /// Perform one request with [`FetchOptions::default`]. - fn fetch( - &self, - request: http::Request>, - ) -> Result>, FetchError> { - self.fetch_with(request, FetchOptions::default()) - } -} - -/// A shared reference forwards, so middleware can borrow its transport. -impl Fetch for &F { - fn fetch_with( - &self, - request: http::Request>, - options: FetchOptions, - ) -> Result>, FetchError> { - (**self).fetch_with(request, options) - } -} - -/// [`Fetch`] adapter over the host's wasi:http outgoing handler. -/// -/// Guest-only glue: the type exists on every target so module -/// `lib.rs` glue compiles host-side for unit tests, but calling -/// [`Fetch::fetch_with`] off the wasm guest is unimplemented. -#[derive(Clone, Copy, Debug, Default)] -pub struct WasiFetch; - -impl Fetch for WasiFetch { - #[cfg(all(target_arch = "wasm32", target_os = "wasi"))] - fn fetch_with( - &self, - request: http::Request>, - options: FetchOptions, - ) -> Result>, FetchError> { - fetch_with(request, options) - } - - #[cfg(not(all(target_arch = "wasm32", target_os = "wasi")))] - fn fetch_with( - &self, - _request: http::Request>, - _options: FetchOptions, - ) -> Result>, FetchError> { - unimplemented!("wasi:http fetch is only available in a wasm32-wasip2 guest") - } -} - -#[cfg(all(target_arch = "wasm32", target_os = "wasi"))] -mod wasi_impl { - use wstd::http::ErrorCode; - - use super::{FetchError, FetchOptions}; - - /// Perform `request` with [`FetchOptions::default`]. - pub fn fetch(request: http::Request>) -> Result>, FetchError> { - fetch_with(request, FetchOptions::default()) - } - - /// Perform `request` through the host's wasi:http outgoing - /// handler, blocking the (single-threaded) guest until the - /// response body is fully buffered. The buffered body is bounded - /// only by the module's memory limit. - pub fn fetch_with( - request: http::Request>, - options: FetchOptions, - ) -> Result>, FetchError> { - wstd::runtime::block_on(fetch_async(request, options)) - } - - async fn fetch_async( - request: http::Request>, - options: FetchOptions, - ) -> Result>, FetchError> { - let mut client = wstd::http::Client::new(); - client.set_connect_timeout(options.connect_timeout); - client.set_first_byte_timeout(options.first_byte_timeout); - client.set_between_bytes_timeout(options.between_bytes_timeout); - - let response = client.send(request).await.map_err(map_error)?; - let (parts, mut body) = response.into_parts(); - let bytes = body.bytes_contents().await.map_err(map_error)?; - Ok(http::Response::from_parts(parts, bytes.to_vec())) - } - - /// Fold the wasi:http error code carried inside the client error - /// into [`FetchError`]. Codes that do not identify a policy, - /// timeout, or request-shape failure are transport failures. - fn map_error(error: wstd::http::Error) -> FetchError { - let Some(code) = error.downcast_ref::() else { - return FetchError::Transport(format!("{error:#}")); - }; - match code { - ErrorCode::HttpRequestDenied => FetchError::Denied, - ErrorCode::DnsTimeout - | ErrorCode::ConnectionTimeout - | ErrorCode::ConnectionReadTimeout - | ErrorCode::ConnectionWriteTimeout - | ErrorCode::HttpResponseTimeout => FetchError::Timeout(code.to_string()), - ErrorCode::HttpRequestMethodInvalid - | ErrorCode::HttpRequestUriInvalid - | ErrorCode::HttpRequestUriTooLong - | ErrorCode::HttpRequestLengthRequired => FetchError::InvalidRequest(code.to_string()), - other => FetchError::Transport(other.to_string()), - } - } -} - -#[cfg(all(target_arch = "wasm32", target_os = "wasi"))] -pub use wasi_impl::{fetch, fetch_with}; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_options_apply_the_default_timeout_per_phase() { - let opts = FetchOptions::default(); - assert_eq!(opts.connect_timeout, DEFAULT_TIMEOUT); - assert_eq!(opts.first_byte_timeout, DEFAULT_TIMEOUT); - assert_eq!(opts.between_bytes_timeout, DEFAULT_TIMEOUT); - } - - #[test] - fn fetch_error_labels_are_snake_case() { - assert_eq!(<&'static str>::from(&FetchError::Denied), "denied"); - assert_eq!( - <&'static str>::from(&FetchError::Transport("x".into())), - "transport" - ); - } - - /// The default [`Fetch::fetch`] must delegate to `fetch_with` with - /// default options, so a stub can observe both the request and the - /// options it was handed. - #[test] - fn fetch_delegates_to_fetch_with_default_options() { - use core::cell::Cell; - - struct Spy { - seen: Cell>, - } - - impl Fetch for Spy { - fn fetch_with( - &self, - _request: http::Request>, - options: FetchOptions, - ) -> Result>, FetchError> { - self.seen.set(Some(options)); - Ok(http::Response::new(Vec::new())) - } - } - - let spy = Spy { - seen: Cell::new(None), - }; - let request = http::Request::get("https://api.cow.fi/") - .body(Vec::new()) - .unwrap(); - spy.fetch(request).unwrap(); - assert_eq!(spy.seen.get(), Some(FetchOptions::default())); - } -} diff --git a/nexum/crates/nexum-sdk/src/keeper.rs b/nexum/crates/nexum-sdk/src/keeper.rs deleted file mode 100644 index 726bdae4..00000000 --- a/nexum/crates/nexum-sdk/src/keeper.rs +++ /dev/null @@ -1,447 +0,0 @@ -//! Strategy-keeper stores: the persistent-state conventions shared by -//! conditional-commitment modules, expressed over [`LocalStoreHost`] -//! alone so they compile for any world and test against the in-memory -//! mocks. -//! -//! Private, single-tenant instance over the wallet's own authorised -//! orders; it submits intents to the venue and does not settle them. -//! This is not a public keeper network, fee marketplace, or MEV -//! searcher. -//! -//! Three stores cover the machinery watcher modules hand-roll: -//! -//! - [`WatchSet`] - the watch-set registry, one `watch:{owner}:{hash}` -//! row per conditional commitment. -//! - [`Gates`] - `next_block:` / `next_epoch:` gate keys holding a -//! u64 little-endian threshold, with an -//! [`is_ready`](Gates::is_ready) predicate the poll loop consults. -//! - [`Journal`] - the receipt-keyed idempotency journal of -//! `submitted:` / `observed:` presence markers. -//! -//! Two pieces drive the stores from the poll loop: -//! -//! - [`ConditionalSource`] - the world-neutral poll seam: one watch in, -//! one outcome out, at a given [`Tick`]. Implementations own the -//! transport and the outcome shape. -//! - [`Retrier`] - runs a [`RetryAction`]'s effect through the -//! stores after a failed keeper run attempt. -//! -//! [`WatchRef`] ties the first two together: gate and marker keys are -//! derived from the exact hex substrings of the stored watch key, and -//! [`WatchSet::remove`] drops a watch together with all of its derived -//! keys so no failure path can orphan one. -//! -//! ``` -//! use nexum_sdk::keeper::{Gates, Journal, WatchRef, WatchSet}; -//! use nexum_sdk::host::{Fault, LocalStoreHost}; -//! use nexum_sdk::prelude::*; -//! -//! # use std::cell::RefCell; -//! # use std::collections::BTreeMap; -//! # #[derive(Default)] -//! # struct StubStore(RefCell>>); -//! # impl LocalStoreHost for StubStore { -//! # fn get(&self, key: &str) -> Result>, Fault> { -//! # Ok(self.0.borrow().get(key).cloned()) -//! # } -//! # fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { -//! # self.0.borrow_mut().insert(key.into(), value.into()); -//! # Ok(()) -//! # } -//! # fn delete(&self, key: &str) -> Result<(), Fault> { -//! # self.0.borrow_mut().remove(key); -//! # Ok(()) -//! # } -//! # fn list_keys(&self, prefix: &str) -> Result, Fault> { -//! # Ok(self -//! # .0 -//! # .borrow() -//! # .keys() -//! # .filter(|k| k.starts_with(prefix)) -//! # .cloned() -//! # .collect()) -//! # } -//! # } -//! let host = StubStore::default(); -//! let watches = WatchSet::new(&host); -//! let key = watches.put(&Address::ZERO, &B256::ZERO, b"params")?; -//! let watch = WatchRef::parse(&key).expect("well-formed key"); -//! -//! let gates = Gates::new(&host); -//! gates.set_next_block(watch, 100)?; -//! assert!(!gates.is_ready(watch, 99, 0)?); -//! assert!(gates.is_ready(watch, 100, 0)?); -//! -//! let journal = Journal::submitted(&host); -//! journal.record("0xuid")?; -//! assert!(journal.contains("0xuid")?); -//! -//! watches.remove(watch)?; -//! assert!(watches.list()?.is_empty()); -//! # Ok::<(), Fault>(()) -//! ``` - -use alloy_primitives::{Address, B256}; -use strum::IntoStaticStr; - -use crate::host::{Fault, LocalStoreHost}; - -/// Prefix of every watch-set row. -pub const WATCH_PREFIX: &str = "watch:"; -/// Prefix of the block-height gate row paired with a watch. -pub const NEXT_BLOCK_PREFIX: &str = "next_block:"; -/// Prefix of the Unix-seconds gate row paired with a watch. -pub const NEXT_EPOCH_PREFIX: &str = "next_epoch:"; -/// Journal prefix for receipts the module posted upstream itself: the -/// submit path recorded that it has sent an order on. -pub const SUBMITTED_PREFIX: &str = "submitted:"; -/// Journal prefix for receipts the module confirmed but did not post: -/// the observe-and-verify path (e.g. ethflow) recorded an existing -/// upstream order as seen. -pub const OBSERVED_PREFIX: &str = "observed:"; -/// Prefix of the first-refusal marker paired with a watch: the block a -/// [`RetryAction::DropOnRepeat`] refusal was first seen at, u64 -/// little-endian. -pub const REFUSED_PREFIX: &str = "refused:"; - -/// Canonical watch key for an owner / commitment-hash pair (lowercase -/// `0x`-prefixed hex on both halves). Free-standing because the key -/// shape is a property of the store convention, not of any host. -#[must_use] -pub fn watch_key(owner: &Address, hash: &B256) -> String { - format!("{WATCH_PREFIX}{owner:#x}:{hash:#x}") -} - -/// Borrowed view of a watch key's two hex halves, parsed from a -/// `watch:{owner}:{hash}` row. Gate keys are derived from the exact -/// substrings of the stored key, so a parse-then-derive round trip is -/// byte-stable regardless of how the original writer cased the hex. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct WatchRef<'k> { - owner_hex: &'k str, - hash_hex: &'k str, -} - -impl<'k> WatchRef<'k> { - /// Parse a `watch:{owner}:{hash}` key. `None` when the prefix or - /// the separating colon is missing, or when either half is empty - /// (an empty half would derive a degenerate gate key like - /// `next_block::`). - pub fn parse(key: &'k str) -> Option { - let rest = key.strip_prefix(WATCH_PREFIX)?; - let (owner_hex, hash_hex) = rest.split_once(':')?; - if owner_hex.is_empty() || hash_hex.is_empty() { - return None; - } - Some(Self { - owner_hex, - hash_hex, - }) - } - - /// The owner half, verbatim from the key. - pub fn owner_hex(&self) -> &'k str { - self.owner_hex - } - - /// The commitment-hash half, verbatim from the key. - pub fn hash_hex(&self) -> &'k str { - self.hash_hex - } - - /// Rebuild the full watch key. - pub fn key(&self) -> String { - format!("{WATCH_PREFIX}{}:{}", self.owner_hex, self.hash_hex) - } - - /// The `next_block:` gate key paired with this watch. - pub fn next_block_key(&self) -> String { - format!("{NEXT_BLOCK_PREFIX}{}:{}", self.owner_hex, self.hash_hex) - } - - /// The `next_epoch:` gate key paired with this watch. - pub fn next_epoch_key(&self) -> String { - format!("{NEXT_EPOCH_PREFIX}{}:{}", self.owner_hex, self.hash_hex) - } - - /// The `refused:` first-refusal marker key paired with this watch. - pub fn refused_key(&self) -> String { - format!("{REFUSED_PREFIX}{}:{}", self.owner_hex, self.hash_hex) - } -} - -/// Watch-set registry: one row per conditional commitment, keyed -/// `watch:{owner}:{hash}` with the encoded commitment parameters as -/// the value. -pub struct WatchSet<'h, H> { - host: &'h H, -} - -impl<'h, H: LocalStoreHost> WatchSet<'h, H> { - /// Registry view over the given host. - pub fn new(host: &'h H) -> Self { - Self { host } - } - - /// Canonical key for an owner / commitment-hash pair. Thin - /// delegate kept for discoverability; prefer the free - /// [`watch_key`], which needs no host turbofish. - pub fn key(owner: &Address, hash: &B256) -> String { - watch_key(owner, hash) - } - - /// Insert or overwrite the watch row; returns the key written. - /// Overwriting in place makes re-indexing a replayed log a no-op. - pub fn put(&self, owner: &Address, hash: &B256, value: &[u8]) -> Result { - let key = Self::key(owner, hash); - self.host.set(&key, value)?; - Ok(key) - } - - /// The stored value. `Ok(None)` when the watch is absent. - pub fn get(&self, watch: WatchRef<'_>) -> Result>, Fault> { - self.host.get(&watch.key()) - } - - /// Every watch key currently registered. - pub fn list(&self) -> Result, Fault> { - self.host.list_keys(WATCH_PREFIX) - } - - /// Drop the watch together with its gate and refusal keys. The - /// derived keys go first: a fault part-way leaves the watch row - /// behind so a retry re-drops it, and a derived key can never - /// outlive its watch. - pub fn remove(&self, watch: WatchRef<'_>) -> Result<(), Fault> { - Gates::new(self.host).clear(watch)?; - self.host.delete(&watch.refused_key())?; - self.host.delete(&watch.key()) - } -} - -/// Gate-key discipline: `next_block:{owner}:{hash}` and -/// `next_epoch:{owner}:{hash}` rows holding a u64 little-endian -/// threshold. A malformed or absent row reads as "no gate", so a -/// corrupt value can only make a watch poll sooner, never wedge it. -pub struct Gates<'h, H> { - host: &'h H, -} - -impl<'h, H: LocalStoreHost> Gates<'h, H> { - /// Gate view over the given host. - pub fn new(host: &'h H) -> Self { - Self { host } - } - - /// Skip polls until the chain reaches `block`. - pub fn set_next_block(&self, watch: WatchRef<'_>, block: u64) -> Result<(), Fault> { - self.host.set(&watch.next_block_key(), &block.to_le_bytes()) - } - - /// Skip polls until the Unix-seconds clock reaches `epoch_s`. - pub fn set_next_epoch(&self, watch: WatchRef<'_>, epoch_s: u64) -> Result<(), Fault> { - self.host - .set(&watch.next_epoch_key(), &epoch_s.to_le_bytes()) - } - - /// Whether the watch is clear to poll at the given block height - /// and Unix-seconds timestamp. Both gates must pass; each is - /// inclusive at its threshold. - #[must_use = "the readiness verdict gates the poll; `?` alone drops the inner bool"] - pub fn is_ready(&self, watch: WatchRef<'_>, block: u64, epoch_s: u64) -> Result { - if let Some(next) = read_u64(self.host, &watch.next_block_key())? - && block < next - { - return Ok(false); - } - if let Some(next) = read_u64(self.host, &watch.next_epoch_key())? - && epoch_s < next - { - return Ok(false); - } - Ok(true) - } - - /// Delete both gate keys. No-op for gates never set. - pub fn clear(&self, watch: WatchRef<'_>) -> Result<(), Fault> { - self.host.delete(&watch.next_block_key())?; - self.host.delete(&watch.next_epoch_key()) - } -} - -/// Little-endian u64 row read shared by the gate and refusal-marker -/// keys. Absent key: silently `None`. Present but wrong length: the -/// value is corrupt, so warn before falling open to `None` - fail-open -/// is deliberate (a corrupt value can only make the watch act sooner, -/// never wedge it), but it must not pass unobserved. -fn read_u64(host: &H, key: &str) -> Result, Fault> { - let Some(b) = host.get(key)? else { - return Ok(None); - }; - match <[u8; 8]>::try_from(b.as_slice()) { - Ok(bytes) => Ok(Some(u64::from_le_bytes(bytes))), - Err(_) => { - tracing::warn!(%key, len = b.len(), "stored value corrupt; treating as absent"); - Ok(None) - } - } -} - -/// Receipt-keyed idempotency journal: presence markers under a fixed -/// prefix. The marker value is empty - presence of the key is the -/// receipt - so re-recording is idempotent by construction. -pub struct Journal<'h, H> { - host: &'h H, - prefix: &'static str, -} - -impl<'h, H: LocalStoreHost> Journal<'h, H> { - /// Journal of receipts this module has submitted upstream - /// (`submitted:` markers). - pub fn submitted(host: &'h H) -> Self { - Self { - host, - prefix: SUBMITTED_PREFIX, - } - } - - /// Journal of receipts this module has observed upstream - /// (`observed:` markers). - pub fn observed(host: &'h H) -> Self { - Self { - host, - prefix: OBSERVED_PREFIX, - } - } - - /// Record the receipt. - pub fn record(&self, receipt: &str) -> Result<(), Fault> { - self.host.set(&format!("{}{receipt}", self.prefix), b"") - } - - /// Whether the receipt is already journalled. - pub fn contains(&self, receipt: &str) -> Result { - Ok(self - .host - .get(&format!("{}{receipt}", self.prefix))? - .is_some()) - } -} - -/// One poll dispatch's world view: chain, block height, and the block -/// clock in Unix seconds. Gate checks and backoff arithmetic read the -/// same instant a source is polled at, so a watch can never gate -/// itself against a clock it was not judged by. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct Tick { - /// Chain the dispatch targets. - pub chain_id: u64, - /// Block height at the tick. - pub block: u64, - /// Block timestamp, Unix seconds. - pub epoch_s: u64, -} - -/// A source of conditional commitments: poll one watch, produce one -/// outcome. Generic over the host so implementations stay mock- -/// testable; deliberately no venue-transport abstraction - the source -/// owns its own wire (an `eth_call`, an HTTP probe, a stub). -/// -/// A transient failure should surface as a retry-flavoured outcome, -/// not tear down the caller's sweep: `poll` is infallible by contract. -pub trait ConditionalSource { - /// What one poll produces. - type Outcome; - - /// Poll the source for `watch` at `tick`. `params` is the stored - /// watch value (the encoded commitment parameters), passed - /// verbatim so the source owns the decode. - fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Self::Outcome; - - /// Short strategy name compositions prefix shared log lines with - /// (for example `"twap"`). Diagnostic only - no behaviour keys - /// off it. - fn label(&self) -> &'static str { - "conditional" - } -} - -/// What the retry ledger should do to a watch after a failed -/// keeper run attempt. -/// -/// `IntoStaticStr` exposes each variant as a snake_case `&'static -/// str` for log and metric labels. `#[non_exhaustive]` so the -/// contract can grow a variant; downstream dispatch should treat an -/// unknown variant as "leave the watch in place" (the conservative -/// choice). -#[derive(Clone, Copy, Debug, Eq, PartialEq, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum RetryAction { - /// Leave the watch untouched; the next tick re-attempts. - TryNextBlock, - /// Gate the watch until `now + seconds` on the epoch clock. - Backoff { - /// Seconds to wait before retrying. - seconds: u64, - }, - /// Grant one next-block retry: the first application records the - /// block and gates the watch to the next one; a repeat at a later - /// block removes the watch. - DropOnRepeat, - /// Remove the watch and its gates; no retry can succeed. - Drop, -} - -/// Retry ledger: runs a [`RetryAction`]'s effect through the keeper -/// stores. `Backoff` saturates at `u64::MAX` on the epoch clock; -/// `DropOnRepeat` keeps a `refused:` block marker so only a repeat on -/// a later block removes the watch; `Drop` delegates to -/// [`WatchSet::remove`], so derived keys go first and no failure path -/// can orphan one. -pub struct Retrier<'h, H> { - host: &'h H, -} - -impl<'h, H: LocalStoreHost> Retrier<'h, H> { - /// Ledger view over the given host. - pub fn new(host: &'h H) -> Self { - Self { host } - } - - /// Apply `action` to the watch at `tick`: the backoff origin and - /// the repeat-detection block both read from it. - pub fn apply( - &self, - watch: WatchRef<'_>, - action: RetryAction, - tick: &Tick, - ) -> Result<(), Fault> { - match action { - RetryAction::TryNextBlock => Ok(()), - RetryAction::Backoff { seconds } => { - Gates::new(self.host).set_next_epoch(watch, tick.epoch_s.saturating_add(seconds)) - } - RetryAction::DropOnRepeat => { - let key = watch.refused_key(); - match read_u64(self.host, &key)? { - Some(block) if tick.block > block => WatchSet::new(self.host).remove(watch), - Some(_) => Ok(()), - None => { - self.host.set(&key, &tick.block.to_le_bytes())?; - Gates::new(self.host).set_next_block(watch, tick.block.saturating_add(1)) - } - } - } - RetryAction::Drop => WatchSet::new(self.host).remove(watch), - } - } - - /// Clear the watch's first-refusal marker: an accepted submit ends - /// the refusal episode, so a later [`RetryAction::DropOnRepeat`] - /// refusal earns a fresh one-block grace. No-op when no marker is - /// set. - pub fn clear_refusal(&self, watch: WatchRef<'_>) -> Result<(), Fault> { - self.host.delete(&watch.refused_key()) - } -} diff --git a/nexum/crates/nexum-sdk/src/lib.rs b/nexum/crates/nexum-sdk/src/lib.rs deleted file mode 100644 index b766be1f..00000000 --- a/nexum/crates/nexum-sdk/src/lib.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! # nexum-sdk -//! -//! Guest-side SDK for nexum runtime modules. The helpers here are -//! host-neutral and domain-free: any module targeting the runtime can -//! use them regardless of which world it exports. Domain layers such as -//! the CoW SDK depend on this crate and add their own surface on top. -//! -//! The crate is the shared companion to the per-module -//! `wit_bindgen::generate!` invocation: modules keep their own -//! wit-bindgen call (which emits the world-specific `Guest` trait, -//! `Fault` shape, and host import shims into the module's own -//! crate) and pull helpers and canonical primitive types from here. -//! -//! ## What lives here -//! -//! - [`prelude`] - `use nexum_sdk::prelude::*` imports the alloy -//! primitives ([`Address`], [`B256`], [`Bytes`], [`U256`], -//! [`keccak256`]). -//! -//! - [`host`] - host trait seam: [`Host`] bundling all six core -//! interfaces ([`ChainHost`] / [`IdentityHost`] / [`LocalStoreHost`] -//! / [`RemoteStoreHost`] / [`MessagingHost`] / [`LoggingHost`]) plus -//! the host-neutral [`Fault`] vocabulary. Modules that want -//! host-free tests structure their strategy logic against these -//! traits and slot in the `nexum-sdk-test` mocks. See the host -//! module docs for the wit-bindgen adapter pattern. -//! -//! - [`bind_host_via_wit_bindgen!`](crate::bind_host_via_wit_bindgen) - -//! generates the per-module `WitBindgenHost` adapter over the -//! wit-bindgen import shims. -//! -//! - [`module`] - attribute macro that generates the whole per-cdylib -//! glue (the wit-bindgen call, the adapter above, the -//! `Guest`/`on-event` dispatch, and `export!`) from an `impl` block of -//! named handlers. -//! -//! - [`keeper`] - strategy-keeper stores over [`LocalStoreHost`]: -//! the watch-set registry ([`WatchSet`]), block/epoch gate keys -//! ([`Gates`]) and the receipt-keyed idempotency journal -//! ([`Journal`]); plus the [`ConditionalSource`] poll seam and the -//! [`Retrier`] dispatching a [`RetryAction`] through the stores. -//! -//! - [`chain`] - typed chain access: [`Chain`] / [`ChainId`] newtypes, -//! the closed [`ChainMethod`] read surface, and the alloy provider -//! seam ([`HostTransport`], [`provider`], [`block_on`]); plus -//! `eth_call` JSON plumbing ([`eth_call_params`], -//! [`parse_eth_call_result`]) and the Chainlink AggregatorV3 reader -//! ([`read_latest_answer`]). -//! -//! - [`events`] - chain-log delivery: the native alloy [`Log`] modules -//! handle plus [`ChainLogParts`], the WIT-edge `From` input the bind -//! macro fills to rebuild it from the wire record. -//! -//! - [`status_body`] - decoder for the opaque versioned status body an -//! `intent-status` event carries ([`StatusBody`]). -//! -//! - [`config`] - `(key, value)` config-table lookups and decimal -//! scaling ([`get_required`], [`get_optional`], [`scale_decimal`]). -//! -//! - [`address`] - EVM address parsing with typed errors -//! ([`parse_address`], [`parse_address_list`]). -//! -//! - [`http`] - outbound HTTP over wasi:http in the standard `http` -//! crate's request/response vocabulary: a synchronous [`fetch`] -//! helper (guest target only), the [`Fetch`] trait seam for host-free -//! strategy tests, and a [`FetchError`] that distinguishes allowlist -//! denials from transport failures. -//! -//! - [`tracing`] - guest-side `tracing` facade: an events-only -//! subscriber plus panic hook that forward through a [`LogSink`] -//! seam. The bind macro wires the bound host logging call into the -//! sink so module authors emit `tracing::info!(...)` with no host -//! parameter to thread. -//! -//! ## Why no `wit_bindgen::generate!` here -//! -//! The macro emits types into the calling crate (the module's -//! cdylib). Re-exporting wit-bindgen output from a library crate -//! would duplicate symbols and break the component-export contract. -//! Helpers in this SDK therefore take primitive types (`&[u8]`, -//! `Option<&str>`, slices) rather than the per-module `Fault` -//! type; modules unpack their `Fault` on the way in. -//! -//! [`Address`]: alloy_primitives::Address -//! [`B256`]: alloy_primitives::B256 -//! [`Bytes`]: alloy_primitives::Bytes -//! [`U256`]: alloy_primitives::U256 -//! [`keccak256`]: alloy_primitives::keccak256 -//! [`Host`]: host::Host -//! [`ChainHost`]: host::ChainHost -//! [`IdentityHost`]: host::IdentityHost -//! [`LocalStoreHost`]: host::LocalStoreHost -//! [`RemoteStoreHost`]: host::RemoteStoreHost -//! [`MessagingHost`]: host::MessagingHost -//! [`LoggingHost`]: host::LoggingHost -//! [`Fault`]: host::Fault -//! [`WatchSet`]: keeper::WatchSet -//! [`Gates`]: keeper::Gates -//! [`Journal`]: keeper::Journal -//! [`ConditionalSource`]: keeper::ConditionalSource -//! [`Retrier`]: keeper::Retrier -//! [`RetryAction`]: keeper::RetryAction -//! [`Chain`]: chain::Chain -//! [`ChainId`]: chain::ChainId -//! [`ChainMethod`]: chain::ChainMethod -//! [`HostTransport`]: chain::HostTransport -//! [`provider`]: chain::ProviderHost::provider -//! [`block_on`]: chain::block_on -//! [`eth_call_params`]: chain::eth_call_params -//! [`parse_eth_call_result`]: chain::parse_eth_call_result -//! [`read_latest_answer`]: chain::chainlink::read_latest_answer -//! [`Log`]: events::Log -//! [`ChainLogParts`]: events::ChainLogParts -//! [`StatusBody`]: status_body::StatusBody -//! [`get_required`]: config::get_required -//! [`get_optional`]: config::get_optional -//! [`scale_decimal`]: config::scale_decimal -//! [`parse_address`]: address::parse_address -//! [`parse_address_list`]: address::parse_address_list -//! [`fetch`]: http::Fetch::fetch -//! [`Fetch`]: http::Fetch -//! [`FetchError`]: http::FetchError -//! [`LogSink`]: tracing::LogSink - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![warn(missing_docs)] -#![cfg_attr(docsrs, feature(doc_cfg))] - -/// Generate the per-cdylib module glue (wit-bindgen, host adapter, -/// `Guest`/`on-event` dispatch, `export!`) from an `impl` block of named -/// handlers. See [`nexum_module_macros::module`]. -pub use nexum_module_macros::module; - -pub mod address; -pub mod chain; -pub mod config; -pub mod events; -pub mod host; -pub mod http; -pub mod keeper; -pub mod prelude; -pub mod tracing; -pub mod wit_bindgen_macro; - -/// The opaque status-body codec: decode an `intent-status` event's -/// `status` bytes into a typed [`StatusBody`](status_body::StatusBody). -pub use nexum_status_body as status_body; - -/// The level vocabulary for every SDK log path: the host logging trait, -/// the guest tracing facade sink, and the module mocks all speak -/// `tracing_core::Level`. Its `Ord` is filter-oriented (`ERROR` is the -/// least verbose), so compare with that in mind rather than as severity. -pub use tracing_core::Level; - -#[cfg(test)] -mod proptests; diff --git a/nexum/crates/nexum-sdk/src/prelude.rs b/nexum/crates/nexum-sdk/src/prelude.rs deleted file mode 100644 index b72a6b04..00000000 --- a/nexum/crates/nexum-sdk/src/prelude.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Bulk-imports the primitives every module uses on every other line. -//! `use nexum_sdk::prelude::*` covers the alloy address / hash / -//! numeric types the chain helpers consume. -//! -//! The wit-bindgen-generated types (`Guest`, `Fault`, `Event`, ...) -//! are **not** re-exported here because they live in each module's own -//! crate (one `wit_bindgen::generate!` call per cdylib). Domain SDKs -//! ship their own prelude for their protocol surface. - -pub use alloy_primitives::{Address, B256, Bytes, Signature, U256, address, b256, hex, keccak256}; diff --git a/nexum/crates/nexum-sdk/src/proptests.rs b/nexum/crates/nexum-sdk/src/proptests.rs deleted file mode 100644 index 2a4709e7..00000000 --- a/nexum/crates/nexum-sdk/src/proptests.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Property-based regression tests for the SDK's codec round-trips -//! and validation functions. Lives behind `#[cfg(test)]` so neither -//! the wasm32-wasip2 builds nor downstream consumers pay the -//! proptest dep cost. -//! -//! Covered here: -//! -//! - `eth_call_params` / `parse_eth_call_result` round-trip. -//! - `config::scale_decimal` decimal scaling round-trip. -//! - `U256` little-endian byte round-trip (mirrored from -//! `balance-tracker`'s persistence path). -//! -//! The CoW-domain properties (`decode_revert`, the -//! `gpv2_to_order_data` marker guard) live in `composable-cow` and -//! `cow-venue`. - -#![cfg(test)] - -use alloy_primitives::{Address, I256, U256}; -use proptest::prelude::*; - -use crate::chain::{eth_call_params, parse_eth_call_result}; -use crate::config; - -// ---- generators --------------------------------------------------- - -fn any_address() -> impl Strategy { - proptest::array::uniform20(any::()).prop_map(Address::from) -} - -fn any_u256() -> impl Strategy { - proptest::array::uniform32(any::()).prop_map(U256::from_be_bytes) -} - -/// Decimal-string generator: positive or negative, with or without a -/// fractional part, between 0 and 38 fractional digits. -fn decimal_string() -> impl Strategy { - ( - any::(), - 0u128..=u64::MAX as u128, - 0u32..=18, - 0u32..=18, - ) - .prop_map(|(sign, whole, frac_len, decimals)| { - let frac = if frac_len == 0 { - String::new() - } else { - let modulo = 10u128.pow(frac_len); - let frac_val = whole.checked_rem(modulo).unwrap_or(0); - format!("{:0>width$}", frac_val, width = frac_len as usize) - }; - let value = if frac.is_empty() { - format!("{}{whole}", if sign { "-" } else { "" }) - } else { - format!("{}{whole}.{frac}", if sign { "-" } else { "" }) - }; - (value, decimals) - }) -} - -// ---- properties --------------------------------------------------- - -proptest! { - /// `eth_call_params(to, data)` produces a JSON string that - /// alloy's transport will accept; `parse_eth_call_result` round- - /// trips through any 0x-prefixed hex blob the result field can - /// carry. - #[test] - fn eth_call_round_trip_hex( - addr in any_address(), - body in proptest::collection::vec(any::(), 0..512), - ) { - let params = eth_call_params(&addr, &body); - // Params must contain the address (case-insensitive 0x-prefixed). - let addr_lower = format!("{:#x}", addr); - prop_assert!( - params.to_ascii_lowercase().contains(&addr_lower), - "params={params:?} missing addr={addr_lower}" - ); - // Round-trip the body bytes back through parse_eth_call_result - // by simulating the JSON-RPC result wrapping. - let result_json = format!("\"0x{}\"", alloy_primitives::hex::encode(&body)); - let parsed = parse_eth_call_result(&result_json).expect("hex parses"); - prop_assert_eq!(parsed, body); - } - - /// `parse_eth_call_result` returns `None` on a non-quoted or - /// non-hex shape. Catches accidental "string contains 0x" - /// false positives. - #[test] - fn parse_eth_call_result_rejects_unquoted( - s in "[a-zA-Z0-9]{0,32}", - ) { - // Anything without surrounding quotes must be None. - prop_assert!(parse_eth_call_result(&s).is_none() || s.starts_with('"')); - } - - /// `config::scale_decimal` round-trips: scaling by 10^d then - /// reversing the integer division reproduces the unsigned - /// portion. The reverse uses I256 to U256 cast guarded by sign. - #[test] - fn scale_decimal_round_trip( - (value, decimals) in decimal_string(), - ) { - let scaled = match config::scale_decimal(&value, decimals, "v") { - Ok(s) => s, - Err(_) => return Ok(()), // generator can emit out-of-range; that's OK - }; - // Reverse: divide by 10^decimals; should match the integer - // part of `value` (modulo sign). - let denom = U256::from(10u128).checked_pow(U256::from(decimals)).expect("fits"); - let unsigned: U256 = scaled.unsigned_abs(); - let reconstructed_whole = unsigned / denom; - let value_unsigned = value.trim_start_matches('-'); - let (expected_whole, _) = value_unsigned.split_once('.').unwrap_or((value_unsigned, "")); - let expected = expected_whole.parse::().expect("generator: whole parses"); - prop_assert_eq!( - reconstructed_whole, - expected, - "{}", - format!("value={value} decimals={decimals} scaled={scaled}"), - ); - // Sign matches. - if value.starts_with('-') && scaled != I256::ZERO { - prop_assert!(scaled.is_negative(), "{}", format!("expected negative for {value}")); - } else { - prop_assert!( - !scaled.is_negative() || scaled == I256::ZERO, - "{}", - format!("expected non-negative for {value}"), - ); - } - } - - /// `U256` round-trips through little-endian 32-byte - /// serialisation. Mirrored from balance-tracker's persistence - /// path; the SDK does not own this function but the property - /// belongs here since the same shape is reused across modules. - #[test] - fn u256_le_round_trip(v in any_u256()) { - let bytes = v.to_le_bytes::<32>(); - let back = U256::from_le_bytes(bytes); - prop_assert_eq!(v, back); - } -} diff --git a/nexum/crates/nexum-sdk/src/tracing.rs b/nexum/crates/nexum-sdk/src/tracing.rs deleted file mode 100644 index 81d1bb43..00000000 --- a/nexum/crates/nexum-sdk/src/tracing.rs +++ /dev/null @@ -1,286 +0,0 @@ -//! Guest-side `tracing` facade: routes `tracing` events to a host log -//! sink so module authors write `tracing::info!(...)` with no host -//! parameter to thread. -//! -//! The subscriber is events-only: it renders each event's fields into -//! one line and forwards it at the event's [`Level`]. Spans are inert -//! no-ops. It links `tracing-core` alone, not the subscriber registry, -//! so the wasm module stays small. -//! -//! The [`init`] call also installs a panic hook that writes the panic -//! to stderr and then reports it over the same sink. Stderr is written -//! first on purpose: host-side stderr capture still records the panic -//! even if the sink's host call traps before `panic = abort` fires. - -use core::fmt::{self, Write as _}; -use core::sync::atomic::{AtomicU64, Ordering}; -use std::panic::PanicHookInfo; -use std::sync::Arc; - -use tracing_core::field::{Field, Visit}; -use tracing_core::span::{Attributes, Id, Record}; -use tracing_core::{Event, Level, LevelFilter, Metadata, Subscriber}; - -/// Sink the facade forwards rendered events to. Implementors carry the -/// bound host logging call; the host decides how each line is handled. -pub trait LogSink: Send + Sync { - /// Forward one rendered line at `level`. - fn log(&self, level: Level, message: &str); -} - -/// Install the facade as the global subscriber and register the panic -/// hook, both forwarding to `sink`. The subscriber is set once; a -/// second call leaves it in place and only re-registers the panic hook. -pub fn init(sink: impl LogSink + 'static) { - let sink: Arc = Arc::new(sink); - let dispatch = tracing_core::Dispatch::new(FacadeSubscriber::new(Arc::clone(&sink))); - // A second install is a no-op: the global default is set once. - let _ = tracing_core::dispatcher::set_global_default(dispatch); - set_panic_hook(sink); -} - -/// Build the events-only subscriber over `sink` without touching global -/// state. Test harnesses scope it with `tracing::subscriber::with_default`. -pub fn subscriber(sink: impl LogSink + 'static) -> impl Subscriber { - FacadeSubscriber::new(Arc::new(sink)) -} - -fn set_panic_hook(sink: Arc) { - std::panic::set_hook(Box::new(move |info| { - let message = format_panic( - &panic_payload(info), - info.location().map(|l| (l.file(), l.line())), - ); - // stderr first: host-side stderr capture still records the panic - // even if the sink's host call traps before `panic = abort` fires. - eprintln!("{message}"); - sink.log(Level::ERROR, &message); - })); -} - -fn panic_payload(info: &PanicHookInfo<'_>) -> String { - if let Some(s) = info.payload().downcast_ref::<&str>() { - (*s).to_owned() - } else if let Some(s) = info.payload().downcast_ref::() { - s.clone() - } else { - "".to_owned() - } -} - -/// Render a panic into the reported line. Pure so it is unit-testable -/// apart from the abort path. -fn format_panic(payload: &str, location: Option<(&str, u32)>) -> String { - match location { - Some((file, line)) => format!("panic: {payload} at {file}:{line}"), - None => format!("panic: {payload}"), - } -} - -struct FacadeSubscriber { - sink: Arc, - next_id: AtomicU64, -} - -impl FacadeSubscriber { - fn new(sink: Arc) -> Self { - Self { - sink, - next_id: AtomicU64::new(0), - } - } -} - -impl Subscriber for FacadeSubscriber { - fn enabled(&self, _metadata: &Metadata<'_>) -> bool { - // Forward everything; the host applies its own filter. - true - } - - fn max_level_hint(&self) -> Option { - Some(LevelFilter::TRACE) - } - - fn new_span(&self, _span: &Attributes<'_>) -> Id { - // Spans are inert, but a valid non-zero id must be returned. - let raw = self.next_id.fetch_add(1, Ordering::Relaxed).wrapping_add(1); - Id::from_u64(raw.max(1)) - } - - fn record(&self, _span: &Id, _values: &Record<'_>) {} - - fn record_follows_from(&self, _span: &Id, _follows: &Id) {} - - fn event(&self, event: &Event<'_>) { - let level = *event.metadata().level(); - let mut visitor = LineVisitor::default(); - event.record(&mut visitor); - let line = visitor.finish(); - self.sink.log(level, &line); - #[cfg(feature = "stderr-echo")] - eprintln!("[{level}] {line}"); - } - - fn enter(&self, _span: &Id) {} - - fn exit(&self, _span: &Id) {} -} - -/// Flattens an event into ` key=value ...`: the `message` -/// field becomes the line body, every other field is appended as a -/// space-separated `key=value` pair in record order. -#[derive(Default)] -struct LineVisitor { - message: String, - fields: String, -} - -impl LineVisitor { - fn finish(mut self) -> String { - if self.message.is_empty() { - // A field-only event would otherwise carry a leading space. - return self.fields.trim_start().to_owned(); - } - self.message.push_str(&self.fields); - self.message - } -} - -impl Visit for LineVisitor { - fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { - if field.name() == "message" { - let _ = write!(self.message, "{value:?}"); - } else { - let _ = write!(self.fields, " {}={value:?}", field.name()); - } - } - - fn record_str(&mut self, field: &Field, value: &str) { - if field.name() == "message" { - self.message.push_str(value); - } else { - let _ = write!(self.fields, " {}={value}", field.name()); - } - } - - fn record_u64(&mut self, field: &Field, value: u64) { - let _ = write!(self.fields, " {}={value}", field.name()); - } - - fn record_i64(&mut self, field: &Field, value: i64) { - let _ = write!(self.fields, " {}={value}", field.name()); - } - - fn record_bool(&mut self, field: &Field, value: bool) { - let _ = write!(self.fields, " {}={value}", field.name()); - } -} - -#[cfg(test)] -mod tests { - use std::sync::Mutex; - - use super::*; - - /// Capturing sink for the with_default-scoped subscriber. - #[derive(Default)] - struct Captured { - lines: Mutex>, - } - - impl LogSink for Arc { - fn log(&self, level: Level, message: &str) { - self.lines.lock().unwrap().push((level, message.to_owned())); - } - } - - fn capture(f: impl FnOnce()) -> Vec<(Level, String)> { - let sink = Arc::new(Captured::default()); - let subscriber = subscriber(Arc::clone(&sink)); - tracing::subscriber::with_default(subscriber, f); - sink.lines.lock().unwrap().clone() - } - - #[test] - fn each_macro_level_forwards_at_its_event_level() { - let lines = capture(|| { - tracing::trace!("t"); - tracing::debug!("d"); - tracing::info!("i"); - tracing::warn!("w"); - tracing::error!("e"); - }); - let levels: Vec = lines.iter().map(|(l, _)| *l).collect(); - assert_eq!( - levels, - vec![ - Level::TRACE, - Level::DEBUG, - Level::INFO, - Level::WARN, - Level::ERROR - ] - ); - } - - #[test] - fn message_only_event_renders_bare_message() { - let lines = capture(|| tracing::info!("hello world")); - assert_eq!(lines, vec![(Level::INFO, "hello world".to_owned())]); - } - - #[test] - fn formatted_message_renders_without_field_suffix() { - let lines = capture(|| tracing::info!("value is {}", 41 + 1)); - assert_eq!(lines, vec![(Level::INFO, "value is 42".to_owned())]); - } - - #[test] - fn fields_flatten_across_types_after_message() { - let lines = capture(|| { - tracing::warn!( - name = "eth", - count = 7u64, - signed = -3i64, - ready = true, - answer = ?Some(9), - "changed" - ); - }); - assert_eq!( - lines, - vec![( - Level::WARN, - "changed name=eth count=7 signed=-3 ready=true answer=Some(9)".to_owned() - )] - ); - } - - #[test] - fn fieldset_without_message_renders_only_pairs() { - let lines = capture(|| tracing::info!(a = 1u64, b = "x")); - assert_eq!(lines, vec![(Level::INFO, "a=1 b=x".to_owned())]); - } - - #[test] - fn spans_are_inert_no_ops() { - let lines = capture(|| { - let span = tracing::info_span!("work", key = "v"); - let _entered = span.enter(); - span.record("key", "v2"); - }); - assert!( - lines.is_empty(), - "span lifecycle produced events: {lines:?}" - ); - } - - #[test] - fn format_panic_with_and_without_location() { - assert_eq!( - format_panic("boom", Some(("src/lib.rs", 42))), - "panic: boom at src/lib.rs:42" - ); - assert_eq!(format_panic("boom", None), "panic: boom"); - } -} diff --git a/nexum/crates/nexum-sdk/src/wit_bindgen_macro.rs b/nexum/crates/nexum-sdk/src/wit_bindgen_macro.rs deleted file mode 100644 index 98059b8b..00000000 --- a/nexum/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ /dev/null @@ -1,390 +0,0 @@ -//! Declarative macro that generates the `WitBindgenHost` adapter -//! every module ships in `lib.rs`. -//! -//! Before this macro existed, each module hand-rolled ~80 lines of -//! mechanical glue: the `struct WitBindgenHost;` plus the core trait -//! impls plus the fault, chain-error, and level conversions. The code -//! differed across modules in zero places that were not bugs. -//! -//! The adapter is capability-selected: the `caps: [...]` form emits -//! only the pieces backed by the module's declared capabilities -//! (`#[nexum_sdk::module]` invokes it this way, matching the -//! per-module world it generates), while the zero-argument form emits -//! the full six-interface set (`chain, identity, local_store, -//! remote_store, messaging, logging`) for modules that compile -//! against a blanket world with every core import present. -//! Either way the call site must already have the wit-bindgen output -//! for its world in scope (`wit_bindgen::generate!({ ..., -//! generate_all })`): each selected piece resolves its -//! `nexum::host::*` module, so selecting a capability the world does -//! not import is a compile error. -//! -//! A domain SDK layers its own interfaces on top by invoking this -//! macro and adding trait impls for the same `WitBindgenHost`. -//! -//! Usage in a module's `lib.rs`: -//! -//! ```ignore -//! wit_bindgen::generate!({ /* ... */ }); -//! nexum_sdk::bind_host_via_wit_bindgen!(); -//! // or, capability-selected: -//! // nexum_sdk::bind_host_via_wit_bindgen!(caps: [chain, logging]); -//! -//! // `WitBindgenHost` and the `Fault` `From` impls (both directions) -//! // are now in scope, plus per selected capability: `convert_chain_err` -//! // (chain), the `IdentityHost`, `LocalStoreHost`, `RemoteStoreHost`, -//! // and `MessagingHost` impls (identity / local_store / remote_store / -//! // messaging), and the `Level` `From` impl, `HostLogSink`, and -//! // `install_tracing` (logging), with the wit-bindgen and SDK types -//! // tied together through identifier resolution. Call -//! // `install_tracing()` once at the top of `Guest::init` to route -//! // `tracing::info!(...)` to the host. `From` impls for -//! // `nexum_sdk::events::Log` and `nexum_sdk::host::Message` are also -//! // emitted so `on_event` maps chain-log batches and messages -//! // straight to the SDK types. -//! ``` - -/// Generate `WitBindgenHost` + the `*Host` trait impls + the error / -/// level `From` impls for the selected capabilities. See module docs. -/// -/// The fault and level conversions are `From` impls: orphan-legal here -/// because the wit-bindgen types are local to the expanding cdylib. -/// -/// Macro hygiene note: `macro_rules!` is not hygienic for type names -/// or function items, so the names `WitBindgenHost`, `convert_chain_err`, -/// `HostLogSink`, and `install_tracing` are intentionally visible in the -/// caller's scope. -#[macro_export] -macro_rules! bind_host_via_wit_bindgen { - // Blanket-world form: every core interface is in scope, emit the - // full adapter. - () => { - $crate::bind_host_via_wit_bindgen!( - caps: [chain, identity, local_store, remote_store, messaging, logging] - ); - }; - // Capability-selected form: the base pieces (which need only the - // always-present `nexum:host/types`) plus one block per listed - // capability. - (caps: [$($cap:ident),* $(,)?]) => { - /// Wraps the module's per-cdylib wit-bindgen imports so the - /// strategy can hold a `&impl Host` instead of dispatching on - /// the free functions directly. Generated by - /// `nexum_sdk::bind_host_via_wit_bindgen!`. - struct WitBindgenHost; - - /// Lift the wit-bindgen `types.fault` (per-cdylib) into the - /// SDK's `Fault`. Exhaustive on the seven-case vocabulary; the - /// `rate-limited` backoff record maps field for field. - impl ::core::convert::From for $crate::host::Fault { - fn from(f: nexum::host::types::Fault) -> Self { - match f { - nexum::host::types::Fault::Unsupported(s) => Self::Unsupported(s), - nexum::host::types::Fault::Unavailable(s) => Self::Unavailable(s), - nexum::host::types::Fault::Denied(s) => Self::Denied(s), - nexum::host::types::Fault::RateLimited(rl) => { - Self::RateLimited($crate::host::RateLimit { - retry_after_ms: rl.retry_after_ms, - }) - } - nexum::host::types::Fault::Timeout => Self::Timeout, - nexum::host::types::Fault::InvalidInput(s) => Self::InvalidInput(s), - nexum::host::types::Fault::Internal(s) => Self::Internal(s), - } - } - } - - /// Reverse direction: lower the SDK `Fault` back into the - /// per-cdylib wit-bindgen `Fault` so `Guest::init` / - /// `Guest::on_event` can return what the export signature - /// expects (`?` applies it). - /// - /// Carries a wildcard arm because `$crate::host::Fault` is - /// `#[non_exhaustive]`: a future SDK-side case must compile in - /// module crates without source changes. Falls back to - /// `internal` carrying the `Display` detail. - impl ::core::convert::From<$crate::host::Fault> for nexum::host::types::Fault { - fn from(f: $crate::host::Fault) -> Self { - match f { - $crate::host::Fault::Unsupported(s) => Self::Unsupported(s), - $crate::host::Fault::Unavailable(s) => Self::Unavailable(s), - $crate::host::Fault::Denied(s) => Self::Denied(s), - $crate::host::Fault::RateLimited(rl) => { - Self::RateLimited(nexum::host::types::RateLimit { - retry_after_ms: rl.retry_after_ms, - }) - } - $crate::host::Fault::Timeout => Self::Timeout, - $crate::host::Fault::InvalidInput(s) => Self::InvalidInput(s), - $crate::host::Fault::Internal(s) => Self::Internal(s), - // `$crate::host::Fault` is `#[non_exhaustive]`; a - // future SDK case lands here as `internal`. - other => Self::Internal(::std::string::ToString::to_string(&other)), - } - } - } - - /// Rebuild the native alloy log from the per-cdylib wit-bindgen - /// `chain-log` record. The one conversion home for the guest WIT - /// edge: strategies receive `nexum_sdk::events::Log`, never the - /// wire record. Assembly logic lives in `nexum_sdk::events`. - impl ::core::convert::From for $crate::events::Log { - fn from(log: nexum::host::types::ChainLog) -> Self { - $crate::events::ChainLogParts { - address: &log.address, - topics: &log.topics, - data: &log.data, - block_hash: log.block_hash.as_deref(), - block_number: log.block_number, - block_timestamp: log.block_timestamp, - transaction_hash: log.transaction_hash.as_deref(), - transaction_index: log.transaction_index, - log_index: log.log_index, - removed: log.removed, - } - .into() - } - } - - /// Rebuild the SDK message from the per-cdylib wit-bindgen - /// `message` record, so `on_event` maps a delivery straight to - /// `nexum_sdk::host::Message`. - impl ::core::convert::From for $crate::host::Message { - fn from(message: nexum::host::types::Message) -> Self { - Self { - content_topic: message.content_topic, - payload: message.payload, - timestamp: message.timestamp, - sender: message.sender, - } - } - } - - $($crate::__bind_host_cap_via_wit_bindgen!($cap);)* - }; -} - -/// One capability's slice of the `WitBindgenHost` adapter. Invoked by -/// [`bind_host_via_wit_bindgen!`]; not part of the public surface. -#[doc(hidden)] -#[macro_export] -macro_rules! __bind_host_cap_via_wit_bindgen { - (chain) => { - impl $crate::host::ChainHost for WitBindgenHost { - fn request( - &self, - chain_id: u64, - method: &str, - params: &str, - ) -> ::core::result::Result<::std::string::String, $crate::host::ChainError> { - nexum::host::chain::request(chain_id, method, params).map_err(convert_chain_err) - } - } - - /// Lift the wit-bindgen `chain.chain-error` (per-cdylib) into - /// the SDK's host-neutral `ChainError`. Exhaustive on both the - /// `Fault` vocabulary and the `RpcError` shape. - fn convert_chain_err(e: nexum::host::chain::ChainError) -> $crate::host::ChainError { - match e { - nexum::host::chain::ChainError::Fault(f) => { - $crate::host::ChainError::Fault(::core::convert::Into::into(f)) - } - nexum::host::chain::ChainError::Rpc(r) => { - $crate::host::ChainError::Rpc($crate::host::RpcError { - code: r.code, - message: r.message, - data: r.data.map(::core::convert::Into::into), - }) - } - } - } - }; - (identity) => { - impl $crate::host::IdentityHost for WitBindgenHost { - fn accounts( - &self, - ) -> ::core::result::Result< - ::std::vec::Vec<$crate::prelude::Address>, - $crate::host::Fault, - > { - nexum::host::identity::accounts() - .map_err($crate::host::Fault::from)? - .iter() - .map(|account| $crate::host::account_from_wire(account)) - .collect() - } - fn sign( - &self, - account: $crate::prelude::Address, - message: &[u8], - ) -> ::core::result::Result<$crate::prelude::Signature, $crate::host::Fault> { - let raw = nexum::host::identity::sign(account.as_slice(), message) - .map_err($crate::host::Fault::from)?; - $crate::host::signature_from_wire(&raw) - } - fn sign_typed_data( - &self, - account: $crate::prelude::Address, - typed_data: &str, - ) -> ::core::result::Result<$crate::prelude::Signature, $crate::host::Fault> { - let raw = nexum::host::identity::sign_typed_data(account.as_slice(), typed_data) - .map_err($crate::host::Fault::from)?; - $crate::host::signature_from_wire(&raw) - } - } - }; - (local_store) => { - impl $crate::host::LocalStoreHost for WitBindgenHost { - fn get( - &self, - key: &str, - ) -> ::core::result::Result< - ::core::option::Option<::std::vec::Vec>, - $crate::host::Fault, - > { - nexum::host::local_store::get(key).map_err($crate::host::Fault::from) - } - fn set( - &self, - key: &str, - value: &[u8], - ) -> ::core::result::Result<(), $crate::host::Fault> { - nexum::host::local_store::set(key, value).map_err($crate::host::Fault::from) - } - fn delete(&self, key: &str) -> ::core::result::Result<(), $crate::host::Fault> { - nexum::host::local_store::delete(key).map_err($crate::host::Fault::from) - } - fn list_keys( - &self, - prefix: &str, - ) -> ::core::result::Result<::std::vec::Vec<::std::string::String>, $crate::host::Fault> - { - nexum::host::local_store::list_keys(prefix).map_err($crate::host::Fault::from) - } - fn contains(&self, key: &str) -> ::core::result::Result { - nexum::host::local_store::contains(key).map_err($crate::host::Fault::from) - } - fn len( - &self, - key: &str, - ) -> ::core::result::Result<::core::option::Option, $crate::host::Fault> { - nexum::host::local_store::len(key).map_err($crate::host::Fault::from) - } - fn count(&self, prefix: &str) -> ::core::result::Result { - nexum::host::local_store::count(prefix).map_err($crate::host::Fault::from) - } - } - }; - (remote_store) => { - impl $crate::host::RemoteStoreHost for WitBindgenHost { - fn upload( - &self, - data: &[u8], - ) -> ::core::result::Result<$crate::prelude::B256, $crate::host::Fault> { - let raw = - nexum::host::remote_store::upload(data).map_err($crate::host::Fault::from)?; - $crate::host::reference_from_wire(&raw) - } - fn download( - &self, - reference: $crate::prelude::B256, - ) -> ::core::result::Result<::std::vec::Vec, $crate::host::Fault> { - nexum::host::remote_store::download(reference.as_slice()) - .map_err($crate::host::Fault::from) - } - fn read_feed( - &self, - owner: $crate::prelude::Address, - topic: $crate::prelude::B256, - ) -> ::core::result::Result< - ::core::option::Option<::std::vec::Vec>, - $crate::host::Fault, - > { - nexum::host::remote_store::read_feed(owner.as_slice(), topic.as_slice()) - .map_err($crate::host::Fault::from) - } - fn write_feed( - &self, - topic: $crate::prelude::B256, - data: &[u8], - ) -> ::core::result::Result<$crate::prelude::B256, $crate::host::Fault> { - let raw = nexum::host::remote_store::write_feed(topic.as_slice(), data) - .map_err($crate::host::Fault::from)?; - $crate::host::reference_from_wire(&raw) - } - } - }; - (messaging) => { - impl $crate::host::MessagingHost for WitBindgenHost { - fn publish( - &self, - content_topic: &str, - payload: &[u8], - ) -> ::core::result::Result<(), $crate::host::Fault> { - nexum::host::messaging::publish(content_topic, payload) - .map_err($crate::host::Fault::from) - } - fn query( - &self, - content_topic: &str, - start_time: ::core::option::Option, - end_time: ::core::option::Option, - limit: ::core::option::Option, - ) -> ::core::result::Result<::std::vec::Vec<$crate::host::Message>, $crate::host::Fault> - { - let messages = - nexum::host::messaging::query(content_topic, start_time, end_time, limit) - .map_err($crate::host::Fault::from)?; - ::core::result::Result::Ok( - messages - .into_iter() - .map(::core::convert::Into::into) - .collect(), - ) - } - } - }; - (logging) => { - impl $crate::host::LoggingHost for WitBindgenHost { - fn log(&self, level: $crate::Level, message: &str) { - nexum::host::logging::log(nexum::host::logging::Level::from(level), message); - } - } - - /// Translate a `tracing_core::Level` into the wit-bindgen - /// `logging::Level` wire enum. `Level` is a set of associated - /// consts, not a matchable enum, so compare rather than match; - /// the five tiers are total, so the final arm is `Trace`. - impl ::core::convert::From<$crate::Level> for nexum::host::logging::Level { - fn from(level: $crate::Level) -> Self { - if level == $crate::Level::ERROR { - Self::Error - } else if level == $crate::Level::WARN { - Self::Warn - } else if level == $crate::Level::INFO { - Self::Info - } else if level == $crate::Level::DEBUG { - Self::Debug - } else { - Self::Trace - } - } - } - - /// Routes guest `tracing` events to the bound host logging call. - struct HostLogSink; - - impl $crate::tracing::LogSink for HostLogSink { - fn log(&self, level: $crate::Level, message: &str) { - ::log(&WitBindgenHost, level, message); - } - } - - /// Install the guest tracing facade and panic hook, forwarding - /// through the bound host logging call. Call once at the top of - /// `Guest::init` so `tracing::info!(...)` reaches the host. - fn install_tracing() { - $crate::tracing::init(HostLogSink); - } - }; -} diff --git a/nexum/crates/nexum-sdk/tests/keeper.rs b/nexum/crates/nexum-sdk/tests/keeper.rs deleted file mode 100644 index f73c9adf..00000000 --- a/nexum/crates/nexum-sdk/tests/keeper.rs +++ /dev/null @@ -1,598 +0,0 @@ -//! Keeper-store acceptance tests against the composed -//! `nexum_sdk_test::MockHost` - the keeper touches only the local -//! store, so the world-neutral mock is the whole seam. These live as -//! an integration test (not `#[cfg(test)]`) because the mock crate -//! links `nexum-sdk` externally, and the external and unit-test -//! copies of the host traits are distinct types. - -use alloy_primitives::{Address, B256, address, b256}; -use nexum_sdk::host::{Fault, LocalStoreHost as _}; -use nexum_sdk::keeper::{ - ConditionalSource, Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, REFUSED_PREFIX, - Retrier, RetryAction, Tick, WATCH_PREFIX, WatchRef, WatchSet, watch_key, -}; -use nexum_sdk_test::MockHost; - -fn sample_owner() -> Address { - address!("00112233445566778899aabbccddeeff00112233") -} - -fn sample_hash() -> B256 { - b256!("0202020202020202020202020202020202020202020202020202020202020202") -} - -// ---- watch keys ---- - -#[test] -fn watch_key_is_lowercase_prefixed_hex() { - let key = watch_key(&sample_owner(), &sample_hash()); - assert_eq!( - key, - concat!( - "watch:0x00112233445566778899aabbccddeeff00112233:", - "0x0202020202020202020202020202020202020202020202020202020202020202", - ), - ); -} - -#[test] -fn watch_key_round_trips_via_parse() { - let key = watch_key(&sample_owner(), &sample_hash()); - let watch = WatchRef::parse(&key).expect("parse"); - assert_eq!( - watch.owner_hex().parse::
().unwrap(), - sample_owner() - ); - assert_eq!(watch.hash_hex().parse::().unwrap(), sample_hash()); - assert_eq!(watch.key(), key); -} - -#[test] -fn parse_rejects_missing_prefix_or_separator() { - assert_eq!(WatchRef::parse("gate:0xaa:0xbb"), None); - assert_eq!(WatchRef::parse("watch:0xaa0xbb"), None); - assert_eq!(WatchRef::parse(""), None); -} - -#[test] -fn parse_rejects_empty_halves() { - // `watch::` splits into two empty halves, which would derive - // degenerate gate keys like `next_block::`; reject it outright. - assert_eq!(WatchRef::parse("watch::"), None); - assert_eq!(WatchRef::parse("watch:0xaa:"), None); - assert_eq!(WatchRef::parse("watch::0xbb"), None); - // A well-formed key with both halves still parses. - assert!(WatchRef::parse("watch:0xaa:0xbb").is_some()); -} - -#[test] -fn parse_preserves_key_substrings_verbatim() { - // A foreign writer may have cased the hex differently; gate keys - // must derive from the stored substrings, not from a re-rendered - // canonical form. - let watch = WatchRef::parse("watch:0xAABB:0xCCDD").expect("parse"); - assert_eq!(watch.owner_hex(), "0xAABB"); - assert_eq!(watch.hash_hex(), "0xCCDD"); - assert_eq!(watch.next_block_key(), "next_block:0xAABB:0xCCDD"); - assert_eq!(watch.next_epoch_key(), "next_epoch:0xAABB:0xCCDD"); -} - -// ---- watch-set registry ---- - -#[test] -fn put_get_list_round_trip() { - let host = MockHost::new(); - let watches = WatchSet::new(&host); - - let key = watches - .put(&sample_owner(), &sample_hash(), b"params") - .unwrap(); - assert_eq!(watches.list().unwrap(), vec![key.clone()]); - - let watch = WatchRef::parse(&key).unwrap(); - assert_eq!(watches.get(watch).unwrap().as_deref(), Some(&b"params"[..])); -} - -#[test] -fn put_overwrites_in_place() { - let host = MockHost::new(); - let watches = WatchSet::new(&host); - - watches - .put(&sample_owner(), &sample_hash(), b"one") - .unwrap(); - let key = watches - .put(&sample_owner(), &sample_hash(), b"two") - .unwrap(); - - assert_eq!(host.store.len(), 1, "re-put must not duplicate the row"); - let watch = WatchRef::parse(&key).unwrap(); - assert_eq!(watches.get(watch).unwrap().as_deref(), Some(&b"two"[..])); -} - -#[test] -fn get_absent_watch_is_none() { - let host = MockHost::new(); - let watches = WatchSet::new(&host); - let key = watch_key(&sample_owner(), &sample_hash()); - let watch = WatchRef::parse(&key).unwrap(); - assert_eq!(watches.get(watch).unwrap(), None); -} - -#[test] -fn list_scans_only_the_watch_prefix() { - let host = MockHost::new(); - let watches = WatchSet::new(&host); - let key = watches - .put(&sample_owner(), &sample_hash(), b"params") - .unwrap(); - Journal::submitted(&host).record("0xuid").unwrap(); - - assert_eq!(watches.list().unwrap(), vec![key]); -} - -// ---- atomic delete ---- - -#[test] -fn remove_drops_watch_and_all_gate_keys() { - let host = MockHost::new(); - let watches = WatchSet::new(&host); - let gates = Gates::new(&host); - - let key = watches - .put(&sample_owner(), &sample_hash(), b"params") - .unwrap(); - let watch = WatchRef::parse(&key).unwrap(); - gates.set_next_block(watch, 500).unwrap(); - gates.set_next_epoch(watch, 1_700_000_000).unwrap(); - assert_eq!(host.store.len(), 3); - - watches.remove(watch).unwrap(); - - assert!(host.store.is_empty(), "watch and both gates must go"); -} - -#[test] -fn remove_without_gates_is_clean() { - let host = MockHost::new(); - let watches = WatchSet::new(&host); - let key = watches - .put(&sample_owner(), &sample_hash(), b"params") - .unwrap(); - watches.remove(WatchRef::parse(&key).unwrap()).unwrap(); - assert!(host.store.is_empty()); -} - -#[test] -fn remove_clears_gates_before_the_watch_row() { - // A fault on the watch delete must still find the gates gone: the - // retryable leftover is the watch row, never an orphaned gate. - let host = MockHost::new(); - let watches = WatchSet::new(&host); - let gates = Gates::new(&host); - let key = watches - .put(&sample_owner(), &sample_hash(), b"params") - .unwrap(); - let watch = WatchRef::parse(&key).unwrap(); - gates.set_next_block(watch, 500).unwrap(); - gates.set_next_epoch(watch, 1_700_000_000).unwrap(); - - host.store - .fail_on(WATCH_PREFIX, Fault::Unavailable("injected".into())); - - watches.remove(watch).unwrap_err(); - - let snapshot = host.store.snapshot(); - assert!( - !snapshot - .keys() - .any(|k| k.starts_with(NEXT_BLOCK_PREFIX) || k.starts_with(NEXT_EPOCH_PREFIX)), - "gates must already be gone when the watch delete faults", - ); - assert!( - snapshot.contains_key(&key), - "the watch row stays behind so a retry can re-drop it", - ); -} - -#[test] -fn remove_propagates_a_gate_delete_fault_and_keeps_the_watch() { - let host = MockHost::new(); - let watches = WatchSet::new(&host); - let key = watches - .put(&sample_owner(), &sample_hash(), b"params") - .unwrap(); - let watch = WatchRef::parse(&key).unwrap(); - - host.store - .fail_on(NEXT_BLOCK_PREFIX, Fault::Unavailable("injected".into())); - - watches.remove(watch).unwrap_err(); - - assert!( - host.store.snapshot().contains_key(&key), - "a gate-delete fault must leave the watch for a retry", - ); -} - -// ---- gates ---- - -#[test] -fn ready_with_no_gates_set() { - let host = MockHost::new(); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - assert!(Gates::new(&host).is_ready(watch, 0, 0).unwrap()); -} - -#[test] -fn next_block_gate_is_inclusive_at_threshold() { - let host = MockHost::new(); - let gates = Gates::new(&host); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - gates.set_next_block(watch, 500).unwrap(); - - assert!(!gates.is_ready(watch, 499, u64::MAX).unwrap()); - assert!(gates.is_ready(watch, 500, u64::MAX).unwrap()); - assert!(gates.is_ready(watch, 501, u64::MAX).unwrap()); -} - -#[test] -fn next_epoch_gate_is_inclusive_at_threshold() { - let host = MockHost::new(); - let gates = Gates::new(&host); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - gates.set_next_epoch(watch, 1_700_000_000).unwrap(); - - assert!(!gates.is_ready(watch, u64::MAX, 1_699_999_999).unwrap()); - assert!(gates.is_ready(watch, u64::MAX, 1_700_000_000).unwrap()); -} - -#[test] -fn both_gates_must_pass() { - let host = MockHost::new(); - let gates = Gates::new(&host); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - gates.set_next_block(watch, 100).unwrap(); - gates.set_next_epoch(watch, 2_000).unwrap(); - - assert!(!gates.is_ready(watch, 100, 1_999).unwrap()); - assert!(!gates.is_ready(watch, 99, 2_000).unwrap()); - assert!(gates.is_ready(watch, 100, 2_000).unwrap()); -} - -#[test] -fn gate_values_are_u64_le() { - let host = MockHost::new(); - let gates = Gates::new(&host); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - gates.set_next_block(watch, 0x0102_0304_0506_0708).unwrap(); - - assert_eq!( - host.store.snapshot().get("next_block:0xaa:0xbb").unwrap(), - &0x0102_0304_0506_0708_u64.to_le_bytes().to_vec(), - ); -} - -#[test] -fn malformed_gate_value_reads_as_no_gate() { - let host = MockHost::new(); - let gates = Gates::new(&host); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - host.store.set("next_block:0xaa:0xbb", b"not8b").unwrap(); - - assert!( - gates.is_ready(watch, 0, 0).unwrap(), - "a corrupt gate can only make the watch poll sooner", - ); -} - -#[test] -fn clear_removes_both_gate_keys() { - let host = MockHost::new(); - let gates = Gates::new(&host); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - gates.set_next_block(watch, 1).unwrap(); - gates.set_next_epoch(watch, 2).unwrap(); - - gates.clear(watch).unwrap(); - - assert!(host.store.is_empty()); - // And clearing again stays a no-op. - gates.clear(watch).unwrap(); -} - -#[test] -fn gate_fault_propagates_from_is_ready() { - let host = MockHost::new(); - let gates = Gates::new(&host); - let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); - host.store - .fail_on(NEXT_EPOCH_PREFIX, Fault::Unavailable("injected".into())); - - gates.is_ready(watch, 0, 0).unwrap_err(); -} - -// ---- journal ---- - -#[test] -fn journal_round_trips_a_receipt() { - let host = MockHost::new(); - let journal = Journal::submitted(&host); - - assert!(!journal.contains("0xuid").unwrap()); - journal.record("0xuid").unwrap(); - assert!(journal.contains("0xuid").unwrap()); -} - -#[test] -fn journal_marker_is_an_empty_presence_row() { - let host = MockHost::new(); - Journal::submitted(&host).record("0xuid").unwrap(); - assert_eq!( - host.store.snapshot().get("submitted:0xuid").unwrap(), - &Vec::::new(), - ); -} - -#[test] -fn journal_record_is_idempotent() { - let host = MockHost::new(); - let journal = Journal::observed(&host); - journal.record("0xuid").unwrap(); - journal.record("0xuid").unwrap(); - assert_eq!(host.store.len(), 1); -} - -#[test] -fn submitted_and_observed_keyspaces_are_disjoint() { - let host = MockHost::new(); - Journal::submitted(&host).record("0xuid").unwrap(); - - assert!(!Journal::observed(&host).contains("0xuid").unwrap()); - let snapshot = host.store.snapshot(); - assert!(snapshot.contains_key("submitted:0xuid")); - assert!(!snapshot.contains_key("observed:0xuid")); -} - -// ---- retry ledger ---- - -fn seeded_watch(host: &MockHost) -> String { - WatchSet::new(host) - .put(&sample_owner(), &sample_hash(), b"params") - .unwrap() -} - -fn tick_at(block: u64, epoch_s: u64) -> Tick { - Tick { - chain_id: 1, - block, - epoch_s, - } -} - -#[test] -fn ledger_try_next_block_leaves_the_store_untouched() { - let host = MockHost::new(); - let key = seeded_watch(&host); - let before = host.store.snapshot(); - - Retrier::new(&host) - .apply( - WatchRef::parse(&key).unwrap(), - RetryAction::TryNextBlock, - &tick_at(100, 1_000), - ) - .unwrap(); - - assert_eq!(host.store.snapshot(), before); -} - -#[test] -fn ledger_backoff_gates_the_watch_on_the_epoch_clock() { - let host = MockHost::new(); - let key = seeded_watch(&host); - let watch = WatchRef::parse(&key).unwrap(); - let ledger = Retrier::new(&host); - - ledger - .apply( - watch, - RetryAction::Backoff { seconds: 30 }, - &tick_at(100, 1_000), - ) - .unwrap(); - - let gates = Gates::new(&host); - assert!(!gates.is_ready(watch, u64::MAX, 1_029).unwrap()); - assert!(gates.is_ready(watch, u64::MAX, 1_030).unwrap()); - assert_eq!( - host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), - &1_030_u64.to_le_bytes().to_vec(), - ); - assert!( - host.store.snapshot().contains_key(&key), - "backoff must keep the watch", - ); -} - -#[test] -fn ledger_backoff_saturates_on_the_epoch_clock() { - let host = MockHost::new(); - let key = seeded_watch(&host); - let watch = WatchRef::parse(&key).unwrap(); - - Retrier::new(&host) - .apply( - watch, - RetryAction::Backoff { seconds: u64::MAX }, - &tick_at(100, 1_000), - ) - .unwrap(); - - assert_eq!( - host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), - &u64::MAX.to_le_bytes().to_vec(), - ); -} - -#[test] -fn ledger_drop_removes_the_watch_and_its_gates() { - let host = MockHost::new(); - let key = seeded_watch(&host); - let watch = WatchRef::parse(&key).unwrap(); - Gates::new(&host).set_next_block(watch, 500).unwrap(); - - Retrier::new(&host) - .apply(watch, RetryAction::Drop, &tick_at(100, 1_000)) - .unwrap(); - - assert!(host.store.is_empty(), "watch and gates must go"); -} - -#[test] -fn ledger_drop_on_repeat_grants_one_next_block_retry() { - let host = MockHost::new(); - let key = seeded_watch(&host); - let watch = WatchRef::parse(&key).unwrap(); - let ledger = Retrier::new(&host); - - // First refusal: the block is recorded and the watch gates to the - // next block instead of dropping. - ledger - .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) - .unwrap(); - let snapshot = host.store.snapshot(); - assert!(snapshot.contains_key(&key), "first refusal keeps the watch"); - assert_eq!( - snapshot.get(&watch.refused_key()).unwrap(), - &100_u64.to_le_bytes().to_vec(), - ); - assert_eq!( - snapshot.get(&watch.next_block_key()).unwrap(), - &101_u64.to_le_bytes().to_vec(), - ); - - // A repeat at the same block leaves the store untouched. - let before = host.store.snapshot(); - ledger - .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) - .unwrap(); - assert_eq!(host.store.snapshot(), before); - - // A repeat on a later block removes the watch and every derived key. - ledger - .apply(watch, RetryAction::DropOnRepeat, &tick_at(101, 1_012)) - .unwrap(); - assert!(host.store.is_empty(), "watch, gates, and marker must go"); -} - -#[test] -fn ledger_clear_refusal_resets_the_one_block_grace() { - let host = MockHost::new(); - let key = seeded_watch(&host); - let watch = WatchRef::parse(&key).unwrap(); - let ledger = Retrier::new(&host); - - ledger - .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) - .unwrap(); - ledger.clear_refusal(watch).unwrap(); - assert!(!host.store.snapshot().contains_key(&watch.refused_key())); - - // A refusal at a later block after the clear is a fresh first - // refusal: the watch survives and the marker records the new block. - ledger - .apply(watch, RetryAction::DropOnRepeat, &tick_at(105, 1_060)) - .unwrap(); - let snapshot = host.store.snapshot(); - assert!(snapshot.contains_key(&key), "the watch must survive"); - assert_eq!( - snapshot.get(&watch.refused_key()).unwrap(), - &105_u64.to_le_bytes().to_vec(), - ); - assert_eq!( - snapshot.get(&watch.next_block_key()).unwrap(), - &106_u64.to_le_bytes().to_vec(), - ); -} - -#[test] -fn ledger_drop_removes_the_refused_marker() { - let host = MockHost::new(); - let key = seeded_watch(&host); - let watch = WatchRef::parse(&key).unwrap(); - - let ledger = Retrier::new(&host); - ledger - .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) - .unwrap(); - ledger - .apply(watch, RetryAction::Drop, &tick_at(100, 1_000)) - .unwrap(); - - assert!( - !host - .store - .snapshot() - .keys() - .any(|k| k.starts_with(REFUSED_PREFIX)), - "drop must not orphan the refusal marker", - ); -} - -#[test] -fn retry_action_labels_are_stable_snake_case() { - let cases: [(RetryAction, &str); 4] = [ - (RetryAction::TryNextBlock, "try_next_block"), - (RetryAction::Backoff { seconds: 1 }, "backoff"), - (RetryAction::DropOnRepeat, "drop_on_repeat"), - (RetryAction::Drop, "drop"), - ]; - for (action, label) in cases { - assert_eq!(<&'static str>::from(action), label); - } -} - -// ---- conditional source ---- - -/// A source is generic over the host and owns its outcome shape; the -/// keeper passes the stored params verbatim and the tick it judged -/// the gates by. -#[test] -fn conditional_source_sees_params_and_tick_verbatim() { - struct EchoSource; - impl ConditionalSource for EchoSource { - type Outcome = (usize, u64, u64, u64, String); - fn poll( - &self, - _host: &H, - watch: WatchRef<'_>, - params: &[u8], - tick: &Tick, - ) -> Self::Outcome { - ( - params.len(), - tick.chain_id, - tick.block, - tick.epoch_s, - watch.key(), - ) - } - } - - let host = MockHost::new(); - let key = seeded_watch(&host); - let watch = WatchRef::parse(&key).unwrap(); - let tick = Tick { - chain_id: 1, - block: 42, - epoch_s: 1_700_000_000, - }; - - let (len, chain_id, block, epoch_s, echoed) = EchoSource.poll(&host, watch, b"params", &tick); - assert_eq!(len, b"params".len()); - assert_eq!(chain_id, 1); - assert_eq!(block, 42); - assert_eq!(epoch_s, 1_700_000_000); - assert_eq!(echoed, key); -} diff --git a/nexum/crates/nexum-status-body/Cargo.toml b/nexum/crates/nexum-status-body/Cargo.toml deleted file mode 100644 index cd4361b3..00000000 --- a/nexum/crates/nexum-status-body/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "nexum-status-body" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Versioned codec for the opaque status body the host event stream carries: a leading version tag, then that version's borsh payload; unknown tags fail closed." - -[lints] -workspace = true - -[dependencies] -borsh.workspace = true -thiserror.workspace = true diff --git a/nexum/crates/nexum-status-body/src/lib.rs b/nexum/crates/nexum-status-body/src/lib.rs deleted file mode 100644 index 2bd9c009..00000000 --- a/nexum/crates/nexum-status-body/src/lib.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! The versioned opaque status-body codec. -//! -//! The host `event` stream carries an intent-status transition as opaque -//! bytes: a leading `u8` version tag, then that version's borsh payload. -//! The emitter encodes, the keeper decodes, the host never inspects the -//! bytes. An unknown tag fails closed and a body is never empty. -//! -//! v1 wire form: `0x01`, the [`IntentStatus`] discriminant, then the -//! borsh `option` encodings of `proof` and `reason`. - -#![warn(missing_docs)] - -use borsh::{BorshDeserialize, BorshSerialize}; - -/// Wire tag of the v1 payload. -pub const VERSION_V1: u8 = 1; - -/// Where an intent is in its life at the venue. The borsh discriminant -/// is the wire form: append new states, never reorder. -#[derive(BorshDeserialize, BorshSerialize, Clone, Copy, Debug, Eq, PartialEq)] -pub enum IntentStatus { - /// Accepted for processing but not yet live at the venue. - Pending, - /// Live at the venue and eligible for settlement. - Open, - /// Settled. - Fulfilled, - /// Withdrawn or terminally refused before settlement. - Cancelled, - /// Reached its expiry without settling. - Expired, -} - -/// Why an intent failed terminally, as reported by the venue. -#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] -pub struct FailReason { - /// Venue-scoped machine-readable code, stable enough to match on. - pub code: String, - /// Human-readable detail for logs and the consent surface. - pub detail: String, -} - -/// One decoded status body. -/// -/// `proof` is display-grade venue bytes (for an EVM venue, typically -/// the settlement transaction hash). There is no `failed` status: a -/// venue-reported terminal failure reads as a non-[`Fulfilled`] -/// terminal `status` plus a `reason`. -/// -/// [`Fulfilled`]: IntentStatus::Fulfilled -#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] -pub struct StatusBody { - /// Where the intent is in its life at the venue. - pub status: IntentStatus, - /// Venue-defined settlement proof. - pub proof: Option>, - /// Terminal-failure reason. - pub reason: Option, -} - -impl StatusBody { - /// Encode as the version tag plus the borsh payload. Never empty: - /// at minimum the tag and the status discriminant. - pub fn encode(&self) -> Result, EncodeError> { - let mut out = vec![VERSION_V1]; - borsh::to_writer(&mut out, self).map_err(|err| EncodeError { - detail: err.to_string(), - })?; - Ok(out) - } - - /// Decode, failing typedly on an empty body, an unknown version - /// tag (fail-closed), or a payload that does not parse as the - /// tagged version (including trailing bytes). - pub fn decode(bytes: &[u8]) -> Result { - match bytes { - [] => Err(DecodeError::Empty), - [VERSION_V1, payload @ ..] => { - borsh::from_slice(payload).map_err(|err| DecodeError::Malformed { - version: VERSION_V1, - detail: err.to_string(), - }) - } - [version, ..] => Err(DecodeError::UnknownVersion { version: *version }), - } - } -} - -/// A payload failed to encode. Only reachable when a field's length -/// exceeds the wire's `u32` bound. -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] -#[error("status body failed to encode: {detail}")] -pub struct EncodeError { - /// Borsh's encode failure detail. - pub detail: String, -} - -/// Why bytes failed to decode as a status body. -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] -#[non_exhaustive] -pub enum DecodeError { - /// No bytes at all: not even a version tag. - #[error("empty status body: missing the version tag")] - Empty, - /// The version tag names no published version. Fail-closed: a - /// keeper never guesses at a future layout. - #[error("unknown status-body version {version}")] - UnknownVersion { - /// The unrecognised wire tag. - version: u8, - }, - /// The tag named a known version but its payload did not decode - /// (malformed borsh or trailing bytes). - #[error("malformed version {version} payload: {detail}")] - Malformed { - /// The wire tag whose payload failed. - version: u8, - /// Borsh's decode failure detail. - detail: String, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - - fn body(status: IntentStatus) -> StatusBody { - StatusBody { - status, - proof: None, - reason: None, - } - } - - #[test] - fn golden_minimal_open() { - let encoded = body(IntentStatus::Open).encode().expect("encode"); - assert_eq!(encoded, [VERSION_V1, 1, 0, 0]); - } - - #[test] - fn golden_fulfilled_with_proof() { - let encoded = StatusBody { - status: IntentStatus::Fulfilled, - proof: Some(vec![0xaa, 0xbb]), - reason: None, - } - .encode() - .expect("encode"); - assert_eq!(encoded, [VERSION_V1, 2, 1, 2, 0, 0, 0, 0xaa, 0xbb, 0]); - } - - #[test] - fn golden_terminal_failure() { - let encoded = StatusBody { - status: IntentStatus::Cancelled, - proof: None, - reason: Some(FailReason { - code: "oc".into(), - detail: "od".into(), - }), - } - .encode() - .expect("encode"); - assert_eq!( - encoded, - [ - VERSION_V1, 3, 0, 1, 2, 0, 0, 0, b'o', b'c', 2, 0, 0, 0, b'o', b'd' - ], - ); - } - - #[test] - fn round_trips_every_status() { - for status in [ - IntentStatus::Pending, - IntentStatus::Open, - IntentStatus::Fulfilled, - IntentStatus::Cancelled, - IntentStatus::Expired, - ] { - let original = StatusBody { - status, - proof: Some(b"proof".to_vec()), - reason: Some(FailReason { - code: "code".into(), - detail: "detail".into(), - }), - }; - let decoded = StatusBody::decode(&original.encode().expect("encode")).expect("decode"); - assert_eq!(decoded, original); - } - } - - #[test] - fn a_body_is_never_empty() { - let encoded = body(IntentStatus::Pending).encode().expect("encode"); - assert!(encoded.len() >= 2, "at minimum the tag and the status"); - } - - #[test] - fn empty_bytes_fail_typedly() { - assert_eq!(StatusBody::decode(&[]), Err(DecodeError::Empty)); - } - - #[test] - fn unknown_version_fails_closed() { - assert_eq!( - StatusBody::decode(&[2, 1, 0, 0]), - Err(DecodeError::UnknownVersion { version: 2 }), - ); - } - - #[test] - fn unknown_status_discriminant_is_malformed() { - assert!(matches!( - StatusBody::decode(&[VERSION_V1, 5, 0, 0]), - Err(DecodeError::Malformed { - version: VERSION_V1, - .. - }), - )); - } - - #[test] - fn trailing_bytes_are_malformed() { - let mut encoded = body(IntentStatus::Open).encode().expect("encode"); - encoded.push(0); - assert!(matches!( - StatusBody::decode(&encoded), - Err(DecodeError::Malformed { - version: VERSION_V1, - .. - }), - )); - } - - #[test] - fn truncated_payload_is_malformed() { - assert!(matches!( - StatusBody::decode(&[VERSION_V1, 1, 0]), - Err(DecodeError::Malformed { - version: VERSION_V1, - .. - }), - )); - } -} diff --git a/nexum/crates/nexum-tasks/Cargo.toml b/nexum/crates/nexum-tasks/Cargo.toml deleted file mode 100644 index b5855c7e..00000000 --- a/nexum/crates/nexum-tasks/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "nexum-tasks" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Task lifecycle and graceful shutdown for the Nexum runtime" - -[lints] -workspace = true - -[dependencies] -tokio.workspace = true -futures.workspace = true -tracing.workspace = true diff --git a/nexum/crates/nexum-tasks/src/lib.rs b/nexum/crates/nexum-tasks/src/lib.rs deleted file mode 100644 index 81e5b3d0..00000000 --- a/nexum/crates/nexum-tasks/src/lib.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Task lifecycle and graceful shutdown: every runtime task is spawned -//! through a [`TaskExecutor`] minted by a [`TaskManager`], which owns the -//! shutdown signal and the bounded drain. -//! -//! This crate is the only place a raw `tokio` spawn appears; consumers -//! route every task through the executor so shutdown reaches all of them. - -mod manager; -mod shutdown; -mod task; - -pub use manager::{TaskExecutor, TaskManager}; -pub use shutdown::{ - DrainOutcome, GracefulShutdown, GracefulShutdownGuard, Shutdown, ShutdownTrigger, -}; -pub use task::{TaskExit, TaskHandle, TaskSet}; diff --git a/nexum/crates/nexum-tasks/src/manager.rs b/nexum/crates/nexum-tasks/src/manager.rs deleted file mode 100644 index f1131dac..00000000 --- a/nexum/crates/nexum-tasks/src/manager.rs +++ /dev/null @@ -1,293 +0,0 @@ -//! The task manager: mints [`TaskExecutor`]s, owns the shutdown signal, the -//! drain-guard counter, and the critical-task failure channel. - -use std::future::Future; -use std::panic::AssertUnwindSafe; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::Duration; - -use futures::FutureExt; -use tokio::sync::{mpsc, watch}; -use tracing::{error, info}; - -use crate::shutdown::{ - DrainOutcome, GracefulShutdown, GracefulShutdownGuard, Shutdown, ShutdownTrigger, -}; -use crate::task::TaskHandle; - -/// One queued wake-up is enough to force a drain recheck. -const DRAIN_WAKE_BUF: usize = 1; - -/// Owns the task lifecycle: hands out [`TaskExecutor`]s, observes critical -/// failures, and drives the bounded drain. Dropping it fires shutdown. -#[derive(Debug)] -pub struct TaskManager { - executor: TaskExecutor, - drained_rx: mpsc::Receiver<()>, - critical_rx: mpsc::UnboundedReceiver, -} - -impl TaskManager { - /// New manager spawning on the ambient tokio runtime; call within one. - pub fn new() -> Self { - let (signal_tx, _signal_rx) = watch::channel(false); - let (drained_tx, drained_rx) = mpsc::channel(DRAIN_WAKE_BUF); - let (critical_tx, critical_rx) = mpsc::unbounded_channel(); - Self { - executor: TaskExecutor { - handle: tokio::runtime::Handle::current(), - signal_tx, - drained_tx, - outstanding: Arc::new(AtomicUsize::new(0)), - critical_tx, - }, - drained_rx, - critical_rx, - } - } - - /// A clonable executor spawning onto this manager. - pub fn executor(&self) -> TaskExecutor { - self.executor.clone() - } - - /// A shutdown signal a task awaits via [`Shutdown::recv`]. - pub fn subscribe(&self) -> Shutdown { - Shutdown { - rx: self.executor.signal_tx.subscribe(), - } - } - - /// A trigger that fires the shutdown signal. - pub fn trigger(&self) -> ShutdownTrigger { - ShutdownTrigger { - signal_tx: self.executor.signal_tx.clone(), - } - } - - /// Resolves with the task name when a critical task ends or panics; - /// shutdown is already signalled by then. - pub async fn on_critical_failure(&mut self) -> String { - match self.critical_rx.recv().await { - Some(name) => name, - // No live executor, so no critical task can ever end. - None => std::future::pending().await, - } - } - - /// Fire the shutdown signal, then wait until every graceful guard has - /// released or `timeout` elapses. Firing is idempotent. - pub async fn graceful_shutdown_with_timeout(mut self, timeout: Duration) -> DrainOutcome { - self.executor.signal_tx.send_replace(true); - let deadline = tokio::time::sleep(timeout); - tokio::pin!(deadline); - loop { - if self.executor.outstanding.load(Ordering::SeqCst) == 0 { - return DrainOutcome::Drained; - } - tokio::select! { - () = &mut deadline => { - return DrainOutcome::TimedOut { - outstanding: self.executor.outstanding.load(Ordering::SeqCst), - }; - } - // Wake-ups coalesce; the count above is authoritative. - _ = self.drained_rx.recv() => {} - } - } - } -} - -impl Default for TaskManager { - fn default() -> Self { - Self::new() - } -} - -impl Drop for TaskManager { - /// A drop without a drain still stops the runtime. - fn drop(&mut self) { - self.executor.signal_tx.send_replace(true); - } -} - -/// Clonable spawn surface bound to one [`TaskManager`]. -#[derive(Debug, Clone)] -pub struct TaskExecutor { - handle: tokio::runtime::Handle, - signal_tx: watch::Sender, - drained_tx: mpsc::Sender<()>, - outstanding: Arc, - critical_tx: mpsc::UnboundedSender, -} - -impl TaskExecutor { - /// Spawn a regular task; it is not awaited on shutdown. - pub fn spawn(&self, fut: F) -> TaskHandle - where - F: Future + Send + 'static, - F::Output: Send + 'static, - { - TaskHandle(self.handle.spawn(fut)) - } - - /// Spawn a task whose end, by return or panic, shuts the runtime down. - pub fn spawn_critical(&self, name: &str, fut: F) -> TaskHandle<()> - where - F: Future + Send + 'static, - { - let name = name.to_owned(); - let signal_tx = self.signal_tx.clone(); - let critical_tx = self.critical_tx.clone(); - TaskHandle(self.handle.spawn(async move { - match AssertUnwindSafe(fut).catch_unwind().await { - Ok(()) => info!(task = %name, "critical task ended, shutting down"), - Err(_) => error!(task = %name, "critical task panicked, shutting down"), - } - signal_tx.send_replace(true); - let _ = critical_tx.send(name); - })) - } - - /// Spawn a task that flushes at shutdown: `f` receives a - /// [`GracefulShutdown`] whose guard blocks the drain until dropped. - pub fn spawn_graceful(&self, f: F) -> TaskHandle - where - F: FnOnce(GracefulShutdown) -> Fut, - Fut: Future + Send + 'static, - Fut::Output: Send + 'static, - { - let graceful = GracefulShutdown::new( - Shutdown { - rx: self.signal_tx.subscribe(), - }, - GracefulShutdownGuard::new(self.outstanding.clone(), self.drained_tx.clone()), - ); - TaskHandle(self.handle.spawn(f(graceful))) - } - - /// Run a blocking closure on the blocking pool. - pub fn spawn_blocking(&self, f: F) -> TaskHandle - where - F: FnOnce() -> T + Send + 'static, - T: Send + 'static, - { - TaskHandle(self.handle.spawn_blocking(f)) - } -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::AtomicBool; - - use super::*; - use crate::task::TaskExit; - - #[tokio::test] - async fn spawn_runs_the_future_to_its_result() { - let manager = TaskManager::new(); - let handle = manager.executor().spawn(async { TaskExit::ReceiverGone }); - assert_eq!(handle.join().await, Some(TaskExit::ReceiverGone)); - } - - #[tokio::test] - async fn spawn_critical_panic_triggers_shutdown() { - let mut manager = TaskManager::new(); - let mut signal = manager.subscribe(); - manager - .executor() - .spawn_critical("boom", async { panic!("boom") }); - signal.recv().await; - assert_eq!(manager.on_critical_failure().await, "boom"); - } - - #[tokio::test] - async fn spawn_critical_return_triggers_shutdown() { - let mut manager = TaskManager::new(); - let mut signal = manager.subscribe(); - manager.executor().spawn_critical("done", async {}); - signal.recv().await; - assert_eq!(manager.on_critical_failure().await, "done"); - } - - #[tokio::test] - async fn graceful_guard_blocks_the_drain_until_dropped() { - let manager = TaskManager::new(); - let flushed = Arc::new(AtomicBool::new(false)); - let seen = flushed.clone(); - manager - .executor() - .spawn_graceful(move |graceful| async move { - let guard = graceful.await; - tokio::time::sleep(Duration::from_millis(20)).await; - seen.store(true, Ordering::SeqCst); - drop(guard); - }); - assert_eq!( - manager - .graceful_shutdown_with_timeout(Duration::from_secs(5)) - .await, - DrainOutcome::Drained - ); - assert!( - flushed.load(Ordering::SeqCst), - "flush ran before the drain returned" - ); - } - - #[tokio::test] - async fn drain_times_out_with_the_outstanding_count() { - let manager = TaskManager::new(); - manager.executor().spawn_graceful(|graceful| async move { - let _guard = graceful.await; - tokio::time::sleep(Duration::from_secs(60)).await; - }); - assert_eq!( - manager - .graceful_shutdown_with_timeout(Duration::from_millis(50)) - .await, - DrainOutcome::TimedOut { outstanding: 1 } - ); - } - - #[tokio::test] - async fn signal_fired_before_subscribe_is_not_lost() { - let manager = TaskManager::new(); - manager.trigger().fire(); - let mut signal = manager.subscribe(); - signal.recv().await; - } - - #[tokio::test] - async fn dropping_the_manager_fires_shutdown() { - let manager = TaskManager::new(); - let mut signal = manager.subscribe(); - drop(manager); - signal.recv().await; - } - - #[tokio::test] - async fn spawn_blocking_yields_the_closure_result() { - let manager = TaskManager::new(); - let handle = manager.executor().spawn_blocking(|| 7u32); - assert_eq!(handle.join().await, Some(7)); - } - - /// A graceful task counts against the drain from spawn, so a drain that - /// starts before the task first polls still waits for it. - #[tokio::test] - async fn drain_waits_for_a_graceful_task_that_has_not_polled() { - let manager = TaskManager::new(); - manager.executor().spawn_graceful(|graceful| async move { - tokio::time::sleep(Duration::from_millis(20)).await; - drop(graceful.await); - }); - assert_eq!( - manager - .graceful_shutdown_with_timeout(Duration::from_secs(5)) - .await, - DrainOutcome::Drained - ); - } -} diff --git a/nexum/crates/nexum-tasks/src/shutdown.rs b/nexum/crates/nexum-tasks/src/shutdown.rs deleted file mode 100644 index 45dd97fb..00000000 --- a/nexum/crates/nexum-tasks/src/shutdown.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Shutdown signalling: one latched watch signal fanned to tasks, plus the -//! drain guards the manager blocks on before exit. - -use std::future::{Future, IntoFuture}; -use std::pin::Pin; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use tokio::sync::{mpsc, watch}; - -/// Outcome of a bounded drain. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DrainOutcome { - /// Every graceful guard released before the deadline. - Drained, - /// The deadline fired with `outstanding` guards still held; force exit. - TimedOut { - /// Guards still held when the deadline fired. - outstanding: usize, - }, -} - -/// Cheap, clonable handle that fires the shutdown signal. Idempotent. -#[derive(Debug, Clone)] -pub struct ShutdownTrigger { - pub(crate) signal_tx: watch::Sender, -} - -impl ShutdownTrigger { - /// Fire the shutdown signal. Latched, so a fire before the first - /// subscribe is not lost. - pub fn fire(&self) { - self.signal_tx.send_replace(true); - } -} - -/// A shutdown signal a task awaits. Resolves once fired, already fired, or -/// the manager is dropped. -#[derive(Debug, Clone)] -pub struct Shutdown { - pub(crate) rx: watch::Receiver, -} - -impl Shutdown { - /// Await the shutdown signal; resolves immediately if already fired and - /// is safe to await more than once. - pub async fn recv(&mut self) { - if *self.rx.borrow_and_update() { - return; - } - // A `changed` error means the manager was dropped: treat as shutdown - // so the task winds down rather than hanging. - while self.rx.changed().await.is_ok() { - if *self.rx.borrow_and_update() { - return; - } - } - } -} - -/// Resolves when shutdown is signalled, yielding the guard the task holds -/// while it flushes. The guard is counted from spawn, so a drain that starts -/// before the task polls still waits for it. -#[derive(Debug)] -pub struct GracefulShutdown { - signal: Shutdown, - guard: GracefulShutdownGuard, -} - -impl GracefulShutdown { - pub(crate) fn new(signal: Shutdown, guard: GracefulShutdownGuard) -> Self { - Self { signal, guard } - } -} - -impl IntoFuture for GracefulShutdown { - type Output = GracefulShutdownGuard; - type IntoFuture = Pin + Send>>; - - fn into_future(self) -> Self::IntoFuture { - let Self { mut signal, guard } = self; - Box::pin(async move { - signal.recv().await; - guard - }) - } -} - -/// Held by a task that must flush before exit; dropping it releases the -/// drain. -#[derive(Debug)] -pub struct GracefulShutdownGuard { - outstanding: Arc, - drained_tx: mpsc::Sender<()>, -} - -impl GracefulShutdownGuard { - /// Counts itself into `outstanding` on creation. - pub(crate) fn new(outstanding: Arc, drained_tx: mpsc::Sender<()>) -> Self { - outstanding.fetch_add(1, Ordering::SeqCst); - Self { - outstanding, - drained_tx, - } - } -} - -impl Drop for GracefulShutdownGuard { - fn drop(&mut self) { - self.outstanding.fetch_sub(1, Ordering::SeqCst); - // Queue a drain wake-up; a full queue already holds one. - let _ = self.drained_tx.try_send(()); - } -} diff --git a/nexum/crates/nexum-tasks/src/task.rs b/nexum/crates/nexum-tasks/src/task.rs deleted file mode 100644 index dbabe9a1..00000000 --- a/nexum/crates/nexum-tasks/src/task.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Typed handles over spawned tasks and the abort-on-drop set the event -//! loop drains at shutdown. - -use tracing::debug; - -/// Why a pump task returned; the sole ordinary exit is its downstream -/// receiver closing at shutdown. -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub enum TaskExit { - /// The receiver the task feeds was dropped, so it stopped pumping. - ReceiverGone, -} - -/// Handle to one spawned task. -#[derive(Debug)] -pub struct TaskHandle(pub(crate) tokio::task::JoinHandle); - -impl TaskHandle { - /// Request cancellation; the task stops at its next await point. - pub fn abort(&self) { - self.0.abort(); - } - - /// Wait for the task to finish. `None` when it was aborted or panicked. - pub async fn join(self) -> Option { - self.0.await.ok() - } -} - -/// The pump-task handles the event loop owns for its lifetime; abortable as -/// a set so every task is observed to finish before the engine returns. -#[derive(Debug, Default)] -pub struct TaskSet { - handles: Vec>, -} - -impl TaskSet { - /// An empty set. - pub fn new() -> Self { - Self::default() - } - - /// Take ownership of a freshly spawned task's handle. - pub fn push(&mut self, handle: TaskHandle) { - self.handles.push(handle); - } - - /// Abort every task, then await each handle so all tasks are observed - /// to finish. A `None` join (aborted or panicked) counts against the - /// aborted tally in the drain summary. - pub async fn shutdown(mut self) { - for handle in &self.handles { - handle.abort(); - } - let total = self.handles.len(); - let mut clean = 0usize; - let mut aborted = 0usize; - for handle in self.handles.drain(..) { - match handle.join().await { - Some(_) => clean += 1, - None => aborted += 1, - } - } - debug!(total, clean, aborted, "pump task set drained"); - } -} - -impl Drop for TaskSet { - /// Abort any handles [`shutdown`](TaskSet::shutdown) did not drain, so - /// the tasks do not detach and outlive the engine (a bare `JoinHandle` - /// detaches on drop). - fn drop(&mut self) { - for handle in &self.handles { - handle.abort(); - } - } -} diff --git a/nexum/crates/nexum-world/Cargo.toml b/nexum/crates/nexum-world/Cargo.toml deleted file mode 100644 index 088d377a..00000000 --- a/nexum/crates/nexum-world/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "nexum-world" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Per-module WIT world synthesis: the core capability table, registry-driven extension rows, manifest parsing, and crate-local WIT package resolution." - -[lints] -workspace = true - -[dependencies] -toml.workspace = true - -[dev-dependencies] -tempfile.workspace = true diff --git a/nexum/crates/nexum-world/src/lib.rs b/nexum/crates/nexum-world/src/lib.rs deleted file mode 100644 index 5ca9e1ca..00000000 --- a/nexum/crates/nexum-world/src/lib.rs +++ /dev/null @@ -1,743 +0,0 @@ -//! Per-module world synthesis: turn a manifest's `[capabilities]` -//! declarations into an inline WIT world whose imports are exactly the -//! declared capability interfaces. -//! -//! The one non-obvious invariant: the capability rows here must agree -//! with the runtime's capability registry (`nexum-runtime`'s manifest -//! enforcement) on both the capability names and the WIT interfaces they -//! map to. The runtime cross-checks a component's imports against the -//! manifest at load time; because the imports are derived from the same -//! manifest, a macro-built component passes that check by construction -//! rather than by relying on the toolchain eliding unused imports. -//! -//! The table here carries only the core `nexum:host` rows. Per-namespace -//! rows come from the composition root's `extensions.toml` registry -//! ([`manifest_extensions`]): the caller passes them to [`synthesize`], -//! so this crate carries no downstream name. - -use std::path::{Path, PathBuf}; - -/// Capability name consts: the single source the [`CORE`] table and the -/// runtime's capability registry emit from. -pub mod caps { - /// `nexum:host/chain`. - pub const CHAIN: &str = "chain"; - /// `nexum:host/identity`. - pub const IDENTITY: &str = "identity"; - /// `nexum:host/local-store`. - pub const LOCAL_STORE: &str = "local-store"; - /// `nexum:host/remote-store`. - pub const REMOTE_STORE: &str = "remote-store"; - /// `nexum:host/messaging`. - pub const MESSAGING: &str = "messaging"; - /// `nexum:host/logging`. - pub const LOGGING: &str = "logging"; - /// Gates `wasi:http/*`; no world import. - pub const HTTP: &str = "http"; -} - -/// Snake_case labels of the `nexum:host/types.fault` cases, in -/// declaration order: the single source every label mirror emits from. -pub mod fault_labels { - /// `fault.unsupported`. - pub const UNSUPPORTED: &str = "unsupported"; - /// `fault.unavailable`. - pub const UNAVAILABLE: &str = "unavailable"; - /// `fault.denied`. - pub const DENIED: &str = "denied"; - /// `fault.rate-limited`. - pub const RATE_LIMITED: &str = "rate_limited"; - /// `fault.timeout`. - pub const TIMEOUT: &str = "timeout"; - /// `fault.invalid-input`. - pub const INVALID_INPUT: &str = "invalid_input"; - /// `fault.internal`. - pub const INTERNAL: &str = "internal"; - /// All seven, in declaration order. - pub const ALL: [&str; 7] = [ - UNSUPPORTED, - UNAVAILABLE, - DENIED, - RATE_LIMITED, - TIMEOUT, - INVALID_INPUT, - INTERNAL, - ]; -} - -/// One manifest capability and its world wiring. -pub struct Capability { - /// The name declared under `[capabilities].required` / `optional`. - pub name: &'static str, - /// The WIT import the declaration turns into, or `None` for - /// capabilities with no world import (`http` is granted through the - /// SDK's wasi:http client and the host allowlist, not the world). - pub import: Option<&'static str>, - /// WIT package directories the import needs on the resolve path, - /// beyond `nexum-host`. - pub packages: &'static [&'static str], - /// The `bind_host_via_wit_bindgen!` capability ident carrying this - /// capability's host-adapter pieces, if the SDK has a trait seam - /// for it. - pub adapter: Option<&'static str>, -} - -/// The core capability rows, in emission order. Mirrors the runtime's -/// core registry and nothing else; extension rows are the caller's. -pub const CORE: &[Capability] = &[ - Capability { - name: caps::CHAIN, - import: Some("nexum:host/chain@0.1.0"), - packages: &[], - adapter: Some("chain"), - }, - Capability { - name: caps::IDENTITY, - import: Some("nexum:host/identity@0.1.0"), - packages: &[], - adapter: Some("identity"), - }, - Capability { - name: caps::LOCAL_STORE, - import: Some("nexum:host/local-store@0.1.0"), - packages: &[], - adapter: Some("local_store"), - }, - Capability { - name: caps::REMOTE_STORE, - import: Some("nexum:host/remote-store@0.1.0"), - packages: &[], - adapter: Some("remote_store"), - }, - Capability { - name: caps::MESSAGING, - import: Some("nexum:host/messaging@0.1.0"), - packages: &[], - adapter: Some("messaging"), - }, - Capability { - name: caps::LOGGING, - import: Some("nexum:host/logging@0.1.0"), - packages: &[], - adapter: Some("logging"), - }, - Capability { - name: caps::HTTP, - import: None, - packages: &[], - adapter: None, - }, -]; - -/// Number of import-bearing [`CORE`] rows. -const fn core_iface_count() -> usize { - let mut n = 0; - let mut i = 0; - while i < CORE.len() { - if CORE[i].import.is_some() { - n += 1; - } - i += 1; - } - n -} - -/// Names of the import-bearing [`CORE`] rows, in emission order: the -/// `nexum:host` interface set the runtime's capability registry -/// enforces. `http` is absent (no world import). -pub const CORE_IFACES: [&str; core_iface_count()] = { - let mut out = [""; core_iface_count()]; - let mut n = 0; - let mut i = 0; - while i < CORE.len() { - if CORE[i].import.is_some() { - out[n] = CORE[i].name; - n += 1; - } - i += 1; - } - out -}; - -/// One registered extension row: a per-namespace capability a -/// composition root declares in its `extensions.toml`. An extension -/// always has a WIT import and never a host-adapter ident (adapter -/// seams are core-only). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExtensionRow { - /// The name modules declare under `[capabilities]`. - pub name: String, - /// The WIT import the declaration turns into. - pub import: String, - /// WIT package directories the import needs on the resolve path, - /// beyond `nexum-host`, in dependency order. - pub packages: Vec, -} - -/// The synthesized world plus what the `generate!` call and the host -/// adapter need to go with it. -#[derive(Debug)] -pub struct ModuleWorld { - /// Inline WIT text defining `nexum:module-world/module`. - pub wit: String, - /// WIT package directories the resolve path must carry, in - /// dependency order (a package precedes its dependants). Always - /// starts with the base set the host `event` variant needs. - pub packages: Vec, - /// Capability idents to pass to `bind_host_via_wit_bindgen!`. - pub adapters: Vec<&'static str>, -} - -/// Extract the declared capability names (`required` then `optional`) -/// from the manifest text. A missing or malformed `[capabilities]` -/// section is an error: the emitted world is derived from it, so the -/// synthesis has nothing to build from without one. -pub fn manifest_capabilities(text: &str) -> Result, String> { - let value: toml::Table = text - .parse() - .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; - let caps = value.get("capabilities").ok_or_else(|| { - "module.toml has no [capabilities] section; the module/adapter macro derives the \ - component's WIT world from [capabilities].required/optional, so declare it (an empty \ - `required = []` is valid)" - .to_string() - })?; - let list = |key: &str| -> Result, String> { - match caps.get(key) { - None => Ok(Vec::new()), - Some(v) => v - .as_array() - .ok_or_else(|| format!("[capabilities].{key} must be an array of strings"))? - .iter() - .map(|item| { - item.as_str() - .map(str::to_owned) - .ok_or_else(|| format!("[capabilities].{key} must contain only strings")) - }) - .collect(), - } - }; - let mut names = list("required")?; - names.extend(list("optional")?); - Ok(names) -} - -/// Extract the declared `[module] kind` from the manifest text, `None` -/// when absent (the runtime defaults an absent kind to the worker). -pub fn manifest_kind(text: &str) -> Result, String> { - let value: toml::Table = text - .parse() - .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; - match value.get("module").and_then(|module| module.get("kind")) { - None => Ok(None), - Some(kind) => kind - .as_str() - .map(|kind| Some(kind.to_owned())) - .ok_or_else(|| "[module].kind must be a string".to_string()), - } -} - -/// Parse the registered extension rows from an `extensions.toml`. Each -/// `[extensions.]` table carries the WIT `import` the declaration -/// turns into and the extra `packages` its resolve path needs. A file -/// without an `[extensions]` section registers nothing. -pub fn manifest_extensions(text: &str) -> Result, String> { - let value: toml::Table = text - .parse() - .map_err(|e| format!("extensions.toml is not valid TOML: {e}"))?; - let Some(extensions) = value.get("extensions") else { - return Ok(Vec::new()); - }; - let extensions = extensions - .as_table() - .ok_or_else(|| "[extensions] must be a table of `[extensions.]` rows".to_string())?; - extensions - .iter() - .map(|(name, row)| { - let row = row - .as_table() - .ok_or_else(|| format!("[extensions.{name}] must be a table"))?; - let import = row - .get("import") - .and_then(toml::Value::as_str) - .ok_or_else(|| format!("[extensions.{name}] must carry a string `import`"))? - .to_owned(); - let packages = match row.get("packages") { - None => Vec::new(), - Some(value) => value - .as_array() - .ok_or_else(|| { - format!("[extensions.{name}].packages must be an array of strings") - })? - .iter() - .map(|item| { - item.as_str().map(str::to_owned).ok_or_else(|| { - format!("[extensions.{name}].packages must contain only strings") - }) - }) - .collect::>()?, - }; - Ok(ExtensionRow { - name: name.clone(), - import, - packages, - }) - }) - .collect() -} - -/// Find the extension registry for a build rooted at `start`: the -/// nearest ancestor `extensions.toml`. `None` means no registered -/// extensions. -pub fn find_extensions_manifest(start: &Path) -> Option { - let mut dir = Some(start); - while let Some(cur) = dir { - let candidate = cur.join("extensions.toml"); - if candidate.is_file() { - return Some(candidate); - } - dir = cur.parent(); - } - None -} - -/// Build the per-module world from the declared capability names -/// (required and optional alike: an optional capability must still be -/// importable, the host decides at load time whether to back or stub -/// it). `extensions` carries the per-namespace rows of the registered -/// extensions, emitted after the core rows. Unknown names are an error -/// so a typo cannot silently drop an import; a registered name that -/// shadows a core row or another registration is an error so a -/// colliding registry cannot emit a duplicate import. -pub fn synthesize(declared: &[String], extensions: &[ExtensionRow]) -> Result { - for (idx, ext) in extensions.iter().enumerate() { - if CORE.iter().any(|c| c.name == ext.name) - || extensions[..idx].iter().any(|prior| prior.name == ext.name) - { - return Err(format!( - "extension capability `{}` collides with an already-registered capability; \ - names must be unique across the core table and the registered extensions", - ext.name - )); - } - } - - let known = || { - CORE.iter() - .map(|c| c.name) - .chain(extensions.iter().map(|e| e.name.as_str())) - }; - for name in declared { - if !known().any(|k| k == name.as_str()) { - let names = known().collect::>().join(", "); - return Err(format!( - "unknown capability `{name}` in module.toml [capabilities]; expected one of: \ - {names}" - )); - } - } - - let mut imports = String::new(); - // `nexum:host` is a leaf package (the `event` variant carries status - // transitions as opaque bytes), so the base resolve set - // is the host package alone; capability declarations append their - // own packages. Dependency order: each directory is parsed against - // the packages before it, so a package precedes its dependants. - let mut packages = vec!["nexum-host".to_owned()]; - let mut adapters = Vec::new(); - for cap in CORE { - if !declared.iter().any(|d| d == cap.name) { - continue; - } - if let Some(import) = cap.import { - imports.push_str(&format!(" import {import};\n")); - } - for package in cap.packages { - if !packages.iter().any(|p| p == package) { - packages.push((*package).to_owned()); - } - } - if let Some(adapter) = cap.adapter { - adapters.push(adapter); - } - } - for ext in extensions { - if !declared.contains(&ext.name) { - continue; - } - imports.push_str(&format!(" import {};\n", ext.import)); - for package in &ext.packages { - if !packages.contains(package) { - packages.push(package.clone()); - } - } - } - - let mut wit = String::from( - "package nexum:module-world;\n\nworld module {\n \ - use nexum:host/types@0.1.0.{config, event, fault};\n\n", - ); - wit.push_str(&imports); - wit.push_str( - "\n export init: func(config: config) -> result<_, fault>;\n \ - export on-event: func(event: event) -> result<_, fault>;\n}\n", - ); - - Ok(ModuleWorld { - wit, - packages, - adapters, - }) -} - -/// Resolve each WIT package directory for a component build rooted at -/// `start` (the consuming crate's manifest directory). A package -/// resolves crate-locally, vendored `wit/deps/` before own -/// `wit/`; a crate not carrying it falls back to the nearest -/// ancestor `wit/` that does (the transitional monorepo layout). -pub fn resolve_wit_packages>( - start: &Path, - packages: &[S], -) -> Result, String> { - packages - .iter() - .map(|package| { - let package = package.as_ref(); - resolve_wit_package(start, package).ok_or_else(|| { - format!( - "declared capabilities need the `{package}` WIT package, but neither \ - `wit/deps/{package}` nor `wit/{package}` exists under {} or any ancestor", - start.display() - ) - }) - }) - .collect() -} - -/// Find one package directory: crate-local `wit/deps/` then -/// `wit/`, walking up on a miss. -fn resolve_wit_package(start: &Path, package: &str) -> Option { - let mut dir = Some(start); - while let Some(cur) = dir { - let wit = cur.join("wit"); - for candidate in [wit.join("deps").join(package), wit.join(package)] { - if candidate.is_dir() { - return Some(candidate); - } - } - dir = cur.parent(); - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The base package set every module world resolves against: - /// `nexum:host` is a leaf package, so it stands alone. - const MODULE_PACKAGES: [&str; 1] = ["nexum-host"]; - - /// A stand-in extension row, as a registered extension would pass. - fn ext() -> Vec { - vec![ExtensionRow { - name: "acme".to_owned(), - import: "acme:ext/api@0.1.0".to_owned(), - packages: vec!["acme-ext".to_owned()], - }] - } - - #[test] - fn logging_only_world_imports_logging_alone() { - let world = synthesize(&["logging".to_string()], &[]).unwrap(); - assert!(world.wit.contains("import nexum:host/logging@0.1.0;")); - assert!(!world.wit.contains("import nexum:host/chain")); - assert_eq!(world.packages, MODULE_PACKAGES); - assert_eq!(world.adapters, vec!["logging"]); - } - - #[test] - fn extension_row_emits_its_import_and_packages() { - let world = synthesize(&["logging".to_string(), "acme".to_string()], &ext()).unwrap(); - assert!(world.wit.contains("import acme:ext/api@0.1.0;")); - assert_eq!(world.packages, vec!["nexum-host", "acme-ext"]); - } - - #[test] - fn undeclared_extension_row_stays_out_of_the_world() { - let world = synthesize(&["logging".to_string()], &ext()).unwrap(); - assert!(!world.wit.contains("acme")); - assert_eq!(world.packages, MODULE_PACKAGES); - } - - #[test] - fn extension_shadowing_a_core_name_is_rejected() { - let rows = vec![ExtensionRow { - name: "chain".to_owned(), - import: "acme:ext/chain@0.1.0".to_owned(), - packages: Vec::new(), - }]; - let err = synthesize(&["chain".to_string()], &rows).unwrap_err(); - assert!(err.contains("extension capability `chain` collides")); - } - - #[test] - fn duplicate_extension_registration_is_rejected() { - let mut rows = ext(); - rows.extend(ext()); - let err = synthesize(&[], &rows).unwrap_err(); - assert!(err.contains("extension capability `acme` collides")); - } - - #[test] - fn core_ifaces_are_the_import_bearing_rows() { - assert_eq!( - CORE_IFACES, - [ - caps::CHAIN, - caps::IDENTITY, - caps::LOCAL_STORE, - caps::REMOTE_STORE, - caps::MESSAGING, - caps::LOGGING, - ], - ); - assert!(!CORE_IFACES.contains(&caps::HTTP)); - } - - #[test] - fn fault_labels_are_snake_case_and_distinct() { - for label in fault_labels::ALL { - assert!(label.chars().all(|c| c.is_ascii_lowercase() || c == '_')); - } - let mut labels = fault_labels::ALL.to_vec(); - labels.sort_unstable(); - labels.dedup(); - assert_eq!(labels.len(), fault_labels::ALL.len()); - } - - #[test] - fn core_table_carries_no_extension_row() { - assert!( - CORE.iter() - .all(|c| c.import.is_none_or(|i| i.starts_with("nexum:host/"))) - ); - assert!(CORE.iter().all(|c| c.packages.is_empty())); - } - - #[test] - fn every_import_bearing_core_row_carries_an_adapter() { - // `http` has no world import (SDK wasi:http client) and no - // adapter; every other core row has both. - for cap in CORE { - assert_eq!(cap.import.is_some(), cap.adapter.is_some(), "{}", cap.name); - } - } - - #[test] - fn full_declaration_emits_the_six_adapters_in_core_order() { - let declared: Vec = CORE.iter().map(|c| c.name.to_string()).collect(); - let world = synthesize(&declared, &[]).unwrap(); - assert_eq!( - world.adapters, - vec![ - "chain", - "identity", - "local_store", - "remote_store", - "messaging", - "logging", - ], - ); - } - - #[test] - fn http_declares_no_world_import() { - let world = synthesize(&["logging".to_string(), "http".to_string()], &[]).unwrap(); - assert!(!world.wit.contains("wasi:http")); - assert_eq!(world.packages, MODULE_PACKAGES); - } - - #[test] - fn duplicate_declarations_emit_one_import() { - let world = synthesize(&["chain".to_string(), "chain".to_string()], &[]).unwrap(); - assert_eq!(world.wit.matches("import nexum:host/chain").count(), 1); - assert_eq!(world.adapters, vec!["chain"]); - } - - #[test] - fn unknown_capability_is_rejected_with_the_known_list() { - let err = synthesize(&["telepathy".to_string()], &ext()).unwrap_err(); - assert!(err.contains("unknown capability `telepathy`")); - assert!(err.contains("logging")); - assert!(err.contains("acme")); - } - - #[test] - fn manifest_extensions_reads_rows() { - let rows = manifest_extensions( - r#" -[extensions.acme] -import = "acme:ext/api@0.1.0" -packages = ["acme-base", "acme-ext"] - -[extensions.beta] -import = "beta:ext/api@0.1.0" -"#, - ) - .unwrap(); - assert_eq!(rows, { - let mut expected = ext(); - expected[0].packages = vec!["acme-base".to_owned(), "acme-ext".to_owned()]; - expected.push(ExtensionRow { - name: "beta".to_owned(), - import: "beta:ext/api@0.1.0".to_owned(), - packages: Vec::new(), - }); - expected - }); - } - - #[test] - fn manifest_without_extensions_section_registers_nothing() { - assert_eq!(manifest_extensions("").unwrap(), Vec::new()); - } - - #[test] - fn extension_row_without_an_import_is_an_error() { - let err = manifest_extensions("[extensions.acme]\npackages = []\n").unwrap_err(); - assert!(err.contains("[extensions.acme] must carry a string `import`")); - } - - #[test] - fn extension_row_with_non_string_package_is_an_error() { - let err = - manifest_extensions("[extensions.acme]\nimport = \"a:b/c@0.1.0\"\npackages = [1]\n") - .unwrap_err(); - assert!(err.contains("only strings")); - } - - #[test] - fn manifest_capabilities_reads_required_and_optional() { - let caps = manifest_capabilities( - r#" -[capabilities] -required = ["logging", "chain"] -optional = ["remote-store"] - -[capabilities.http] -allow = [] -"#, - ) - .unwrap(); - assert_eq!(caps, vec!["logging", "chain", "remote-store"]); - } - - #[test] - fn manifest_kind_reads_the_module_kind() { - let kind = manifest_kind("[module]\nname = \"x\"\nkind = \"venue-adapter\"\n").unwrap(); - assert_eq!(kind.as_deref(), Some("venue-adapter")); - } - - #[test] - fn manifest_without_a_kind_is_none() { - assert_eq!(manifest_kind("[module]\nname = \"x\"\n").unwrap(), None); - assert_eq!(manifest_kind("").unwrap(), None); - } - - #[test] - fn manifest_with_a_non_string_kind_is_an_error() { - let err = manifest_kind("[module]\nkind = 3\n").unwrap_err(); - assert!(err.contains("[module].kind must be a string")); - } - - #[test] - fn manifest_without_capabilities_section_is_an_error() { - let err = manifest_capabilities("[module]\nname = \"x\"\n").unwrap_err(); - assert!(err.contains("[capabilities]")); - } - - #[test] - fn manifest_with_non_string_capability_is_an_error() { - let err = manifest_capabilities("[capabilities]\nrequired = [1]\n").unwrap_err(); - assert!(err.contains("only strings")); - } - - #[test] - fn world_is_valid_wit_shape() { - // Not a full WIT parse (that is the module build's job); pin the - // structural pieces the runtime contract depends on. - let world = synthesize(&["logging".to_string()], &[]).unwrap(); - assert!(world.wit.starts_with("package nexum:module-world;")); - assert!(world.wit.contains("world module {")); - assert!( - world - .wit - .contains("export init: func(config: config) -> result<_, fault>;") - ); - assert!( - world - .wit - .contains("export on-event: func(event: event) -> result<_, fault>;") - ); - } - - #[test] - fn resolution_prefers_vendored_deps_over_own_wit() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::create_dir_all(root.join("wit/deps/pkg")).unwrap(); - std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); - let paths = resolve_wit_packages(root, &["pkg"]).unwrap(); - assert_eq!(paths, vec![root.join("wit/deps/pkg")]); - } - - #[test] - fn resolution_falls_back_to_the_nearest_ancestor() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); - let leaf = root.join("crates/leaf"); - std::fs::create_dir_all(&leaf).unwrap(); - let paths = resolve_wit_packages(&leaf, &["pkg"]).unwrap(); - assert_eq!(paths, vec![root.join("wit/pkg")]); - } - - #[test] - fn crate_local_package_shadows_the_ancestor() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); - let leaf = root.join("crates/leaf"); - std::fs::create_dir_all(leaf.join("wit/deps/pkg")).unwrap(); - let paths = resolve_wit_packages(&leaf, &["pkg"]).unwrap(); - assert_eq!(paths, vec![leaf.join("wit/deps/pkg")]); - } - - #[test] - fn extension_registry_resolves_from_the_nearest_ancestor() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - std::fs::write(root.join("extensions.toml"), "").unwrap(); - let leaf = root.join("crates/leaf"); - std::fs::create_dir_all(&leaf).unwrap(); - assert_eq!( - find_extensions_manifest(&leaf), - Some(root.join("extensions.toml")) - ); - } - - #[test] - fn absent_extension_registry_is_none() { - let dir = tempfile::tempdir().unwrap(); - assert_eq!(find_extensions_manifest(dir.path()), None); - } - - #[test] - fn missing_package_names_the_paths_tried() { - let dir = tempfile::tempdir().unwrap(); - let err = resolve_wit_packages(dir.path(), &["pkg"]).unwrap_err(); - assert!(err.contains("`pkg` WIT package")); - assert!(err.contains("wit/deps/pkg")); - } -} diff --git a/nexum/modules/example/Cargo.toml b/nexum/modules/example/Cargo.toml deleted file mode 100644 index 19311814..00000000 --- a/nexum/modules/example/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "example" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true - -[lints] -workspace = true - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nexum-sdk = { path = "../../crates/nexum-sdk" } -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/nexum/modules/example/module.toml b/nexum/modules/example/module.toml deleted file mode 100644 index 528c84bf..00000000 --- a/nexum/modules/example/module.toml +++ /dev/null @@ -1,27 +0,0 @@ -# Example module manifest - exercises the 0.2 manifest schema end-to-end. - -[module] -name = "example" -version = "0.1.0" -# Placeholder content hash. 0.2 parses but does not verify this; 0.3 will -# compare it against the sha256 of the loaded component bytes. -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -# 0.2 reference engine provides all listed capabilities; this list is a -# sanity check + future-proofing. -required = ["logging"] - -# Capabilities the module would use opportunistically. In 0.2 these are -# parsed and logged; trap-stub fallback for absent optionals ships in 0.3. -optional = [] - -[capabilities.http] -# Per-module HTTP allowlist. Empty list = no outbound HTTP permitted. -# Entries are exact hostnames or *.domain wildcards. -allow = [] - -[config] -# Stringly-typed in 0.2 (typed variant on 0.3 roadmap). Numbers and -# booleans are flattened to their text form by the host on the way through. -name = "example" diff --git a/nexum/modules/example/src/lib.rs b/nexum/modules/example/src/lib.rs deleted file mode 100644 index da1ddd26..00000000 --- a/nexum/modules/example/src/lib.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! # example (reference Shepherd module) -//! -//! The minimal reference module: one handler per event, each logging a -//! one-line summary through the raw host `logging` binding. It carries -//! no strategy layer and no `[config]` behaviour, so it doubles as the -//! smallest end-to-end demonstration of `#[nexum_sdk::module]` - the -//! attribute supplies the wit-bindgen call, the host adapter, the -//! `Guest`/`on-event` dispatch, and `export!`, leaving only the -//! handlers. - -// wit_bindgen::generate! expands to host-import shims whose arity matches -// the WIT signatures, which can exceed clippy's too-many-arguments threshold. -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -use nexum::host::{logging, types}; - -struct ExampleModule; - -#[nexum_sdk::module] -impl ExampleModule { - fn init(config: Vec<(String, String)>) -> Result<(), Fault> { - let name = config - .iter() - .find(|(k, _)| k == "name") - .map(|(_, v)| v.as_str()) - .unwrap_or("unknown"); - logging::log( - logging::Level::Info, - &format!("example module init (name={name})"), - ); - Ok(()) - } - - fn on_block(block: types::Block) -> Result<(), Fault> { - logging::log( - logging::Level::Info, - &format!( - "block {} on chain {} (ts={}ms)", - block.number, block.chain_id, block.timestamp - ), - ); - Ok(()) - } - - fn on_chain_logs(batch: types::ChainLogs) -> Result<(), Fault> { - logging::log( - logging::Level::Info, - &format!("received {} chain-log entries", batch.logs.len()), - ); - Ok(()) - } - - fn on_tick(tick: types::Tick) -> Result<(), Fault> { - logging::log( - logging::Level::Info, - &format!("tick fired at {}ms", tick.fired_at), - ); - Ok(()) - } - - fn on_message(msg: types::Message) -> Result<(), Fault> { - logging::log( - logging::Level::Info, - &format!("message on topic {}", msg.content_topic), - ); - Ok(()) - } - - fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { - let body = nexum_sdk::status_body::StatusBody::decode(&update.status) - .map_err(|err| Fault::InvalidInput(err.to_string()))?; - logging::log( - logging::Level::Info, - &format!( - "intent status update from venue {}: {:?} ({} receipt bytes)", - update.venue, - body.status, - update.receipt.len(), - ), - ); - Ok(()) - } -} diff --git a/nexum/modules/examples/balance-tracker/Cargo.toml b/nexum/modules/examples/balance-tracker/Cargo.toml deleted file mode 100644 index 23ac8ef1..00000000 --- a/nexum/modules/examples/balance-tracker/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "balance-tracker" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Shepherd example module: tracks native-token balances of a list of addresses and emits a log when one changes by more than a threshold. Demonstrates chain::request + local-store + multi-key persistence." - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nexum-sdk = { path = "../../../crates/nexum-sdk" } -tracing = { version = "0.1", default-features = false } -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } - -[dev-dependencies] -nexum-sdk-test = { path = "../../../crates/nexum-sdk-test" } diff --git a/nexum/modules/examples/balance-tracker/module.toml b/nexum/modules/examples/balance-tracker/module.toml deleted file mode 100644 index ce14fc80..00000000 --- a/nexum/modules/examples/balance-tracker/module.toml +++ /dev/null @@ -1,42 +0,0 @@ -# balance-tracker example module: tracks native-token balances of a -# fixed address list and emits a Warn log when one moves by more than -# `change_threshold` wei between blocks. Demonstrates `chain::request` -# (non-eth_call), per-key `local-store` state, and "diff-against-last- -# seen" patterns reusable across indexer modules. - -[module] -name = "balance-tracker" -version = "0.1.0" -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -required = ["logging", "chain", "local-store"] -optional = [] - -[capabilities.http] -allow = [] - -# --- subscriptions ---------------------------------------------------- - -[[subscription]] -kind = "block" -chain_id = 11155111 - -# --- config ----------------------------------------------------------- - -[config] -# Comma-separated list of 0x-prefixed 20-byte addresses. Whitespace -# around entries is tolerated. -# -# E2E pinning: the test EOA + Safe co-located on Sepolia. -# Both fund themselves during the runbook's prep step (EOA via -# faucet, Safe via EOA send), so balance-tracker writes -# `last:0x7bf1...` and `last:0x1499...` on the first dispatch and -# logs a Warn diff if the Safe later receives the TWAP order's -# gas-side transfer (or any subsequent move >= change_threshold). -addresses = "0x7bF140727D27ea64b607E042f1225680B40ECa6A,0x14995a1118Caf95833e923faf8Dd155721cd53c2" -# Change threshold in wei. Lowered from 0.1 ETH to 0.001 ETH so the -# E2E run's small on-chain actions (wrap-to-WETH ~0.005 ETH gas cost, -# TWAP Safe call ~0.003 ETH gas) show as diffs in the Warn log; -# production deploys leave this at 0.1 ETH or higher. -change_threshold = "1000000000000000" diff --git a/nexum/modules/examples/balance-tracker/src/lib.rs b/nexum/modules/examples/balance-tracker/src/lib.rs deleted file mode 100644 index ee3ae410..00000000 --- a/nexum/modules/examples/balance-tracker/src/lib.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! # balance-tracker (example Shepherd module) -//! -//! Subscribes to blocks, reads `eth_getBalance(addr)` for every -//! address in `[config].addresses` (comma-separated), persists the -//! last seen value under `balance:{addr}` in local-store, and emits -//! a Warn-level log line when the balance changes by more than -//! `[config].change_threshold` wei since the previous block. -//! -//! ## Module layout -//! -//! - `strategy.rs` holds the pure logic and tests against -//! the `nexum_sdk::host` trait seam. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) declares the handlers and defers the -//! per-cdylib glue to `#[nexum_sdk::module]`. -//! -//! ## Config -//! -//! ```toml -//! [config] -//! # Comma-separated list of 0x-prefixed 20-byte addresses. -//! addresses = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8,0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" -//! # Change threshold in wei; an alert fires when the delta exceeds it. -//! change_threshold = "100000000000000000" # 0.1 ETH -//! ``` - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -mod strategy; - -use std::sync::OnceLock; - -use nexum::host::types; - -static SETTINGS: OnceLock = OnceLock::new(); - -// `WitBindgenHost`, the fault `From` impls, and `install_tracing` (used -// by the handlers) are generated by the attribute alongside the -// wit-bindgen call and the `Guest`/`export!` glue. -struct BalanceTracker; - -#[nexum_sdk::module] -impl BalanceTracker { - fn init(config: Vec<(String, String)>) -> Result<(), Fault> { - install_tracing(); - let cfg = strategy::parse_config(&config)?; - tracing::info!( - "balance-tracker init: {} addresses, threshold={} wei", - cfg.addresses.len(), - cfg.change_threshold, - ); - let _ = SETTINGS.set(cfg); - Ok(()) - } - - fn on_block(block: types::Block) -> Result<(), Fault> { - let Some(cfg) = SETTINGS.get() else { - return Ok(()); - }; - strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(Into::into) - } -} diff --git a/nexum/modules/examples/balance-tracker/src/strategy.rs b/nexum/modules/examples/balance-tracker/src/strategy.rs deleted file mode 100644 index f1f5bf73..00000000 --- a/nexum/modules/examples/balance-tracker/src/strategy.rs +++ /dev/null @@ -1,391 +0,0 @@ -//! Pure strategy logic for the balance-tracker module. -//! -//! Every interaction with the world flows through the host trait -//! seam exposed by `nexum-sdk`, bounded to exactly the interfaces the -//! module declares ([`ChainHost`] + [`LocalStoreHost`]) - no direct -//! calls to wit-bindgen-generated free functions live here. The -//! `lib.rs` glue wraps a `WitBindgenHost` adapter around the module's -//! per-cdylib wit-bindgen imports and hands it to [`on_block`]; tests -//! under `#[cfg(test)]` hand the same function a -//! `nexum_sdk_test::MockHost`. -//! -//! Aligns balance-tracker with the M3 "host trait + adapter" recipe -//! the other four modules already follow (PR #55 review). Previously -//! `on_event` here dispatched against wit-bindgen free functions -//! directly, which made `check_one` / `fetch_balance` only reachable -//! from a real WASM build and excluded MockHost coverage. - -use nexum_sdk::address::parse_address_list; -use nexum_sdk::config::{self, ConfigError}; -use nexum_sdk::host::{ChainHost, Fault, LocalStoreHost}; -use nexum_sdk::prelude::{Address, U256}; - -/// Resolved settings parsed from `[config]` at `init` and read on -/// every event. -#[derive(Clone, Debug)] -pub struct Settings { - /// 0x-prefixed addresses to track. - pub addresses: Vec
, - /// Change threshold in wei; an alert fires when the delta exceeds - /// it. - pub change_threshold: U256, -} - -/// Entry point: poll every tracked address on a new block, log on -/// threshold-crossing diffs, persist the latest reading. -/// -/// Each address is independent; a single flaky `eth_getBalance` does -/// not abort the loop - the failure is logged and the next address is -/// still polled. -pub fn on_block( - host: &H, - chain_id: u64, - settings: &Settings, -) -> Result<(), Fault> { - for addr in &settings.addresses { - if let Err(err) = check_one(host, chain_id, *addr, settings.change_threshold) { - tracing::warn!("balance-tracker {addr:#x}: {err}"); - } - } - Ok(()) -} - -/// Poll one address: fetch latest balance, diff against the last -/// stored value, emit a log if the delta crosses `threshold`, then -/// persist the new value under `balance:{addr}`. -fn check_one( - host: &H, - chain_id: u64, - addr: Address, - threshold: U256, -) -> Result<(), Fault> { - let current = fetch_balance(host, chain_id, addr)?; - let key = balance_key(&addr); - let prior = host.get(&key)?.and_then(|b| parse_u256_le(&b)); - - if let Some(prior) = prior - && abs_diff(current, prior) >= threshold - { - let direction = if current > prior { "+" } else { "-" }; - tracing::warn!( - "balance-tracker {addr:#x} changed {direction}{} wei (prior={prior}, current={current})", - abs_diff(current, prior), - ); - } - // Always persist the latest reading so the next event's diff is - // accurate even when the change was below threshold (or when this - // is the first observation for the address). - host.set(&key, &u256_to_le_bytes(current))?; - Ok(()) -} - -/// `chain::request("eth_getBalance", [addr, "latest"])` -> `U256`. -fn fetch_balance(host: &H, chain_id: u64, addr: Address) -> Result { - let params = format!("[\"{addr:#x}\",\"latest\"]"); - let result_json = host.request(chain_id, "eth_getBalance", ¶ms)?; - parse_balance_hex(&result_json).ok_or_else(|| { - invalid_input(format!( - "eth_getBalance result not a hex string: {result_json}" - )) - }) -} - -// ---- pure helpers (unit-testable, no host) ------------------------ - -/// Parse the `"0x..."` JSON string `eth_getBalance` returns into a -/// `U256`. `None` on shape mismatch. -fn parse_balance_hex(result_json: &str) -> Option { - let trimmed = result_json.trim(); - let body = trimmed - .strip_prefix('"') - .and_then(|s| s.strip_suffix('"'))?; - let hex = body.strip_prefix("0x").unwrap_or(body); - // Empty hex (`"0x"`) is a legitimate zero balance. - if hex.is_empty() { - return Some(U256::ZERO); - } - U256::from_str_radix(hex, 16).ok() -} - -fn balance_key(addr: &Address) -> String { - format!("balance:{addr:#x}") -} - -fn abs_diff(a: U256, b: U256) -> U256 { - if a >= b { a - b } else { b - a } -} - -fn u256_to_le_bytes(v: U256) -> [u8; 32] { - v.to_le_bytes() -} - -fn parse_u256_le(bytes: &[u8]) -> Option { - if bytes.len() != 32 { - return None; - } - let mut buf = [0u8; 32]; - buf.copy_from_slice(bytes); - Some(U256::from_le_bytes(buf)) -} - -/// Parse `module.toml::[config]` into a typed [`Settings`]. -pub fn parse_config(entries: &[(String, String)]) -> Result { - let addresses_raw = config::get_required(entries, "addresses").map_err(config_err)?; - let change_threshold_raw = - config::get_required(entries, "change_threshold").map_err(config_err)?; - let addresses = parse_address_list(addresses_raw).map_err(|e| invalid_input(e.to_string()))?; - let change_threshold = change_threshold_raw - .parse::() - .map_err(|e| invalid_input(format!("change_threshold: {e}")))?; - Ok(Settings { - addresses, - change_threshold, - }) -} - -fn invalid_input(message: impl Into) -> Fault { - Fault::InvalidInput(message.into()) -} - -fn config_err(e: ConfigError) -> Fault { - invalid_input(e.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use nexum_sdk::Level; - use nexum_sdk::host::{ChainError, Fault}; - use nexum_sdk::prelude::address; - use nexum_sdk_test::{MockHost, capture_tracing}; - - const SEPOLIA: u64 = 11_155_111; - - // ---- pure helpers ---- - - #[test] - fn parse_balance_hex_decodes_canonical_response() { - // 0x16345785d8a0000 = 100_000_000_000_000_000 = 0.1 ETH. - assert_eq!( - parse_balance_hex("\"0x16345785d8a0000\""), - Some(U256::from(100_000_000_000_000_000_u128)), - ); - } - - #[test] - fn parse_balance_hex_handles_zero() { - assert_eq!(parse_balance_hex("\"0x0\""), Some(U256::ZERO)); - assert_eq!(parse_balance_hex("\"0x\""), Some(U256::ZERO)); - } - - #[test] - fn parse_balance_hex_rejects_unquoted() { - assert!(parse_balance_hex("0x1234").is_none()); - } - - #[test] - fn parse_balance_hex_rejects_garbage() { - assert!(parse_balance_hex("\"hello\"").is_none()); - } - - #[test] - fn u256_le_round_trip() { - let v = U256::from(42_u64); - let bytes = u256_to_le_bytes(v); - assert_eq!(parse_u256_le(&bytes), Some(v)); - } - - #[test] - fn parse_u256_le_rejects_wrong_length() { - assert!(parse_u256_le(&[0u8; 16]).is_none()); - assert!(parse_u256_le(&[0u8; 64]).is_none()); - } - - #[test] - fn abs_diff_is_symmetric() { - let a = U256::from(100_u64); - let b = U256::from(30_u64); - assert_eq!(abs_diff(a, b), U256::from(70_u64)); - assert_eq!(abs_diff(b, a), U256::from(70_u64)); - assert_eq!(abs_diff(a, a), U256::ZERO); - } - - #[test] - fn parse_config_happy_path() { - let entries = vec![ - ( - "addresses".into(), - "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".into(), - ), - ("change_threshold".into(), "100000000000000000".into()), - ]; - let s = parse_config(&entries).unwrap(); - assert_eq!(s.addresses.len(), 1); - assert_eq!(s.change_threshold, U256::from(100_000_000_000_000_000_u128)); - } - - #[test] - fn parse_config_rejects_missing_addresses() { - let err = parse_config(&[("change_threshold".into(), "1".into())]).unwrap_err(); - let Fault::InvalidInput(message) = err else { - panic!("expected invalid-input fault, got {err:?}"); - }; - assert!(message.contains("addresses")); - } - - #[test] - fn parse_config_rejects_missing_change_threshold() { - let err = parse_config(&[( - "addresses".into(), - "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".into(), - )]) - .unwrap_err(); - let Fault::InvalidInput(message) = err else { - panic!("expected invalid-input fault, got {err:?}"); - }; - assert!(message.contains("change_threshold")); - } - - // ---- MockHost-driven coverage of check_one / fetch_balance ---- - - fn one_addr_settings(threshold_wei: u128) -> Settings { - Settings { - addresses: vec![address!("70997970C51812dc3A010C7d01b50e0d17dc79C8")], - change_threshold: U256::from(threshold_wei), - } - } - - fn encode_balance_response(wei: u128) -> String { - format!("\"0x{:x}\"", wei) - } - - #[test] - fn first_seen_persists_without_alert() { - let host = MockHost::new(); - let settings = one_addr_settings(50); - let addr = settings.addresses[0]; - let params = format!("[\"{addr:#x}\",\"latest\"]"); - host.chain - .respond_to("eth_getBalance", ¶ms, Ok(encode_balance_response(100))); - - let (result, logs) = capture_tracing(|| on_block(&host, SEPOLIA, &settings)); - result.unwrap(); - - // First observation: no prior value in the store, so no - // comparison fires - the balance is just persisted silently. - assert_eq!(logs.count_at(Level::WARN), 0); - // Balance persisted for the next block's diff. - let stored = host - .store - .snapshot() - .get(&format!("balance:{addr:#x}")) - .cloned() - .expect("balance persisted"); - assert_eq!(parse_u256_le(&stored), Some(U256::from(100u64))); - } - - #[test] - fn balance_change_below_threshold_persists_without_log() { - let host = MockHost::new(); - let settings = one_addr_settings(1_000); - let addr = settings.addresses[0]; - // Pre-seed prior balance = 100. - host.store - .set( - &format!("balance:{addr:#x}"), - &u256_to_le_bytes(U256::from(100u64)), - ) - .unwrap(); - let params = format!("[\"{addr:#x}\",\"latest\"]"); - host.chain - .respond_to("eth_getBalance", ¶ms, Ok(encode_balance_response(150))); - - let (result, logs) = capture_tracing(|| on_block(&host, SEPOLIA, &settings)); - result.unwrap(); - - // Delta of 50 is under the 1_000 threshold; no Warn line for - // a "changed" event. - assert_eq!(logs.count_at(Level::WARN), 0); - // But the new value is persisted. - let stored = host - .store - .snapshot() - .get(&format!("balance:{addr:#x}")) - .cloned() - .unwrap(); - assert_eq!(parse_u256_le(&stored), Some(U256::from(150u64))); - } - - #[test] - fn fetch_balance_error_logs_warn_does_not_abort_loop() { - let host = MockHost::new(); - // Two addresses; the first errors out, the second succeeds. - let addr_a = address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"); - let addr_b = address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); - let settings = Settings { - addresses: vec![addr_a, addr_b], - change_threshold: U256::from(1u64), - }; - let params_a = format!("[\"{addr_a:#x}\",\"latest\"]"); - let params_b = format!("[\"{addr_b:#x}\",\"latest\"]"); - host.chain.respond_to( - "eth_getBalance", - ¶ms_a, - Err(ChainError::Fault(Fault::Unavailable("rpc down".into()))), - ); - host.chain - .respond_to("eth_getBalance", ¶ms_b, Ok(encode_balance_response(42))); - - let (result, captured) = capture_tracing(|| on_block(&host, SEPOLIA, &settings)); - result.unwrap(); - - // First address errored; Warn line emitted with addr_a. - let ev = captured.expect_one(|e| e.level == Level::WARN); - assert!(ev.message.contains(&format!("{addr_a:#x}"))); - assert!(ev.message.contains("rpc down")); - // Second address still ran; its balance persisted. - assert!( - host.store - .snapshot() - .contains_key(&format!("balance:{addr_b:#x}")) - ); - } - - #[test] - fn balance_change_at_or_above_threshold_warns() { - let host = MockHost::new(); - let settings = one_addr_settings(1_000); - let addr = settings.addresses[0]; - // Pre-seed prior balance = 100. - host.store - .set( - &format!("balance:{addr:#x}"), - &u256_to_le_bytes(U256::from(100u64)), - ) - .unwrap(); - let params = format!("[\"{addr:#x}\",\"latest\"]"); - host.chain.respond_to( - "eth_getBalance", - ¶ms, - Ok(encode_balance_response(1_150)), - ); - - let (result, logs) = capture_tracing(|| on_block(&host, SEPOLIA, &settings)); - result.unwrap(); - - // Delta of 1_050 meets the 1_000 threshold: exactly one Warn. - let ev = logs.expect_one(|e| e.level == Level::WARN); - assert!(ev.message.contains(&format!("{addr:#x}"))); - assert!(ev.message.contains("changed +1050 wei")); - assert!(ev.message.contains("prior=100")); - assert!(ev.message.contains("current=1150")); - // New reading persisted for the next block's diff. - let stored = host - .store - .snapshot() - .get(&format!("balance:{addr:#x}")) - .cloned() - .unwrap(); - assert_eq!(parse_u256_le(&stored), Some(U256::from(1_150u64))); - } -} diff --git a/nexum/modules/examples/http-probe/Cargo.toml b/nexum/modules/examples/http-probe/Cargo.toml deleted file mode 100644 index 86f66a59..00000000 --- a/nexum/modules/examples/http-probe/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "http-probe" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Shepherd example module: fetches an allowlisted URL over wasi:http on every block and verifies the off-list path is denied." - -[lib] -crate-type = ["cdylib"] - -[dependencies] -http.workspace = true -nexum-sdk = { path = "../../../crates/nexum-sdk" } -tracing = { version = "0.1", default-features = false } -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } - -[dev-dependencies] -nexum-sdk-test = { path = "../../../crates/nexum-sdk-test" } diff --git a/nexum/modules/examples/http-probe/module.toml b/nexum/modules/examples/http-probe/module.toml deleted file mode 100644 index bbd8726b..00000000 --- a/nexum/modules/examples/http-probe/module.toml +++ /dev/null @@ -1,38 +0,0 @@ -# http-probe example module: on every matching block it fetches an -# allowlisted URL over wasi:http and logs the status, then fetches an -# off-list URL and verifies the host denies it before any connection. -# Demonstrates `nexum_sdk::http::fetch` + `[capabilities.http].allow`. - -[module] -name = "http-probe" -version = "0.1.0" -# Placeholder content hash. 0.2 parses but does not verify this; 0.3 -# will compare against the sha256 of the loaded component bytes. -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -required = ["logging", "http"] -optional = [] - -[capabilities.http] -# Hosts this module may fetch from. Any other host is denied with the -# wasi:http HTTP-request-denied error code before a connection is made. -allow = ["api.cow.fi"] - -# --- subscriptions ---------------------------------------------------- - -# New blocks on Sepolia drive the probing cadence. -[[subscription]] -kind = "block" -chain_id = 11155111 - -# --- config ----------------------------------------------------------- - -[config] -# URL fetched on every matching block; its host must be allowlisted. -probe_url = "https://api.cow.fi/mainnet/api/v1/version" -# URL whose host is deliberately off-list. The module expects the -# denied error and treats any other outcome as a failure. -denied_url = "https://example.com/" -# Throttle: only probe every Nth block. Default 1. -every_n_blocks = "1" diff --git a/nexum/modules/examples/http-probe/src/lib.rs b/nexum/modules/examples/http-probe/src/lib.rs deleted file mode 100644 index 672f0001..00000000 --- a/nexum/modules/examples/http-probe/src/lib.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! # http-probe (example Shepherd module) -//! -//! On every matching block, fetches an allowlisted URL over wasi:http -//! and logs the response status, then fetches an off-list URL and -//! verifies the host denies it before any connection is made. -//! Demonstrates the guest-side HTTP patterns of a Shepherd module: -//! -//! - `nexum_sdk::http::fetch` (wasi:http via the SDK helper) -//! - the `[capabilities.http].allow` allowlist and its denial path -//! - `[config]` driven behaviour parsed once in `init` -//! -//! ## Module layout -//! -//! - `strategy.rs` holds the pure logic and tests against the SDK's -//! `http::Fetch` seam, logging through the `tracing` facade. It does -//! not know `wit-bindgen` exists. -//! - `lib.rs` (this file) declares the handlers and defers the -//! per-cdylib glue to `#[nexum_sdk::module]`. -//! -//! ## Settings -//! -//! ```toml -//! [config] -//! # URL fetched on every matching block; host must be allowlisted. -//! probe_url = "https://api.cow.fi/mainnet/api/v1/version" -//! # URL whose host is deliberately off-list; the module expects the -//! # denied error and treats any other outcome as a failure. -//! denied_url = "https://example.com/" -//! # Optional throttle: probe every N blocks. Default 1. -//! every_n_blocks = "1" -//! ``` - -// wit_bindgen::generate! expands to host-import shims whose arity matches -// the WIT signatures, which can exceed clippy's too-many-arguments threshold. -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -mod strategy; - -use std::sync::OnceLock; - -use nexum::host::types; - -static SETTINGS: OnceLock = OnceLock::new(); - -// The fault `From` impls and `install_tracing` (used by the handlers) are -// generated by the attribute alongside the wit-bindgen call and the -// `Guest`/`export!` glue. -struct HttpProbe; - -#[nexum_sdk::module] -impl HttpProbe { - fn init(config: Vec<(String, String)>) -> Result<(), Fault> { - install_tracing(); - let cfg = strategy::parse_config(&config)?; - tracing::info!( - "http-probe init: probe_url={} denied_url={} every_n_blocks={}", - cfg.probe_url, - cfg.denied_url, - cfg.every_n_blocks, - ); - let _ = SETTINGS.set(cfg); - Ok(()) - } - - fn on_block(block: types::Block) -> Result<(), Fault> { - let Some(cfg) = SETTINGS.get() else { - return Ok(()); - }; - strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number).map_err(Into::into) - } -} diff --git a/nexum/modules/examples/http-probe/src/strategy.rs b/nexum/modules/examples/http-probe/src/strategy.rs deleted file mode 100644 index 3d3d80b7..00000000 --- a/nexum/modules/examples/http-probe/src/strategy.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! Pure strategy logic for the http-probe module. -//! -//! All HTTP flows through the [`Fetch`] seam and logging goes through -//! the `tracing` facade, so the whole strategy is unit-testable -//! host-free: tests hand [`on_block`] a stub fetcher and capture the -//! `tracing` output; the `lib.rs` glue hands it `nexum_sdk::http::WasiFetch`. - -use nexum_sdk::config::{self, ConfigError}; -use nexum_sdk::host::Fault; -use nexum_sdk::http::{Fetch, FetchError}; - -/// Resolved settings parsed from `[config]` at `init` and read on -/// every event. -#[derive(Clone, Debug)] -pub struct Settings { - /// URL fetched on every matching block; its host must be on the - /// module's allowlist. - pub probe_url: String, - /// URL whose host is deliberately off-list; anything other than a - /// denial is a failure. - pub denied_url: String, - /// Only probe every Nth block. - pub every_n_blocks: u64, -} - -/// Entry point: probe the allowlisted URL, then verify the off-list -/// URL is denied. Returns `Err` when either leg misbehaves so the -/// runtime records a fault for the dispatch. -pub fn on_block( - fetcher: &F, - settings: &Settings, - block_number: u64, -) -> Result<(), Fault> { - if !block_number.is_multiple_of(settings.every_n_blocks) { - return Ok(()); - } - probe_allowlisted(fetcher, &settings.probe_url)?; - probe_denied(fetcher, &settings.denied_url) -} - -/// Fetch the allowlisted URL and log its status; any fetch error is -/// surfaced as a fault for this dispatch. -fn probe_allowlisted(fetcher: &F, url: &str) -> Result<(), Fault> { - let response = fetcher - .fetch(get_request(url)?) - .map_err(|e| fetch_err(url, &e))?; - tracing::info!( - "http-probe {url} -> {} ({} body bytes)", - response.status().as_u16(), - response.body().len(), - ); - Ok(()) -} - -/// Fetch the off-list URL and demand [`FetchError::Denied`]; a -/// response or any other error means the allowlist gate did not hold. -fn probe_denied(fetcher: &F, url: &str) -> Result<(), Fault> { - match fetcher.fetch(get_request(url)?) { - Err(FetchError::Denied) => { - tracing::info!("http-probe {url} denied by allowlist, as expected"); - Ok(()) - } - Ok(response) => Err(internal(format!( - "expected {url} to be denied by the allowlist, got status {}", - response.status().as_u16(), - ))), - Err(other) => Err(internal(format!( - "expected {url} to be denied by the allowlist, got: {other}", - ))), - } -} - -/// Build a body-less GET for `url`; a malformed URL is a config error -/// surfaced as an invalid-input fault. -fn get_request(url: &str) -> Result>, Fault> { - http::Request::get(url) - .body(Vec::new()) - .map_err(|e| invalid_input(format!("probe url {url}: {e}"))) -} - -/// Lift a [`FetchError`] into a [`Fault`], preserving the -/// policy/timeout/input/transport distinction in the case. -fn fetch_err(url: &str, error: &FetchError) -> Fault { - let detail = format!("fetch {url}: {error}"); - match error { - FetchError::Denied => Fault::Denied(detail), - FetchError::InvalidRequest(_) => Fault::InvalidInput(detail), - FetchError::Timeout(_) => Fault::Timeout, - // `FetchError` is `#[non_exhaustive]`: a future case folds to - // retryable `unavailable` with its detail. - _ => Fault::Unavailable(detail), - } -} - -fn internal(message: String) -> Fault { - Fault::Internal(message) -} - -/// Parse `module.toml::[config]` into a typed [`Settings`]. -pub fn parse_config(entries: &[(String, String)]) -> Result { - let probe_url = config::get_required(entries, "probe_url").map_err(config_err)?; - let denied_url = config::get_required(entries, "denied_url").map_err(config_err)?; - let every_n_blocks = match config::get_optional(entries, "every_n_blocks") { - Some(raw) => raw - .parse::() - .map_err(|e| invalid_input(format!("every_n_blocks: {e}")))?, - None => 1, - }; - if every_n_blocks == 0 { - return Err(invalid_input("every_n_blocks must be >= 1".to_owned())); - } - Ok(Settings { - probe_url: probe_url.to_owned(), - denied_url: denied_url.to_owned(), - every_n_blocks, - }) -} - -fn invalid_input(message: String) -> Fault { - Fault::InvalidInput(message) -} - -fn config_err(e: ConfigError) -> Fault { - invalid_input(e.to_string()) -} - -#[cfg(test)] -mod tests { - use std::cell::RefCell; - - use nexum_sdk::Level; - use nexum_sdk::host::Fault; - use nexum_sdk::http::FetchOptions; - use nexum_sdk_test::capture_tracing; - - use super::*; - - /// Stub fetcher: replays canned outcomes in call order and records - /// the requested URLs. - struct StubFetch { - outcomes: RefCell>, FetchError>>>, - urls: RefCell>, - } - - impl StubFetch { - fn new(outcomes: Vec>, FetchError>>) -> Self { - Self { - outcomes: RefCell::new(outcomes), - urls: RefCell::new(Vec::new()), - } - } - } - - impl Fetch for StubFetch { - fn fetch_with( - &self, - request: http::Request>, - _options: FetchOptions, - ) -> Result>, FetchError> { - self.urls.borrow_mut().push(request.uri().to_string()); - self.outcomes.borrow_mut().remove(0) - } - } - - fn ok_response(status: u16, body: &[u8]) -> Result>, FetchError> { - Ok(http::Response::builder() - .status(status) - .body(body.to_vec()) - .unwrap()) - } - - fn settings() -> Settings { - Settings { - probe_url: "https://api.cow.fi/mainnet/api/v1/version".into(), - denied_url: "https://example.com/".into(), - every_n_blocks: 1, - } - } - - #[test] - fn happy_path_logs_status_and_denial() { - let fetcher = StubFetch::new(vec![ - ok_response(200, b"\"1.2.3\""), - Err(FetchError::Denied), - ]); - - let (result, logs) = capture_tracing(|| on_block(&fetcher, &settings(), 42)); - result.unwrap(); - - assert_eq!( - *fetcher.urls.borrow(), - vec![settings().probe_url, settings().denied_url], - ); - assert_eq!(logs.count_at(Level::INFO), 2); - assert_eq!(logs.count_at(Level::WARN), 0); - let events = logs.events(); - assert_eq!( - events[0].message, - format!("http-probe {} -> 200 (7 body bytes)", settings().probe_url), - ); - assert_eq!( - events[1].message, - format!( - "http-probe {} denied by allowlist, as expected", - settings().denied_url - ), - ); - } - - #[test] - fn probe_transport_failure_is_unavailable_fault() { - let fetcher = StubFetch::new(vec![Err(FetchError::Transport( - "connection refused".into(), - ))]); - - let err = on_block(&fetcher, &settings(), 1).unwrap_err(); - let Fault::Unavailable(message) = err else { - panic!("expected unavailable fault, got {err:?}"); - }; - assert!(message.contains("connection refused")); - } - - #[test] - fn denied_url_answering_is_internal_error() { - let fetcher = StubFetch::new(vec![ok_response(200, b"ok"), ok_response(200, b"leak")]); - - let err = on_block(&fetcher, &settings(), 1).unwrap_err(); - let Fault::Internal(message) = err else { - panic!("expected internal fault, got {err:?}"); - }; - assert!(message.contains("expected")); - } - - #[test] - fn denied_url_failing_differently_is_internal_error() { - let fetcher = StubFetch::new(vec![ - ok_response(200, b"ok"), - Err(FetchError::Timeout("connection timeout".into())), - ]); - - let err = on_block(&fetcher, &settings(), 1).unwrap_err(); - let Fault::Internal(message) = err else { - panic!("expected internal fault, got {err:?}"); - }; - assert!(message.contains("connection timeout")); - } - - #[test] - fn throttle_skips_non_multiple_blocks() { - let fetcher = StubFetch::new(vec![]); - let cfg = Settings { - every_n_blocks: 5, - ..settings() - }; - - let (result, logs) = capture_tracing(|| on_block(&fetcher, &cfg, 7)); - result.unwrap(); - - assert!(fetcher.urls.borrow().is_empty()); - assert!(logs.is_empty()); - } - - #[test] - fn parse_config_happy_path() { - let entries = vec![ - ("probe_url".to_owned(), "https://api.cow.fi/x".to_owned()), - ("denied_url".to_owned(), "https://example.com/".to_owned()), - ("every_n_blocks".to_owned(), "3".to_owned()), - ]; - let cfg = parse_config(&entries).unwrap(); - assert_eq!(cfg.probe_url, "https://api.cow.fi/x"); - assert_eq!(cfg.denied_url, "https://example.com/"); - assert_eq!(cfg.every_n_blocks, 3); - } - - #[test] - fn parse_config_defaults_every_n_blocks() { - let entries = vec![ - ("probe_url".to_owned(), "https://a/".to_owned()), - ("denied_url".to_owned(), "https://b/".to_owned()), - ]; - assert_eq!(parse_config(&entries).unwrap().every_n_blocks, 1); - } - - #[test] - fn parse_config_rejects_missing_urls_and_zero_throttle() { - let missing = - parse_config(&[("probe_url".to_owned(), "https://a/".to_owned())]).unwrap_err(); - let Fault::InvalidInput(message) = missing else { - panic!("expected invalid-input fault, got {missing:?}"); - }; - assert!(message.contains("denied_url")); - - let zero = parse_config(&[ - ("probe_url".to_owned(), "https://a/".to_owned()), - ("denied_url".to_owned(), "https://b/".to_owned()), - ("every_n_blocks".to_owned(), "0".to_owned()), - ]) - .unwrap_err(); - let Fault::InvalidInput(message) = zero else { - panic!("expected invalid-input fault, got {zero:?}"); - }; - assert!(message.contains("every_n_blocks")); - } -} diff --git a/nexum/modules/examples/price-alert/Cargo.toml b/nexum/modules/examples/price-alert/Cargo.toml deleted file mode 100644 index 30bb5280..00000000 --- a/nexum/modules/examples/price-alert/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "price-alert" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Shepherd example module: polls a Chainlink price oracle every block and emits a Warn log when the price crosses a config-supplied threshold." - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nexum-sdk = { path = "../../../crates/nexum-sdk" } -alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } -tracing = { version = "0.1", default-features = false } -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } - -[dev-dependencies] -nexum-sdk-test = { path = "../../../crates/nexum-sdk-test" } -# Only used by tests in `strategy.rs` to encode a synthetic oracle -# return body; the production code uses `nexum_sdk::chain::chainlink`. -alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } diff --git a/nexum/modules/examples/price-alert/module.toml b/nexum/modules/examples/price-alert/module.toml deleted file mode 100644 index e5f95fc2..00000000 --- a/nexum/modules/examples/price-alert/module.toml +++ /dev/null @@ -1,43 +0,0 @@ -# price-alert example module: polls a Chainlink price oracle on every -# block and emits a Warn log when the price crosses a config-supplied -# threshold. Demonstrates `chain::request` + ABI decode via -# `alloy_sol_types` + config-driven module behaviour. - -[module] -name = "price-alert" -version = "0.1.0" -# Placeholder content hash. 0.2 parses but does not verify this; 0.3 -# will compare against the sha256 of the loaded component bytes. -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -required = ["logging", "chain"] -optional = [] - -[capabilities.http] -# All chain traffic flows through the `chain` capability (host's -# pinned alloy provider). No direct `http` calls. -allow = [] - -# --- subscriptions ---------------------------------------------------- - -# New blocks on Sepolia drive the polling cadence. -[[subscription]] -kind = "block" -chain_id = 11155111 - -# --- config ----------------------------------------------------------- - -[config] -# Chainlink AggregatorV3Interface address. Default points at the -# canonical ETH/USD feed on Sepolia. -oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" -# Decimals the oracle reports (Chainlink USD pairs are 8). -decimals = "8" -# Threshold in the oracle's native decimal units. -threshold = "2500.00" -# "above" -> fires when answer >= threshold -# "below" -> fires when answer <= threshold -direction = "below" -# Throttle: only poll every Nth block. Default 1. -every_n_blocks = "1" diff --git a/nexum/modules/examples/price-alert/src/lib.rs b/nexum/modules/examples/price-alert/src/lib.rs deleted file mode 100644 index f8e3a825..00000000 --- a/nexum/modules/examples/price-alert/src/lib.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! # price-alert (example Shepherd module) -//! -//! Polls a Chainlink price oracle on every new block and emits a -//! Warn-level log when the price crosses a config-supplied -//! threshold. Demonstrates the three load-bearing patterns of a -//! Shepherd module: -//! -//! - `chain::request` + ABI decode via `alloy_sol_types` -//! - `nexum_sdk` helpers (`prelude`, `chain::eth_call_params`, -//! `chain::parse_eth_call_result`) -//! - `[config]` driven behaviour parsed once in `init` and read on -//! every subsequent event -//! -//! ## Module layout -//! -//! - `strategy.rs` holds the pure logic and tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) declares the handlers and defers the -//! per-cdylib glue to `#[nexum_sdk::module]`. -//! -//! ## Settings -//! -//! ```toml -//! [config] -//! # Chainlink AggregatorV3Interface address. -//! oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" # ETH/USD on Sepolia -//! # Oracle's decimals (Chainlink USD pairs are 8; ETH pairs 18). -//! decimals = "8" -//! # Threshold in the oracle's native units (decimal string). The -//! # module multiplies by 10**decimals at init. -//! threshold = "2500.00" -//! # Either "above" or "below". Fires when the answer crosses on -//! # the configured side. -//! direction = "below" -//! # Optional throttle: poll every N blocks. Default 1. -//! every_n_blocks = "1" -//! ``` - -// wit_bindgen::generate! expands to host-import shims whose arity matches -// the WIT signatures, which can exceed clippy's too-many-arguments threshold. -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -mod strategy; - -use std::sync::OnceLock; - -use nexum::host::types; - -static SETTINGS: OnceLock = OnceLock::new(); - -// `WitBindgenHost`, the fault `From` impls, and `install_tracing` (used -// by the handlers) are generated by the attribute alongside the -// wit-bindgen call and the `Guest`/`export!` glue. -struct PriceAlert; - -#[nexum_sdk::module] -impl PriceAlert { - fn init(config: Vec<(String, String)>) -> Result<(), Fault> { - install_tracing(); - let cfg = strategy::parse_config(&config)?; - tracing::info!( - "price-alert init: oracle={:#x} threshold={} direction={:?} every_n_blocks={}", - cfg.oracle_address, - cfg.threshold_scaled, - cfg.direction, - cfg.every_n_blocks, - ); - let _ = SETTINGS.set(cfg); - Ok(()) - } - - fn on_block(block: types::Block) -> Result<(), Fault> { - let Some(cfg) = SETTINGS.get() else { - return Ok(()); - }; - strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number).map_err(Into::into) - } -} diff --git a/nexum/modules/examples/price-alert/src/strategy.rs b/nexum/modules/examples/price-alert/src/strategy.rs deleted file mode 100644 index a6c773e8..00000000 --- a/nexum/modules/examples/price-alert/src/strategy.rs +++ /dev/null @@ -1,405 +0,0 @@ -//! Pure strategy logic for the price-alert module. -//! -//! Every interaction with the world flows through the host trait -//! seam exposed by `nexum-sdk` - no direct calls to wit-bindgen- -//! generated free functions live here. The `lib.rs` glue wraps a -//! `WitBindgenHost` adapter around the module's per-cdylib wit-bindgen -//! imports and hands it to [`on_block`]; tests under `#[cfg(test)]` -//! hand the same function a `nexum_sdk_test::MockHost`. The bound is -//! `ChainHost + LoggingHost`, the module's two declared capabilities: -//! its world imports nothing else, so the full `Host` supertrait (which -//! adds local-store) is unimplementable here by design. - -use alloy_primitives::I256; -use nexum_sdk::chain::chainlink::read_latest_answer; -use nexum_sdk::config::{self, ConfigError}; -use nexum_sdk::host::{ChainHost, Fault, LoggingHost}; -use nexum_sdk::prelude::Address; - -/// Resolved configuration, parsed from `module.toml::[config]` at -/// `init` and read on every `on_event`. -#[derive(Debug)] -pub struct Settings { - /// Chainlink AggregatorV3Interface address. - pub oracle_address: Address, - /// Threshold scaled to the oracle's native units - /// (`threshold_decimal * 10**decimals`). - pub threshold_scaled: I256, - /// Which side of the threshold fires. - pub direction: Direction, - /// Throttle: only poll every Nth block. - pub every_n_blocks: u64, -} - -/// Which side of the threshold the alert fires on. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Direction { - /// Fire when `answer >= threshold`. - Above, - /// Fire when `answer <= threshold`. - Below, -} - -/// React to a new block. -/// -/// Returns `Ok(())` on success and on recoverable upstream failures -/// (oracle RPC error, decode failure) - the strategy logs a Warn and -/// lets the next block re-poll rather than propagating into the -/// supervisor. Only host-level I/O on the persistence side would -/// bubble up via `?`, and this module does not touch the store. -pub fn on_block( - host: &H, - chain_id: u64, - settings: &Settings, - block_number: u64, -) -> Result<(), Fault> { - if !block_number.is_multiple_of(settings.every_n_blocks) { - return Ok(()); - } - let Some(answer) = read_latest_answer(host, chain_id, settings.oracle_address, "price-alert") - else { - // read_latest_answer already logged the failure at Warn. - return Ok(()); - }; - if classify(answer, settings.threshold_scaled, settings.direction) { - tracing::warn!( - answer = %answer, - threshold = %settings.threshold_scaled, - direction = ?settings.direction, - "price-alert: TRIGGERED", - ); - } else { - tracing::info!( - answer = %answer, - threshold = %settings.threshold_scaled, - direction = ?settings.direction, - "price-alert: ok", - ); - } - Ok(()) -} - -/// `true` when `answer` is on the firing side of `threshold` per -/// `direction`. Pure - exercised by the unit tests. -pub fn classify(answer: I256, threshold: I256, direction: Direction) -> bool { - match direction { - Direction::Above => answer >= threshold, - Direction::Below => answer <= threshold, - } -} - -/// Parse `module.toml::[config]` into a typed [`Settings`]. -/// -/// One-shot config-parser style: returns `Result` so the -/// `Guest::init` adapter can lower the failure into the wit-bindgen -/// `fault` with no extra plumbing. -pub fn parse_config(entries: &[(String, String)]) -> Result { - let oracle_address = config::get_required(entries, "oracle_address") - .map_err(config_err)? - .parse::
() - .map_err(|e| invalid(format!("oracle_address: {e}")))?; - let decimals = config::get_required(entries, "decimals") - .map_err(config_err)? - .parse::() - .map_err(|e| invalid(format!("decimals: {e}")))?; - if decimals > 38 { - return Err(invalid(format!( - "decimals={decimals} exceeds the I256 power-of-ten budget" - ))); - } - let threshold_decimal = config::get_required(entries, "threshold").map_err(config_err)?; - let threshold_scaled = - config::scale_decimal(threshold_decimal, decimals, "threshold").map_err(config_err)?; - let direction = match config::get_required(entries, "direction") - .map_err(config_err)? - .to_ascii_lowercase() - .as_str() - { - "above" => Direction::Above, - "below" => Direction::Below, - other => { - return Err(invalid(format!( - "direction: expected 'above'|'below', got {other:?}" - ))); - } - }; - let every_n_blocks = config::get_optional(entries, "every_n_blocks") - .map(|s| { - s.parse::() - .map_err(|e| invalid(format!("every_n_blocks: {e}"))) - }) - .transpose()? - .unwrap_or(1) - .max(1); - Ok(Settings { - oracle_address, - threshold_scaled, - direction, - every_n_blocks, - }) -} - -/// Lift a free-text invalid-config detail into a [`Fault::InvalidInput`]. -/// Used when the SDK helper does not own the error (e.g. an -/// `Address::from_str` failure). -fn invalid(message: impl Into) -> Fault { - Fault::InvalidInput(message.into()) -} - -/// Project a `nexum_sdk::config::ConfigError` into a -/// [`Fault::InvalidInput`] via `Display`, preserving the detail at the -/// WIT boundary. -fn config_err(e: ConfigError) -> Fault { - invalid(e.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_primitives::{U256, hex}; - use alloy_sol_types::SolCall; - use nexum_sdk::Level; - use nexum_sdk::chain::chainlink::AggregatorV3; - use nexum_sdk::chain::eth_call_params; - use nexum_sdk::host::{ChainError, Fault}; - use nexum_sdk_test::{MockHost, capture_tracing}; - - fn sample_settings(trigger_scaled_dec: i128, direction: Direction) -> Settings { - Settings { - oracle_address: "0x694AA1769357215DE4FAC081bf1f309aDC325306" - .parse() - .unwrap(), - threshold_scaled: I256::try_from(trigger_scaled_dec).unwrap(), - direction, - every_n_blocks: 1, - } - } - - /// Encode a `latestRoundData` return into the `"0x..."` JSON string - /// the host's `chain::request` would yield. - fn oracle_response_json(answer_scaled: i128) -> String { - use alloy_primitives::aliases::U80; - let returns = AggregatorV3::latestRoundDataReturn { - roundId: U80::ZERO, - answer: I256::try_from(answer_scaled).unwrap(), - startedAt: U256::ZERO, - updatedAt: U256::ZERO, - answeredInRound: U80::ZERO, - }; - let encoded = AggregatorV3::latestRoundDataCall::abi_encode_returns(&returns); - let hex = hex::encode_prefixed(encoded); - format!("\"{hex}\"") - } - - fn programmed_eth_call(host: &MockHost, oracle: Address, response: Result) { - let call_data = AggregatorV3::latestRoundDataCall {}.abi_encode(); - let params = eth_call_params(&oracle, &call_data); - host.chain.respond_to("eth_call", ¶ms, response); - } - - // ---- pure helpers ---- - - #[test] - fn classify_below_fires_at_or_under_threshold() { - let t = I256::try_from(100_i32).unwrap(); - assert!(classify( - I256::try_from(99_i32).unwrap(), - t, - Direction::Below - )); - assert!(classify( - I256::try_from(100_i32).unwrap(), - t, - Direction::Below - )); - assert!(!classify( - I256::try_from(101_i32).unwrap(), - t, - Direction::Below - )); - } - - #[test] - fn classify_above_fires_at_or_over_threshold() { - let t = I256::try_from(100_i32).unwrap(); - assert!(classify( - I256::try_from(101_i32).unwrap(), - t, - Direction::Above - )); - assert!(classify( - I256::try_from(100_i32).unwrap(), - t, - Direction::Above - )); - assert!(!classify( - I256::try_from(99_i32).unwrap(), - t, - Direction::Above - )); - } - - // Decimal-parsing tests for the shared scaler live in - // `nexum-sdk::config::tests` now (lifted out of this module per - // PR #55 review). The integration-level parse_config tests below - // still exercise the wiring end-to-end with the SDK helper. - - #[test] - fn parse_config_happy_path() { - let entries = vec![ - ( - "oracle_address".into(), - "0x694AA1769357215DE4FAC081bf1f309aDC325306".into(), - ), - ("decimals".into(), "8".into()), - ("threshold".into(), "2500.50".into()), - ("direction".into(), "below".into()), - ("every_n_blocks".into(), "5".into()), - ]; - let cfg = parse_config(&entries).unwrap(); - assert_eq!(cfg.direction, Direction::Below); - assert_eq!(cfg.every_n_blocks, 5); - assert_eq!( - cfg.threshold_scaled, - I256::try_from(250_050_000_000_i64).unwrap() - ); - } - - #[test] - fn parse_config_defaults_every_n_blocks_to_one() { - let entries = vec![ - ( - "oracle_address".into(), - "0x694AA1769357215DE4FAC081bf1f309aDC325306".into(), - ), - ("decimals".into(), "8".into()), - ("threshold".into(), "1".into()), - ("direction".into(), "above".into()), - ]; - let cfg = parse_config(&entries).unwrap(); - assert_eq!(cfg.every_n_blocks, 1); - assert_eq!(cfg.direction, Direction::Above); - } - - #[test] - fn parse_config_rejects_missing_key() { - let entries = vec![ - ("decimals".into(), "8".into()), - ("threshold".into(), "1".into()), - ("direction".into(), "above".into()), - ]; - let err = parse_config(&entries).unwrap_err(); - let Fault::InvalidInput(message) = err else { - panic!("expected invalid-input fault, got {err:?}"); - }; - assert!(message.contains("oracle_address")); - } - - // ---- strategy behaviour against MockHost ---- - - #[test] - fn on_block_idle_when_price_above_below_trigger() { - let host = MockHost::new(); - let settings = sample_settings(/*trigger*/ 250_050_000_000, Direction::Below); - programmed_eth_call( - &host, - settings.oracle_address, - Ok(oracle_response_json(300_000_000_000)), - ); - - let (result, logs) = capture_tracing(|| on_block(&host, 11_155_111, &settings, 100)); - result.unwrap(); - - assert_eq!(host.chain.call_count(), 1); - assert_eq!(logs.count_at(Level::WARN), 0); - let ev = logs.expect_one(|e| e.level == Level::INFO && e.message == "price-alert: ok"); - assert!(ev.field("answer").is_some()); - assert_eq!(ev.field_str("threshold").as_deref(), Some("250050000000")); - } - - #[test] - fn on_block_triggers_below_threshold() { - let host = MockHost::new(); - let settings = sample_settings(250_050_000_000, Direction::Below); - programmed_eth_call( - &host, - settings.oracle_address, - Ok(oracle_response_json(200_000_000_000)), - ); - - let (result, logs) = capture_tracing(|| on_block(&host, 11_155_111, &settings, 100)); - result.unwrap(); - - // `expect_one` on the WARN level pins the single-alert count. - let ev = logs.expect_one(|e| e.level == Level::WARN); - assert_eq!(ev.message, "price-alert: TRIGGERED"); - assert_eq!(ev.field_str("direction").as_deref(), Some("Below")); - assert_eq!(ev.field_str("answer").as_deref(), Some("200000000000")); - } - - #[test] - fn on_block_triggers_above_threshold() { - let host = MockHost::new(); - let settings = sample_settings(100, Direction::Above); - programmed_eth_call( - &host, - settings.oracle_address, - Ok(oracle_response_json(200)), - ); - - let (result, logs) = capture_tracing(|| on_block(&host, 11_155_111, &settings, 100)); - result.unwrap(); - - let ev = logs.expect_one(|e| e.level == Level::WARN); - assert_eq!(ev.message, "price-alert: TRIGGERED"); - assert_eq!(ev.field_str("direction").as_deref(), Some("Above")); - } - - #[test] - fn on_block_warns_and_continues_on_rpc_error() { - let host = MockHost::new(); - let settings = sample_settings(100, Direction::Below); - programmed_eth_call( - &host, - settings.oracle_address, - Err(ChainError::Fault(Fault::Timeout)), - ); - - // Strategy returns Ok so the supervisor moves on. - let (result, logs) = capture_tracing(|| on_block(&host, 11_155_111, &settings, 100)); - result.unwrap(); - // The oracle-read failure is logged by the SDK chainlink helper - // through the host logging call, so it lands on `host.logging`. - assert!(host.logging.contains("eth_call failed")); - // No facade event at all: the strategy returns before emitting - // either the ok or TRIGGERED line. - assert!(logs.is_empty()); - } - - #[test] - fn on_block_warns_on_undecodable_result() { - let host = MockHost::new(); - let settings = sample_settings(100, Direction::Below); - programmed_eth_call(&host, settings.oracle_address, Ok("not-json".into())); - - on_block(&host, 11_155_111, &settings, 100).unwrap(); - assert!(host.logging.contains("cannot decode result hex")); - } - - #[test] - fn on_block_respects_every_n_blocks_throttle() { - let host = MockHost::new(); - let mut settings = sample_settings(100, Direction::Below); - settings.every_n_blocks = 5; - programmed_eth_call(&host, settings.oracle_address, Ok(oracle_response_json(50))); - - // Blocks 1..5 do not poll; only block 5 (which divides evenly). - for n in 1..5 { - on_block(&host, 11_155_111, &settings, n).unwrap(); - } - assert_eq!(host.chain.call_count(), 0); - - on_block(&host, 11_155_111, &settings, 5).unwrap(); - assert_eq!(host.chain.call_count(), 1); - } -} diff --git a/nexum/modules/fixtures/clock-reader/Cargo.toml b/nexum/modules/fixtures/clock-reader/Cargo.toml deleted file mode 100644 index 6359fcc6..00000000 --- a/nexum/modules/fixtures/clock-reader/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "clock-reader" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Test fixture: on every event reads the WASI wall clock through std and logs it. Lets a test assert the guest observes a WasiClockOverride end to end." - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/nexum/modules/fixtures/clock-reader/module.toml b/nexum/modules/fixtures/clock-reader/module.toml deleted file mode 100644 index 23f9fa3f..00000000 --- a/nexum/modules/fixtures/clock-reader/module.toml +++ /dev/null @@ -1,21 +0,0 @@ -# clock-reader test fixture. Subscribes to a single chain's blocks so the -# supervisor invokes `on_event` once per block; the handler reads the WASI -# wall clock through std and logs it. The integration test boots this -# fixture under a pinned clock override and asserts the logged time matches -# the override, proving the guest observes virtualized time end to end. - -[module] -name = "clock-reader" -version = "0.1.0" -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -required = ["logging"] -optional = [] - -[capabilities.http] -allow = [] - -[[subscription]] -kind = "block" -chain_id = 1 diff --git a/nexum/modules/fixtures/clock-reader/src/lib.rs b/nexum/modules/fixtures/clock-reader/src/lib.rs deleted file mode 100644 index 2f0cea8b..00000000 --- a/nexum/modules/fixtures/clock-reader/src/lib.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! # clock-reader (test fixture) -//! -//! On every event reads `std::time::SystemTime::now()` and logs the wall -//! time as whole seconds since the Unix epoch. Under `wasm32-wasip2` that -//! read routes to `wasi:clocks/wall-clock`, which the supervisor -//! virtualizes per store, so a test that boots this fixture under a pinned -//! clock override can assert from the log line that the guest observed the -//! overridden time rather than the ambient host clock. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only and never gets loaded by the testnet configs. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -use std::time::{SystemTime, UNIX_EPOCH}; - -wit_bindgen::generate!({ - path: [ - "../../../wit/nexum-host", - ], - world: "nexum:host/event-module", - generate_all, -}); - -use nexum::host::{logging, types}; - -struct ClockReader; - -impl Guest for ClockReader { - fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { - // Minimal SDK-free fixture: no tracing subscriber is installed, - // so log through the raw host binding directly. - logging::log(logging::Level::Info, "clock-reader init"); - Ok(()) - } - - fn on_event(_event: types::Event) -> Result<(), Fault> { - // Whole seconds since the epoch is parseable and stable: the - // override pins wall time to an exact instant, so the guest reads - // that instant back rather than the ambient host clock. - let secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - logging::log(logging::Level::Info, &format!("clock wall {secs}")); - Ok(()) - } -} - -export!(ClockReader); diff --git a/nexum/modules/fixtures/flaky-bomb/Cargo.toml b/nexum/modules/fixtures/flaky-bomb/Cargo.toml deleted file mode 100644 index f040e846..00000000 --- a/nexum/modules/fixtures/flaky-bomb/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "flaky-bomb" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Evil-by-design fixture: traps on the first N events (via unreachable!) and succeeds afterwards. The supervisor must exercise its exponential-backoff restart policy + reset the failure counter when the module recovers." - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/nexum/modules/fixtures/flaky-bomb/module.toml b/nexum/modules/fixtures/flaky-bomb/module.toml deleted file mode 100644 index 142af936..00000000 --- a/nexum/modules/fixtures/flaky-bomb/module.toml +++ /dev/null @@ -1,26 +0,0 @@ -# flaky-bomb test fixture. Subscribes to blocks; `on_event` -# traps via `unreachable!()` on the first N attempts, then recovers. -# Drives the supervisor's exponential-backoff restart policy through -# its full lifecycle. - -[module] -name = "flaky-bomb" -version = "0.1.0" -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -required = ["logging", "local-store"] -optional = [] - -[capabilities.http] -allow = [] - -[[subscription]] -kind = "block" -chain_id = 1 - -[config] -# Number of consecutive events to trap on before recovering. Tests -# typically synthesise a manifest with `fail_first_n = "1"` to keep -# the test wall-clock short (only one 1 s backoff window to wait). -fail_first_n = "1" diff --git a/nexum/modules/fixtures/flaky-bomb/src/lib.rs b/nexum/modules/fixtures/flaky-bomb/src/lib.rs deleted file mode 100644 index d1b9598a..00000000 --- a/nexum/modules/fixtures/flaky-bomb/src/lib.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! # flaky-bomb (test fixture) -//! -//! Traps deterministically on the first N events and succeeds on -//! every subsequent event. Drives the supervisor's exponential- -//! backoff restart policy through its full lifecycle: -//! -//! 1. Dispatch 1: trap (failure_count = 1, next_attempt = +1s). -//! 2. (engine waits the backoff window) -//! 3. Dispatch 2 (eligible after 1s): trap again, failure_count = 2. -//! 4. ... -//! 5. Dispatch N+1: succeeds, failure_count resets to 0. -//! -//! N is config-supplied via `[config].fail_first_n`. The fixture -//! reads the value once during `init` into a `OnceLock` and keeps -//! a static `AtomicU32` counter across calls. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -wit_bindgen::generate!({ - path: [ - "../../../wit/nexum-host", - ], - world: "nexum:host/event-module", - generate_all, -}); - -use std::sync::OnceLock; - -use nexum::host::{local_store, logging, types}; - -/// Number of consecutive events to trap on. Set from `[config].fail_first_n` -/// at init; defaults to `1` (trap once, recover on second event). -static FAIL_FIRST_N: OnceLock = OnceLock::new(); - -const ATTEMPTS_KEY: &str = "attempts"; - -struct FlakyBomb; - -impl Guest for FlakyBomb { - fn init(config: Vec<(String, String)>) -> Result<(), Fault> { - let n: u32 = config - .iter() - .find(|(k, _)| k == "fail_first_n") - .and_then(|(_, v)| v.parse().ok()) - .unwrap_or(1); - FAIL_FIRST_N.set(n).ok(); - // Minimal SDK-free fixture: no tracing subscriber is installed, - // so log through the raw host binding directly. - logging::log( - logging::Level::Info, - &format!("flaky-bomb init: will trap on the first {n} event(s)"), - ); - Ok(()) - } - - fn on_event(_event: types::Event) -> Result<(), Fault> { - // Read + increment the attempt counter from local-store. - // Survives wasm-side state resets (the supervisor's restart - // path tears down the Store; local-store is host-side and - // persistent within the supervisor's lifetime, exactly the - // store keeps across reinstantiations). A store fault (never - // expected here) folds straight into the export `fault`. - let prior = local_store::get(ATTEMPTS_KEY)? - .and_then(|b| <[u8; 4]>::try_from(b.as_slice()).ok()) - .map(u32::from_le_bytes) - .unwrap_or(0); - let attempt = prior + 1; - local_store::set(ATTEMPTS_KEY, &attempt.to_le_bytes())?; - - let n = FAIL_FIRST_N.get().copied().unwrap_or(1); - if attempt <= n { - logging::log( - logging::Level::Warn, - &format!("flaky-bomb attempt {attempt}/{n}: burning fuel to trigger OutOfFuel"), - ); - // Burn fuel until wasmtime traps with `OutOfFuel`. The - // supervisor catches the trap + schedules a backoff - // restart. After the backoff window the supervisor - // re-instantiates the component (fresh wasm Store), but - // local-store survives so the attempt counter keeps - // climbing across restarts. - let mut x: u64 = 0; - loop { - x = x.wrapping_add(1); - std::hint::black_box(x); - } - } - logging::log( - logging::Level::Info, - &format!("flaky-bomb attempt {attempt}: ok, recovered"), - ); - Ok(()) - } -} - -export!(FlakyBomb); diff --git a/nexum/modules/fixtures/fuel-bomb/Cargo.toml b/nexum/modules/fixtures/fuel-bomb/Cargo.toml deleted file mode 100644 index 9692cc42..00000000 --- a/nexum/modules/fixtures/fuel-bomb/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "fuel-bomb" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Evil-by-design fixture: on every event runs an unbounded loop to exhaust the wasmtime fuel budget. Engine must trap with OutOfFuel + mark the module dead." - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/nexum/modules/fixtures/fuel-bomb/module.toml b/nexum/modules/fixtures/fuel-bomb/module.toml deleted file mode 100644 index 054f6462..00000000 --- a/nexum/modules/fixtures/fuel-bomb/module.toml +++ /dev/null @@ -1,21 +0,0 @@ -# fuel-bomb test fixture. Subscribes to a single chain's -# blocks so the supervisor invokes `on_event` once; the unbounded -# loop in `on_event` then exhausts the wasmtime fuel budget and the -# host traps `OutOfFuel`. The integration test asserts the trap is -# caught + the module is marked dead. - -[module] -name = "fuel-bomb" -version = "0.1.0" -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -required = ["logging"] -optional = [] - -[capabilities.http] -allow = [] - -[[subscription]] -kind = "block" -chain_id = 1 diff --git a/nexum/modules/fixtures/fuel-bomb/src/lib.rs b/nexum/modules/fixtures/fuel-bomb/src/lib.rs deleted file mode 100644 index 3e6b6b3b..00000000 --- a/nexum/modules/fixtures/fuel-bomb/src/lib.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! # fuel-bomb (test fixture) -//! -//! Deliberately exhausts the wasmtime fuel budget on every `on_event` -//! by running an unbounded counter loop. The wasmtime engine must -//! trap with `OutOfFuel`; the supervisor must catch the trap, mark -//! the module dead, and continue dispatching to other modules. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only and never gets loaded by the M2 / M3 testnet -//! configs. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -wit_bindgen::generate!({ - path: [ - "../../../wit/nexum-host", - ], - world: "nexum:host/event-module", - generate_all, -}); - -use nexum::host::{logging, types}; - -struct FuelBomb; - -impl Guest for FuelBomb { - fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { - // Minimal SDK-free fixture: no tracing subscriber is installed, - // so log through the raw host binding directly. - logging::log(logging::Level::Info, "fuel-bomb init (will exhaust fuel)"); - Ok(()) - } - - fn on_event(_event: types::Event) -> Result<(), Fault> { - // Unbounded loop. `std::hint::black_box` prevents the - // optimiser from constant-folding this away, so the loop - // genuinely burns wasmtime fuel one branch + add at a time. - // 1 billion default fuel / ~10 fuel-per-iteration -> trap - // within ~100M iterations, well under a second of wall - // clock on real hardware. - let mut x: u64 = 0; - loop { - x = x.wrapping_add(1); - std::hint::black_box(x); - } - } -} - -export!(FuelBomb); diff --git a/nexum/modules/fixtures/memory-bomb/Cargo.toml b/nexum/modules/fixtures/memory-bomb/Cargo.toml deleted file mode 100644 index 09c352fc..00000000 --- a/nexum/modules/fixtures/memory-bomb/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "memory-bomb" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Evil-by-design fixture: on every event allocates past the 64 MiB memory cap to force a memory-growth trap. Engine must trap + mark the module dead without taking down the supervisor." - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/nexum/modules/fixtures/memory-bomb/module.toml b/nexum/modules/fixtures/memory-bomb/module.toml deleted file mode 100644 index 48f22dde..00000000 --- a/nexum/modules/fixtures/memory-bomb/module.toml +++ /dev/null @@ -1,20 +0,0 @@ -# memory-bomb test fixture. Subscribes to blocks; the -# `on_event` handler allocates 128 MiB which exceeds the default 64 -# MiB per-module cap. The host traps + the integration test asserts -# the supervisor marks the module dead. - -[module] -name = "memory-bomb" -version = "0.1.0" -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -required = ["logging"] -optional = [] - -[capabilities.http] -allow = [] - -[[subscription]] -kind = "block" -chain_id = 1 diff --git a/nexum/modules/fixtures/memory-bomb/src/lib.rs b/nexum/modules/fixtures/memory-bomb/src/lib.rs deleted file mode 100644 index 948b65e1..00000000 --- a/nexum/modules/fixtures/memory-bomb/src/lib.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! # memory-bomb (test fixture) -//! -//! Deliberately allocates past the default 64 MiB per-module memory -//! cap on every `on_event`. The wasmtime `StoreLimits` reject the -//! linear-memory grow, the host traps the module, the supervisor -//! marks it dead, and other modules keep dispatching. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -wit_bindgen::generate!({ - path: [ - "../../../wit/nexum-host", - ], - world: "nexum:host/event-module", - generate_all, -}); - -use nexum::host::{logging, types}; - -struct MemoryBomb; - -impl Guest for MemoryBomb { - fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { - // Minimal SDK-free fixture: no tracing subscriber is installed, - // so log through the raw host binding directly. - logging::log( - logging::Level::Info, - "memory-bomb init (will exhaust memory)", - ); - Ok(()) - } - - fn on_event(_event: types::Event) -> Result<(), Fault> { - // The default per-module cap is 64 MiB (see - // `crates/nexum-runtime/src/runtime/limits.rs::DEFAULT_MEMORY_LIMIT`). - // Asking for 128 MiB forces a wasmtime `memory.grow` trap. - // `black_box` keeps the allocation live so the optimiser - // cannot eliminate the request. - let size = 128 * 1024 * 1024; - let mut buf: Vec = Vec::with_capacity(size); - buf.resize(size, 0xab); - std::hint::black_box(&buf); - Ok(()) - } -} - -export!(MemoryBomb); diff --git a/nexum/modules/fixtures/panic-bomb/Cargo.toml b/nexum/modules/fixtures/panic-bomb/Cargo.toml deleted file mode 100644 index af834858..00000000 --- a/nexum/modules/fixtures/panic-bomb/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "panic-bomb" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Evil-by-design fixture: installs the nexum-sdk tracing facade in init and panics on every event. One death must leave Stderr, HostInterface, and Panic records on the run." - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nexum-sdk = { path = "../../../crates/nexum-sdk" } -tracing = { version = "0.1", default-features = false } -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/nexum/modules/fixtures/panic-bomb/module.toml b/nexum/modules/fixtures/panic-bomb/module.toml deleted file mode 100644 index 549ccaa0..00000000 --- a/nexum/modules/fixtures/panic-bomb/module.toml +++ /dev/null @@ -1,22 +0,0 @@ -# panic-bomb test fixture. Subscribes to blocks; `init` installs the -# nexum-sdk tracing facade (subscriber + panic hook) and `on_event` -# panics. The hook writes the panic to stderr and forwards it over the -# host logging call before the trap reaches the supervisor, so the -# integration test asserts one dead run carries Stderr, HostInterface, -# and Panic records. - -[module] -name = "panic-bomb" -version = "0.1.0" -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -required = ["logging"] -optional = [] - -[capabilities.http] -allow = [] - -[[subscription]] -kind = "block" -chain_id = 1 diff --git a/nexum/modules/fixtures/panic-bomb/src/lib.rs b/nexum/modules/fixtures/panic-bomb/src/lib.rs deleted file mode 100644 index 56eaa348..00000000 --- a/nexum/modules/fixtures/panic-bomb/src/lib.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! # panic-bomb (test fixture) -//! -//! Installs the nexum-sdk tracing facade (subscriber + panic hook) in -//! `init` and panics on every `on_event`. The hook writes the panic to -//! stderr and forwards it over the host logging call before the trap -//! reaches the supervisor, so one death leaves Stderr, HostInterface, -//! and Panic records on the run. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -wit_bindgen::generate!({ - path: [ - "../../../wit/nexum-host", - ], - world: "nexum:host/event-module", - generate_all, -}); - -use nexum::host::{logging, types}; - -/// Routes facade lines to the bound host logging import. -/// -/// Hand-rolled rather than generated by `bind_host_via_wit_bindgen!`: -/// this fixture binds only the minimal nexum world, so the generic -/// adapter would pull in unused chain/local-store impls and an unused -/// error converter (dead code under the `-D warnings` wasm build) for -/// the sole benefit of one log line. The sink below is the whole cost. -struct HostLogSink; - -impl nexum_sdk::tracing::LogSink for HostLogSink { - fn log(&self, level: nexum_sdk::Level, message: &str) { - use nexum_sdk::Level; - // `Level` is a set of associated consts, so compare rather than - // match; the five tiers are total, hence the final `Trace` arm. - let level = if level == Level::ERROR { - logging::Level::Error - } else if level == Level::WARN { - logging::Level::Warn - } else if level == Level::INFO { - logging::Level::Info - } else if level == Level::DEBUG { - logging::Level::Debug - } else { - logging::Level::Trace - }; - logging::log(level, message); - } -} - -struct PanicBomb; - -impl Guest for PanicBomb { - fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { - nexum_sdk::tracing::init(HostLogSink); - tracing::info!("panic-bomb init (will panic)"); - Ok(()) - } - - fn on_event(_event: types::Event) -> Result<(), Fault> { - panic!("panic-bomb detonated"); - } -} - -export!(PanicBomb); diff --git a/nexum/modules/fixtures/slow-host/Cargo.toml b/nexum/modules/fixtures/slow-host/Cargo.toml deleted file mode 100644 index 8206cab4..00000000 --- a/nexum/modules/fixtures/slow-host/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "slow-host" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Evil-by-design fixture: on_event issues a single chain::request host call that the test wires to a mock provider which parks the request far past the dispatch deadline. Proves the supervisor cuts off a blocked host call and recovers on a fresh store, which fuel and epoch metering cannot do." - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/nexum/modules/fixtures/slow-host/module.toml b/nexum/modules/fixtures/slow-host/module.toml deleted file mode 100644 index 807f5421..00000000 --- a/nexum/modules/fixtures/slow-host/module.toml +++ /dev/null @@ -1,25 +0,0 @@ -# slow-host test fixture. Subscribes to a single chain's blocks so the -# supervisor invokes `on_event` once per block; the handler issues one -# `chain::request` host call and returns Ok. The integration test wires -# the chain capability to a mock provider that parks the first request far -# past a short `event_deadline_secs` override, so the dispatch deadline -# fires while the guest is suspended inside the host call. This proves the -# supervisor cuts off a blocked host call, marks the module dead, and -# reinstantiates it on a fresh store, which fuel and epoch metering cannot -# do because neither sees time spent in native host code. - -[module] -name = "slow-host" -version = "0.1.0" -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -required = ["logging", "chain"] -optional = [] - -[capabilities.http] -allow = [] - -[[subscription]] -kind = "block" -chain_id = 1 diff --git a/nexum/modules/fixtures/slow-host/src/lib.rs b/nexum/modules/fixtures/slow-host/src/lib.rs deleted file mode 100644 index 97b420be..00000000 --- a/nexum/modules/fixtures/slow-host/src/lib.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! # slow-host (test fixture) -//! -//! On every event issues a single `chain::request` host call and returns -//! `Ok`. The handler does no guest-side work of note; the point is the -//! host call itself. -//! -//! Fuel meters only guest wasm instructions and epoch interruption fires -//! only at wasm instruction boundaries, so neither can see, let alone -//! bound, time the guest spends suspended inside a host call. This fixture -//! makes that gap observable: the integration test wires the `chain` -//! capability to a mock provider that parks the first `request` far past a -//! short `event_deadline_secs` override. The guest suspends inside the host -//! call, the per-dispatch wall-clock deadline fires, and the supervisor -//! must drop the suspended call, mark the module dead, and reinstantiate it -//! on a fresh store. On the next dispatch the mock answers promptly, so the -//! same guest recovers and returns `Ok`. -//! -//! The result of the call is deliberately ignored: whether the request -//! resolves, errors, or is cut off, the handler returns `Ok(())`, so the -//! only thing that can end a dispatch early is the deadline under test. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only and never gets loaded by the testnet configs. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -wit_bindgen::generate!({ - path: [ - "../../../wit/nexum-host", - ], - world: "nexum:host/event-module", - generate_all, -}); - -use nexum::host::{chain, logging, types}; - -struct SlowHost; - -impl Guest for SlowHost { - fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { - // Minimal SDK-free fixture: no tracing subscriber is installed, - // so log through the raw host binding directly. - logging::log(logging::Level::Info, "slow-host init"); - Ok(()) - } - - fn on_event(_event: types::Event) -> Result<(), Fault> { - // A single read-only RPC. The test's mock provider decides how long - // it takes to answer; the guest just awaits it. `eth_blockNumber` - // with empty params is the cheapest well-formed request in the - // permitted read surface. - let _ = chain::request(1, "eth_blockNumber", "[]"); - logging::log(logging::Level::Info, "slow-host on_event returned"); - Ok(()) - } -} - -export!(SlowHost); diff --git a/nexum/scripts/check-venue-agnostic.sh b/nexum/scripts/check-venue-agnostic.sh deleted file mode 100755 index d7658e44..00000000 --- a/nexum/scripts/check-venue-agnostic.sh +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env bash -# Zero-leak check for the host layer, scoped precisely: no host-layer -# crate graph (runtime, launcher, bare engine) reaches a -# videre/intent/venue/cow crate; the runtime Rust sources carry no -# charter symbol -# (nexum:intent|value-flow|VenueAdapter|synthesize_venue|nexum:adapter|PoolRouter) -# and no privileged router field; and nexum:host names no foreign WIT -# package, resolves as a leaf, and its wit-deps manifest and lock stay -# empty. The opaque-status envelope -# (intent-status-update, its venue id string) is ratified host surface, -# not a leak. Blocking in CI; run locally via `just check-venue-agnostic`. - -set -uo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR/.." || exit 2 - -pass() { printf '\033[1;32m[l1 PASS]\033[0m %s\n' "$*" >&2; } -fail() { printf '\033[1;31m[l1 FAIL]\033[0m %s\n' "$*" >&2; status=1; } - -command -v rg >/dev/null || { echo "ripgrep (rg) is required" >&2; exit 2; } - -status=0 - -# 1. Crate graph: nothing venue-shaped reachable from the host-layer -# crates - the runtime, the generic launcher, and the bare engine -# binary (normal + build edges; dev-deps stay local to the crate). -for crate in nexum-runtime nexum-launch nexum-cli; do - if tree="$(cargo tree -p "$crate" -e normal,build --all-features --prefix none --locked)"; then - reached="$(printf '%s\n' "$tree" | - awk '{print $1}' | sort -u | rg -i 'videre|intent|venue|cow' || true)" - if [[ -n $reached ]]; then - fail "$crate crate graph reaches: $(tr '\n' ' ' <<<"$reached")" - else - pass "$crate crate graph clean" - fi - else - fail "cargo tree failed for $crate" - fi -done - -# 2. Symbol scan: the charter set (forbidden WIT namespaces, the old -# router and adapter names). Section 1 guards dependency edges; this -# scan stays curated so opaque extension payloads never false-flag. -charter='nexum:intent|value-flow|VenueAdapter|synthesize_venue|nexum:adapter|PoolRouter' -rg -n --no-heading -e "$charter" crates/nexum-runtime/src -case $? in - 0) fail "charter symbols leak into nexum-runtime" ;; - 1) pass "symbol scan empty" ;; - *) fail "symbol scan errored (crates/nexum-runtime/src missing?)" ;; -esac - -# 3. Privileged-field scan: the venue registry rides the extension -# service map; no router field may return to the runtime. -rg -n --no-heading -e 'venue_registry|pool_router' crates/nexum-runtime/src -case $? in - 0) fail "a privileged router field returned to nexum-runtime" ;; - 1) pass "no privileged router field" ;; - *) fail "field scan errored (crates/nexum-runtime/src missing?)" ;; -esac - -# 4. WIT surface: nexum:host is a leaf. No foreign package named -# anywhere in its sources, no cross-package use/import, and the -# package resolves standalone. The opaque-status envelope stays. -wit_charter='nexum:intent|nexum:adapter|value-flow|videre:|shepherd:cow' -rg -n --no-heading -e "$wit_charter" wit/nexum-host -case $? in - 0) fail "a foreign WIT namespace leaks into wit/nexum-host" ;; - 1) pass "no foreign WIT namespace named" ;; - *) fail "WIT namespace scan errored (wit/nexum-host missing?)" ;; -esac -rg -n --no-heading -e '^\s*(use|import)\s+[a-z0-9-]+:' wit/nexum-host -case $? in - 0) fail "nexum:host references another WIT package" ;; - 1) pass "nexum:host has no cross-package reference" ;; - *) fail "WIT scan errored (wit/nexum-host missing?)" ;; -esac -if command -v wasm-tools >/dev/null; then - if wasm-tools component wit wit/nexum-host >/dev/null; then - pass "nexum:host resolves standalone" - else - fail "nexum:host does not resolve standalone" - fi -else - printf '\033[1;33m[l1 WARN]\033[0m wasm-tools not found; WIT resolve skipped\n' >&2 -fi - -# 5. wit-deps manifest: crate-local resolution with an empty, locked -# dependency set; a declared dependency would unmake the leaf. -for f in wit/deps.toml wit/deps.lock; do - if [[ ! -f $f ]]; then - fail "$f missing" - continue - fi - rg -n --no-heading -e '^\s*[^#[:space:]]' "$f" - case $? in - 0) fail "$f declares a WIT dependency; nexum:host is a leaf" ;; - 1) pass "$f empty" ;; - *) fail "manifest scan errored for $f" ;; - esac -done - -exit "$status" diff --git a/nexum/wit/deps.lock b/nexum/wit/deps.lock deleted file mode 100644 index e69de29b..00000000 diff --git a/nexum/wit/deps.toml b/nexum/wit/deps.toml deleted file mode 100644 index e313c703..00000000 --- a/nexum/wit/deps.toml +++ /dev/null @@ -1 +0,0 @@ -# nexum:host is a leaf package; this manifest stays empty. diff --git a/shepherd/scripts/README.md b/scripts/README.md similarity index 100% rename from shepherd/scripts/README.md rename to scripts/README.md diff --git a/shepherd/scripts/_ethflow_quote.py b/scripts/_ethflow_quote.py similarity index 100% rename from shepherd/scripts/_ethflow_quote.py rename to scripts/_ethflow_quote.py diff --git a/shepherd/scripts/_twap_calldata.py b/scripts/_twap_calldata.py similarity index 100% rename from shepherd/scripts/_twap_calldata.py rename to scripts/_twap_calldata.py diff --git a/shepherd/scripts/check-cow-orderbook-only.sh b/scripts/check-cow-orderbook-only.sh similarity index 100% rename from shepherd/scripts/check-cow-orderbook-only.sh rename to scripts/check-cow-orderbook-only.sh diff --git a/scripts/check-dep-sync.sh b/scripts/check-dep-sync.sh deleted file mode 100755 index 1e530623..00000000 --- a/scripts/check-dep-sync.sh +++ /dev/null @@ -1,335 +0,0 @@ -#!/usr/bin/env bash -# Dep-sync gate for the transitional three-grouping workspace. Always -# blocking: the crate DAG points strictly up (nexum <- videre <- -# shepherd) for every workspace member, and path deps and wit/ symlinks -# never cross downward. Agreement checks: the umbrella [patch] table, -# member git-tag pins, per-group Cargo.repo.toml manifests, and -# wit-deps manifests and locks must name the same crates and the same -# one tag per upstream repo. An artefact a train wave has not written -# yet skips visibly and enforces the moment it lands. See -# docs/design/cross-repo-deps.md. Run locally via `just check-dep-sync`. - -set -uo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR/.." || exit 2 -root="$PWD" - -pass() { printf '\033[1;32m[dep-sync PASS]\033[0m %s\n' "$*" >&2; } -fail() { printf '\033[1;31m[dep-sync FAIL]\033[0m %s\n' "$*" >&2; status=1; } -skip() { printf '\033[1;33m[dep-sync SKIP]\033[0m %s\n' "$*" >&2; } - -command -v rg >/dev/null || { echo "ripgrep (rg) is required" >&2; exit 2; } -command -v cargo >/dev/null || { echo "cargo is required" >&2; exit 2; } - -status=0 - -layers=(nexum videre shepherd) -rank() { - case $1 in - nexum) echo 0 ;; - videre) echo 1 ;; - shepherd) echo 2 ;; - *) echo -1 ;; - esac -} -# Owning grouping per upstream repo; shepherd is app-level, never a -# dependency target. -slug_owner() { - case $1 in - nexum-runtime) echo nexum ;; - videre-nexum-module) echo videre ;; - *) echo "" ;; - esac -} - -crate_name() { awk -F'"' '/^name = /{print $2; exit}' "$1/Cargo.toml"; } -toml_members() { awk '/^members = \[/{f=1;next} f&&/^\]/{exit} f' "$1" | tr -d ' ",' | rg -v '^$' || true; } -path_dep_values() { rg -o 'path *= *"([^"]+)"' -r '$1' "$1" 2>/dev/null || true; } - -mapfile -t members < <(toml_members Cargo.toml) -if [[ ${#members[@]} -eq 0 ]]; then - fail "no workspace members parsed from Cargo.toml" - exit 1 -fi - -# 1. Crate DAG: every member's full build closure (normal, build, and -# its own dev edges) stays in its own grouping or above. A stale -# lock fails once here: the lock must move with the manifests. -dag_bad=0 -if ! cargo metadata --format-version 1 --locked >/dev/null; then - fail "Cargo.lock out of sync with the manifests" - dag_bad=1 -fi -[[ $dag_bad -eq 1 ]] || for m in "${members[@]}"; do - g="${m%%/*}" - r="$(rank "$g")" - name="$(crate_name "$m")" - if [[ -z $name ]]; then - fail "no crate name in $m/Cargo.toml" - dag_bad=1 - continue - fi - if ! tree="$(cargo tree -p "$name" -e normal,build,dev --all-features --prefix none --locked)"; then - fail "cargo tree failed for $name ($m)" - dag_bad=1 - continue - fi - reached="$(printf '%s\n' "$tree" | awk -v root="$root/" -v r="$r" ' - { - i = index($0, "(" root) - if (!i) next - rest = substr($0, i + 1 + length(root)) - sub(/\).*/, "", rest) - split(rest, a, "/") - gr = a[1] == "nexum" ? 0 : a[1] == "videre" ? 1 : a[1] == "shepherd" ? 2 : -1 - if (gr > r) print rest - }' | sort -u)" - if [[ -n $reached ]]; then - fail "$m reaches down-layer crates: $(tr '\n' ' ' <<<"$reached")" - dag_bad=1 - fi -done -[[ $dag_bad -eq 0 ]] && pass "crate DAG acyclic across the groupings (${#members[@]} members)" - -# 2. Path deps: resolve every member path dep; a downward edge breaks -# the DAG, and a cross-group edge into a patched grouping must -# already be a git pin (collected here, judged in section 3). -declare -A cross_path_into=() -path_bad=0 -for m in "${members[@]}"; do - g="${m%%/*}" - r="$(rank "$g")" - while IFS= read -r p; do - [[ -z $p ]] && continue - rel="$(realpath -m "$root/$m/$p")" - rel="${rel#"$root"/}" - tg="${rel%%/*}" - trank="$(rank "$tg")" - if [[ $trank -lt 0 ]]; then - fail "$m/Cargo.toml path dep escapes the workspace: $p" - path_bad=1 - elif [[ $trank -gt $r ]]; then - fail "$m/Cargo.toml path dep points down-layer: $p" - path_bad=1 - elif [[ $tg != "$g" ]]; then - cross_path_into[$tg]+="$m/Cargo.toml -> $rel " - fi - done < <(path_dep_values "$m/Cargo.toml") -done -[[ $path_bad -eq 0 ]] && pass "no downward path dep" - -# 3. Cross-group Rust pins: git-tag pins on the owning repos, one tag -# per repo, each neutralised by an umbrella [patch] entry targeting -# the owning grouping's in-tree crate, with no orphan entry and no -# leftover cross-group path dep once the patch exists. -# Live manifests are read by the umbrella build and need [patch] -# neutralisation; Cargo.repo.toml is inert until the carve and only -# joins the tag-agreement check. -manifest_files=(Cargo.toml) -for m in "${members[@]}"; do manifest_files+=("$m/Cargo.toml"); done -for g in "${layers[@]}"; do - [[ -f $g/Cargo.repo.toml ]] && manifest_files+=("$g/Cargo.repo.toml") -done - -declare -A tag_for=() -for slug in nexum-runtime videre-nexum-module; do - url="https://github.com/nullislabs/$slug" - owner="$(slug_owner "$slug")" - pins="$(rg -Hn --no-heading "git *= *\"${url}(\\.git)?\"" "${manifest_files[@]}" || true)" - patch_body="$(awk -v hdr="[patch.\"$url\"]" ' - index($0, hdr) == 1 {f=1; next} - /^\[/{f=0} - f && NF && $0 !~ /^[[:space:]]*#/' Cargo.toml)" - - if [[ -z $pins && -z $patch_body ]]; then - skip "$slug pins and umbrella patch not written yet" - continue - fi - slug_ok=1 - - pin_crates="" - tags="" - live_pins=0 - while IFS= read -r hit; do - [[ -z $hit ]] && continue - file="${hit%%:*}" - loc="$file:$(cut -d: -f2 <<<"$hit")" - line="${hit#*:}" - line="${line#*:}" - if [[ $line =~ tag\ *=\ *\"([^\"]+)\" ]]; then - tags+="${BASH_REMATCH[1]}"$'\n' - else - fail "$loc: $slug git pin without an inline tag" - slug_ok=0 - fi - # Inert Cargo.repo.toml pins join the tag agreement only. - [[ $file == */Cargo.repo.toml ]] && continue - live_pins=1 - pin_crates+="$(sed -E 's/^[[:space:]]*([A-Za-z0-9_-]+)[[:space:]]*=.*/\1/' <<<"$line")"$'\n' - done <<<"$pins" - pin_crates="$(sort -u <<<"$pin_crates" | rg -v '^$' || true)" - uniq_tags="$(sort -u <<<"$tags" | rg -v '^$' || true)" - if [[ -n $uniq_tags && $(wc -l <<<"$uniq_tags") -gt 1 ]]; then - fail "$slug pinned at more than one tag: $(tr '\n' ' ' <<<"$uniq_tags")" - slug_ok=0 - elif [[ -n $uniq_tags ]]; then - tag_for[$slug]="$uniq_tags" - fi - - if [[ $live_pins -eq 1 && -z $patch_body ]]; then - fail "umbrella [patch] section missing for $url" - continue - fi - if [[ $live_pins -eq 0 && -z $patch_body ]]; then - [[ $slug_ok -eq 1 ]] && pass "$slug pinned at ${tag_for[$slug]:-?} (inert manifests only)" - continue - fi - - patch_keys="" - while IFS= read -r entry; do - [[ -z $entry ]] && continue - key="$(sed -E 's/^[[:space:]]*([A-Za-z0-9_-]+)[[:space:]]*=.*/\1/' <<<"$entry")" - p="$(rg -o 'path *= *"([^"]+)"' -r '$1' <<<"$entry" || true)" - patch_keys+="$key"$'\n' - if [[ -z $p ]]; then - fail "umbrella patch entry $key for $slug carries no path" - slug_ok=0 - continue - fi - if [[ ! -f $p/Cargo.toml ]]; then - fail "umbrella patch $key -> $p: no crate there" - slug_ok=0 - continue - fi - if [[ "${p%%/*}" != "$owner" ]]; then - fail "umbrella patch $key -> $p leaves the $owner grouping" - slug_ok=0 - fi - if [[ "$(crate_name "$p")" != "$key" ]]; then - fail "umbrella patch $key -> $p names crate $(crate_name "$p")" - slug_ok=0 - fi - done <<<"$patch_body" - patch_keys="$(sort -u <<<"$patch_keys" | rg -v '^$' || true)" - - unpatched="$(comm -23 <(printf '%s\n' "${pin_crates:-}") <(printf '%s\n' "${patch_keys:-}") | rg -v '^$' || true)" - if [[ -n $unpatched ]]; then - fail "$slug pins not neutralised by the umbrella patch: $(tr '\n' ' ' <<<"$unpatched")" - slug_ok=0 - fi - orphans="$(comm -13 <(printf '%s\n' "${pin_crates:-}") <(printf '%s\n' "${patch_keys:-}") | rg -v '^$' || true)" - if [[ -n $orphans ]]; then - fail "umbrella patch entries with no $slug pin: $(tr '\n' ' ' <<<"$orphans")" - slug_ok=0 - fi - - if [[ -n ${cross_path_into[$owner]:-} ]]; then - fail "cross-group path deps into $owner/ must be $slug git pins: ${cross_path_into[$owner]}" - slug_ok=0 - fi - [[ $slug_ok -eq 1 ]] && pass "$slug pinned at ${tag_for[$slug]:-?} and neutralised by the umbrella patch" -done - -# 4. Per-group Cargo.repo.toml: the carve-time workspace root must list -# exactly the umbrella's members for its grouping, and its path deps -# must stay inside the grouping. -for g in "${layers[@]}"; do - f="$g/Cargo.repo.toml" - if [[ ! -f $f ]]; then - skip "$f not written yet" - continue - fi - repo_members="$(toml_members "$f" | sort)" - expect="$(printf '%s\n' "${members[@]}" | rg "^$g/" | sed "s|^$g/||" | sort)" - if [[ "$repo_members" != "$expect" ]]; then - fail "$f member list disagrees with the umbrella workspace:" - diff <(printf '%s\n' "$expect") <(printf '%s\n' "$repo_members") >&2 || true - else - pass "$f members mirror the umbrella" - fi - while IFS= read -r p; do - [[ -z $p ]] && continue - rel="$(realpath -m "$root/$g/$p")" - [[ $rel == "$root/$g/"* ]] || fail "$f path dep leaves the grouping: $p" - done < <(path_dep_values "$f") -done - -# 5. WIT: cross-group wit/ symlinks never point down-layer; wit-deps -# manifests and locks exist together, stay key-synced, source only -# up-layer repos, and their tags agree with the Rust pins and across -# groups: one tag per upstream repo everywhere. -wit_bad=0 -declare -A wit_tag_seen=() -declare -A wit_tag_where=() -for g in "${layers[@]}"; do - r="$(rank "$g")" - [[ -d $g/wit ]] || continue - while IFS= read -r link; do - rel="$(realpath -m "$link")" - rel="${rel#"$root"/}" - trank="$(rank "${rel%%/*}")" - if [[ $trank -lt 0 || $trank -gt $r ]]; then - fail "$link symlink points down-layer or outside: $rel" - wit_bad=1 - fi - done < <(find "$g/wit" -type l) - - toml="$g/wit/deps.toml" - lock="$g/wit/deps.lock" - if [[ ! -f $toml && ! -f $lock ]]; then - skip "$g wit-deps manifest not written yet" - continue - fi - if [[ ! -f $toml || ! -f $lock ]]; then - fail "$g wit-deps manifest and lock must exist together" - wit_bad=1 - continue - fi - if [[ $g == nexum ]]; then - if rg -q '^\s*[^#[:space:]]' "$toml" "$lock"; then - fail "nexum wit-deps must stay empty; nexum:host is a leaf" - wit_bad=1 - fi - continue - fi - while IFS= read -r key; do - [[ -z $key ]] && continue - if ! rg -q "^(\\[$key\\]|$key *=)" "$lock"; then - fail "$toml key $key missing from $lock" - wit_bad=1 - fi - done < <({ rg -o '^([A-Za-z0-9_-]+) *=' -r '$1' "$toml" || true; rg -o '^\[([A-Za-z0-9_-]+)\]' -r '$1' "$toml" || true; } | sort -u) - for slug in nexum-runtime videre-nexum-module shepherd; do - owner="$(slug_owner "$slug")" - owner_rank="$(rank "${owner:-shepherd}")" - hits="$(rg --no-filename "nullislabs/$slug" "$toml" "$lock" || true)" - [[ -z $hits ]] && continue - if [[ $owner_rank -ge $r ]]; then - fail "$g wit-deps sources nullislabs/$slug, not an up-layer repo" - wit_bad=1 - continue - fi - wit_tags="$({ - rg -o 'refs/tags/([^/"]+)' -r '$1' <<<"$hits" | sed -E 's/\.(tar\.gz|tgz|zip)$//' - rg -o 'tag *= *"([^"]+)"' -r '$1' <<<"$hits" - } | sort -u | rg -v '^$' || true)" - [[ -z $wit_tags ]] && continue - if [[ $(wc -l <<<"$wit_tags") -gt 1 ]]; then - fail "$g wit-deps pins nullislabs/$slug at more than one tag: $(tr '\n' ' ' <<<"$wit_tags")" - wit_bad=1 - elif [[ -n ${tag_for[$slug]:-} && $wit_tags != "${tag_for[$slug]}" ]]; then - fail "$g wit-deps tag $wit_tags disagrees with the Rust pin ${tag_for[$slug]} for nullislabs/$slug" - wit_bad=1 - elif [[ -n ${wit_tag_seen[$slug]:-} && $wit_tags != "${wit_tag_seen[$slug]}" ]]; then - fail "$g wit-deps tag $wit_tags disagrees with the ${wit_tag_where[$slug]} wit-deps tag ${wit_tag_seen[$slug]} for nullislabs/$slug" - wit_bad=1 - else - wit_tag_seen[$slug]="$wit_tags" - wit_tag_where[$slug]="$g" - fi - done -done -[[ $wit_bad -eq 0 ]] && pass "wit artefacts agree where written" - -exit "$status" diff --git a/shepherd/scripts/e2e-finish.sh b/scripts/e2e-finish.sh similarity index 95% rename from shepherd/scripts/e2e-finish.sh rename to scripts/e2e-finish.sh index 687e8e59..43cebfe6 100755 --- a/shepherd/scripts/e2e-finish.sh +++ b/scripts/e2e-finish.sh @@ -21,7 +21,7 @@ require_cmd curl load_env -[[ -f "$STATE_FILE" ]] || die "shepherd/scripts/.state not found — was shepherd/scripts/e2e-run.sh ever invoked?" +[[ -f "$STATE_FILE" ]] || die "scripts/.state not found — was scripts/e2e-run.sh ever invoked?" engine_pid="$(state_value ENGINE_PID)" || die "ENGINE_PID missing from .state" log_file="$(state_value LOG_FILE)" || die "LOG_FILE missing from .state" start_ts="$(state_value START_TS)" || die "START_TS missing from .state" diff --git a/shepherd/scripts/e2e-onchain.sh b/scripts/e2e-onchain.sh similarity index 100% rename from shepherd/scripts/e2e-onchain.sh rename to scripts/e2e-onchain.sh diff --git a/shepherd/scripts/e2e-report-gen.sh b/scripts/e2e-report-gen.sh similarity index 99% rename from shepherd/scripts/e2e-report-gen.sh rename to scripts/e2e-report-gen.sh index 7c8e263b..29f156bf 100755 --- a/shepherd/scripts/e2e-report-gen.sh +++ b/scripts/e2e-report-gen.sh @@ -15,7 +15,7 @@ source "$SCRIPT_DIR/lib.sh" require_cmd jq require_cmd python3 -[[ -f "$STATE_FILE" ]] || die "shepherd/scripts/.state not found" +[[ -f "$STATE_FILE" ]] || die "scripts/.state not found" log_file="$(state_value LOG_FILE)" || die "LOG_FILE missing" metrics_start="$(state_value METRICS_START)" || die "METRICS_START missing" metrics_end="$(state_value METRICS_END)" || die "METRICS_END missing" diff --git a/shepherd/scripts/e2e-run.sh b/scripts/e2e-run.sh similarity index 100% rename from shepherd/scripts/e2e-run.sh rename to scripts/e2e-run.sh diff --git a/shepherd/scripts/env-template b/scripts/env-template similarity index 100% rename from shepherd/scripts/env-template rename to scripts/env-template diff --git a/shepherd/scripts/lib.sh b/scripts/lib.sh similarity index 93% rename from shepherd/scripts/lib.sh rename to scripts/lib.sh index c2cf40c3..244e1229 100644 --- a/shepherd/scripts/lib.sh +++ b/scripts/lib.sh @@ -34,13 +34,13 @@ require_cmd() { } load_env() { - [[ -f "$ENV_FILE" ]] || die "shepherd/scripts/.env not found. Run: cp shepherd/scripts/env-template shepherd/scripts/.env && \$EDITOR shepherd/scripts/.env" + [[ -f "$ENV_FILE" ]] || die "scripts/.env not found. Run: cp scripts/env-template scripts/.env && \$EDITOR scripts/.env" set -a # shellcheck disable=SC1090 source "$ENV_FILE" set +a - [[ -n "${RPC_URL_SEPOLIA:-}" ]] || die "RPC_URL_SEPOLIA unset in shepherd/scripts/.env" - [[ -n "${RPC_URL_SEPOLIA_HTTP:-}" ]] || die "RPC_URL_SEPOLIA_HTTP unset in shepherd/scripts/.env" + [[ -n "${RPC_URL_SEPOLIA:-}" ]] || die "RPC_URL_SEPOLIA unset in scripts/.env" + [[ -n "${RPC_URL_SEPOLIA_HTTP:-}" ]] || die "RPC_URL_SEPOLIA_HTTP unset in scripts/.env" [[ "${RPC_URL_SEPOLIA}" == wss* ]] || die "RPC_URL_SEPOLIA must be wss:// (engine uses eth_subscribe)" [[ "${RPC_URL_SEPOLIA_HTTP}" == http* ]] || die "RPC_URL_SEPOLIA_HTTP must be http(s)://" } diff --git a/shepherd/scripts/load-bootstrap.sh b/scripts/load-bootstrap.sh similarity index 100% rename from shepherd/scripts/load-bootstrap.sh rename to scripts/load-bootstrap.sh diff --git a/shepherd/scripts/load-run.sh b/scripts/load-run.sh similarity index 100% rename from shepherd/scripts/load-run.sh rename to scripts/load-run.sh diff --git a/shepherd/scripts/load-teardown.sh b/scripts/load-teardown.sh similarity index 100% rename from shepherd/scripts/load-teardown.sh rename to scripts/load-teardown.sh diff --git a/shepherd/scripts/soak-snapshot.sh b/scripts/soak-snapshot.sh similarity index 100% rename from shepherd/scripts/soak-snapshot.sh rename to scripts/soak-snapshot.sh diff --git a/shepherd/Cargo.repo.toml b/shepherd/Cargo.repo.toml deleted file mode 100644 index 91820110..00000000 --- a/shepherd/Cargo.repo.toml +++ /dev/null @@ -1,89 +0,0 @@ -# Workspace root for the standalone shepherd repo; renamed to Cargo.toml -# at the carve. Carries no cross-group [patch]: nexum-* and videre-* deps -# resolve from their real git tags. - -[workspace] -members = [ - "crates/composable-cow", - "crates/cow-venue", - "crates/shepherd", - "crates/shepherd-backtest", - "modules/ethflow-watcher", - "modules/examples/stop-loss", - "modules/twap-monitor", - "tools/load-gen", - "tools/orderbook-mock", -] -resolver = "2" - -[workspace.package] -edition = "2024" -license = "AGPL-3.0" -repository = "https://github.com/nullislabs/shepherd" - -# Hoisted external deps. Core crates inherit with `dep.workspace = true`; -# guest modules express-declare their own external deps. -[workspace.dependencies] -# Error + async plumbing. -anyhow = "1" -thiserror = "2" -tokio = { version = "1", features = ["full"] } -futures = "0.3" - -# Serde + config. -serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } -toml = "1" - -# Wire codec. -borsh = { version = "1", features = ["derive"] } - -# Observability. -tracing = "0.1" -tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "ansi", "json"] } - -# CLI parser. -clap = { version = "4", features = ["derive"] } - -# alloy stack; featureless provider, call sites add transports. -alloy-primitives = { version = "1.6", default-features = false, features = ["std", "serde"] } -alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } -alloy-provider = { version = "2.1", default-features = false } -alloy-rpc-types-eth = { version = "2.1", default-features = false, features = ["std"] } -alloy-transport-ws = { version = "2.1", default-features = false } - -# HTTP surface. -reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } -http = "1" -axum = "0.8" - -# Guest bindings. -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } - -# Misc. -url = "2" -rand = "0.10" -hex = "0.4" - -# Dev/test helpers. -proptest = "1" -tower = "0.5" - -[workspace.lints.rust] -unsafe_op_in_unsafe_fn = "warn" - -[workspace.lints.clippy] -dbg_macro = "deny" -todo = "deny" - -# `cowprotocol` 0.2.0 (hash-only submission shape + the WASI clock fix) -# is unpublished; patch to the cow-rs rev carrying both. Drop once a -# release with the hash-only constructor ships. -[patch.crates-io] -cowprotocol = { git = "https://github.com/nullislabs/cow-rs", rev = "17fc0c5f55d04fad38963911da07d5da9290fefb" } - -[profile.dev] -panic = "abort" - -[profile.release] -panic = "abort" diff --git a/shepherd/wit/deps.toml b/shepherd/wit/deps.toml deleted file mode 100644 index 50920682..00000000 --- a/shepherd/wit/deps.toml +++ /dev/null @@ -1,21 +0,0 @@ -# Cross-repo WIT pins, vendored under wit/deps and verified against -# deps.lock by `wit-deps`. Pins are exact git tags of the owning repo, -# one tag per upstream, agreeing with the Rust pins; they converge to -# wkg/OCI (ghcr.io/nullislabs) with per-package semver once the -# packages are published. - -[nexum-host] -url = "https://github.com/nullislabs/nexum-runtime/archive/refs/tags/v0.1.0.tar.gz" -prefix = "nexum-runtime-0.1.0/wit/nexum-host" - -[videre-types] -url = "https://github.com/nullislabs/videre-nexum-module/archive/refs/tags/v0.1.0.tar.gz" -prefix = "videre-nexum-module-0.1.0/wit/videre-types" - -[videre-value-flow] -url = "https://github.com/nullislabs/videre-nexum-module/archive/refs/tags/v0.1.0.tar.gz" -prefix = "videre-nexum-module-0.1.0/wit/videre-value-flow" - -[videre-venue] -url = "https://github.com/nullislabs/videre-nexum-module/archive/refs/tags/v0.1.0.tar.gz" -prefix = "videre-nexum-module-0.1.0/wit/videre-venue" diff --git a/shepherd/wit/deps/nexum-host/chain.wit b/shepherd/wit/deps/nexum-host/chain.wit deleted file mode 100644 index 1d812dc6..00000000 --- a/shepherd/wit/deps/nexum-host/chain.wit +++ /dev/null @@ -1,60 +0,0 @@ -package nexum:host@0.1.0; - -interface chain { - use types.{chain-id, fault}; - - /// A JSON-RPC error response from the upstream node. `code` is the - /// node-reported numeric (typically `-32000` for an `eth_call` - /// revert). `data` is the decoded `error.data` payload: the host - /// hex-decodes the upstream JSON string once, so a guest receives - /// the raw abi-encoded revert bytes without any string handling. - record rpc-error { - code: s32, - message: string, - data: option>, - } - - /// Failure of a chain call: either a shared host `fault` (transport - /// down, timed out, denied, ...) or a structured JSON-RPC error - /// carrying the node code and any decoded revert payload. - variant chain-error { - fault(fault), - rpc(rpc-error), - } - - /// A single JSON-RPC request to be executed as part of a batch. - record rpc-request { - method: string, - params: string, - } - - /// Result of a single request inside a batch. Each entry is independent; - /// one failing call does not abort the others. - variant rpc-result { - ok(string), - err(chain-error), - } - - /// Execute a JSON-RPC request against the specified chain. - /// - /// The host routes to its configured provider for the given chain, - /// applying whatever middleware is appropriate for the platform - /// (timeout, retry, rate-limit, fallback on server; simple HTTP - /// on mobile; window.ethereum or injected provider in WebView). - /// - /// `method` includes the namespace prefix (e.g. "eth_call"). Each - /// host enforces its own permitted method surface; the reference - /// server host forwards only a read-only set and refuses signing - /// or mutating methods with a `denied` fault before they reach the - /// provider. `params` and the success value are JSON-encoded - /// strings. - request: func(chain-id: chain-id, method: string, params: string) - -> result; - - /// Execute several JSON-RPC requests against the same chain in a single - /// round trip where the host transport supports it. Hosts that cannot - /// batch natively MUST fall back to sequential `request` calls. The - /// returned list is the same length as `requests` and in the same order. - request-batch: func(chain-id: chain-id, requests: list) - -> result, chain-error>; -} diff --git a/shepherd/wit/deps/nexum-host/event-module.wit b/shepherd/wit/deps/nexum-host/event-module.wit deleted file mode 100644 index 277134a9..00000000 --- a/shepherd/wit/deps/nexum-host/event-module.wit +++ /dev/null @@ -1,24 +0,0 @@ -package nexum:host@0.1.0; - -/// Event-driven module — automation, background processing. -/// No UI capabilities. Runs on any conforming host. -world event-module { - use types.{config, event, fault}; - - // Six core primitives (always provided by a conforming host). - import chain; - import identity; - import local-store; - import remote-store; - import messaging; - import logging; - - // Time, randomness, and outbound HTTP are WASI concerns, not - // nexum:host interfaces: wasi:clocks and wasi:random are linked - // into every module store, and hosts link - // wasi:http/outgoing-handler, gated per-module by the - // `[capabilities.http].allow` allowlist in module.toml. - - export init: func(config: config) -> result<_, fault>; - export on-event: func(event: event) -> result<_, fault>; -} diff --git a/shepherd/wit/deps/nexum-host/identity.wit b/shepherd/wit/deps/nexum-host/identity.wit deleted file mode 100644 index 2e82c9fe..00000000 --- a/shepherd/wit/deps/nexum-host/identity.wit +++ /dev/null @@ -1,24 +0,0 @@ -package nexum:host@0.1.0; - -/// Identity / signing capability. -/// -/// 0.2 ships a single, minimal interface. A future release (0.4+) is -/// expected to split this into `identity-read` and `identity-sign` and to -/// introduce a richer `signing-result` variant; for 0.2 the simple shape is -/// sufficient because user rejection can already be expressed via -/// `fault.denied`. -interface identity { - use types.{fault}; - - /// Return the list of account addresses (20-byte EVM addresses) the host - /// is willing to sign for. Empty list means no signing capability. - accounts: func() -> result>, fault>; - - /// Sign an arbitrary message with personal_sign semantics (prepends the - /// "\x19Ethereum Signed Message:\n" prefix). Returns a 65-byte signature. - sign: func(account: list, message: list) -> result, fault>; - - /// Sign EIP-712 typed data. `typed-data` is a JSON-encoded EIP-712 payload. - /// Returns a 65-byte signature. - sign-typed-data: func(account: list, typed-data: string) -> result, fault>; -} diff --git a/shepherd/wit/deps/nexum-host/local-store.wit b/shepherd/wit/deps/nexum-host/local-store.wit deleted file mode 100644 index 8521b37a..00000000 --- a/shepherd/wit/deps/nexum-host/local-store.wit +++ /dev/null @@ -1,29 +0,0 @@ -package nexum:host@0.1.0; - -interface local-store { - use types.{fault}; - - /// Get a value by key. Returns none if the key does not exist. - get: func(key: string) -> result>, fault>; - - /// Set a key-value pair. Overwrites any existing value. - /// The host may enforce a size quota; if exceeded, returns err. - set: func(key: string, value: list) -> result<_, fault>; - - /// Delete a key. No-op if the key does not exist. - delete: func(key: string) -> result<_, fault>; - - /// List all keys matching a prefix. Empty prefix returns all keys. - list-keys: func(prefix: string) -> result, fault>; - - /// Whether the key exists, without transferring the value. - contains: func(key: string) -> result; - - /// Value byte length, none if the key is absent, without - /// transferring the value. On some backends this may be a scan. - len: func(key: string) -> result, fault>; - - /// Number of keys matching a prefix, without materialising the key - /// list. On some backends this may be a scan. - count: func(prefix: string) -> result; -} diff --git a/shepherd/wit/deps/nexum-host/logging.wit b/shepherd/wit/deps/nexum-host/logging.wit deleted file mode 100644 index 8cd98140..00000000 --- a/shepherd/wit/deps/nexum-host/logging.wit +++ /dev/null @@ -1,15 +0,0 @@ -package nexum:host@0.1.0; - -interface logging { - enum level { - trace, - debug, - info, - warn, - error, - } - - /// Emit a structured log message. - /// The host decides how to handle it (stdout, file, discard). - log: func(level: level, message: string); -} diff --git a/shepherd/wit/deps/nexum-host/messaging.wit b/shepherd/wit/deps/nexum-host/messaging.wit deleted file mode 100644 index 30777ca7..00000000 --- a/shepherd/wit/deps/nexum-host/messaging.wit +++ /dev/null @@ -1,19 +0,0 @@ -package nexum:host@0.1.0; - -interface messaging { - use types.{fault, message}; - - /// Publish a message to a content topic. - /// - /// Content topics follow the format: //// - /// e.g. "/nexum/1/twap-updates/proto" - publish: func(content-topic: string, payload: list) -> result<_, fault>; - - /// Query historical messages from the Waku store protocol. - query: func( - content-topic: string, - start-time: option, - end-time: option, - limit: option, - ) -> result, fault>; -} diff --git a/shepherd/wit/deps/nexum-host/query-module.wit b/shepherd/wit/deps/nexum-host/query-module.wit deleted file mode 100644 index ffb29b87..00000000 --- a/shepherd/wit/deps/nexum-host/query-module.wit +++ /dev/null @@ -1,25 +0,0 @@ -package nexum:host@0.1.0; - -/// Query module — synchronous, side-effect-free evaluation. -/// -/// EXPERIMENTAL (0.2): the shape of this world is provisional and may -/// change in a future minor release without a major bump. Hosts and SDKs -/// should expect breakage here until the world is stabilised. -/// -/// A query module exposes a single pure `evaluate` entry point. It is given -/// read-only access to the local store (for cached/derived state) and to -/// logging; everything else (chain access, network, messaging, signing) is -/// deliberately excluded so the host can run queries inside a tight -/// deterministic sandbox. -world query-module { - use types.{config, fault}; - - import local-store; - import logging; - - export init: func(config: config) -> result<_, fault>; - - /// Evaluate the query. `input` and the returned bytes are opaque to the - /// host; the module and its caller agree on the encoding. - export evaluate: func(input: list) -> result, fault>; -} diff --git a/shepherd/wit/deps/nexum-host/remote-store.wit b/shepherd/wit/deps/nexum-host/remote-store.wit deleted file mode 100644 index 39b36bae..00000000 --- a/shepherd/wit/deps/nexum-host/remote-store.wit +++ /dev/null @@ -1,33 +0,0 @@ -package nexum:host@0.1.0; - -interface remote-store { - use types.{fault}; - - /// Upload raw data to the decentralised store. - /// Returns the 32-byte content reference (Swarm address). - upload: func(data: list) -> result, fault>; - - /// Download raw data by 32-byte content reference. - download: func(reference: list) -> result, fault>; - - /// Read the latest value from a mutable feed. - /// - /// Feeds are mutable pointers: (owner, topic) -> latest chunk. - /// `owner`: 20-byte Ethereum address of the feed owner. - /// `topic`: 32-byte topic hash. - read-feed: func( - owner: list, - topic: list, - ) -> result>, fault>; - - /// Update a mutable feed with new data. - /// - /// The host signs the feed update with its configured identity. - /// `topic`: 32-byte topic hash. - /// `data`: the payload to publish. - /// Returns the 32-byte reference of the new chunk. - write-feed: func( - topic: list, - data: list, - ) -> result, fault>; -} diff --git a/shepherd/wit/deps/nexum-host/types.wit b/shepherd/wit/deps/nexum-host/types.wit deleted file mode 100644 index 8e834263..00000000 --- a/shepherd/wit/deps/nexum-host/types.wit +++ /dev/null @@ -1,106 +0,0 @@ -package nexum:host@0.1.0; - -/// Common types shared across all runtime interfaces. -/// -/// All `u64` timestamps in this package are milliseconds since the Unix -/// epoch, UTC. -interface types { - type chain-id = u64; - - record block { - chain-id: chain-id, - number: u64, - hash: list, - timestamp: u64, - } - - /// One decoded log, mirroring the RPC `eth_getLogs` shape field for - /// field so the guest can rebuild the native alloy log losslessly. - /// Fixed-width byte fields are carried raw: `address` is 20 bytes, - /// each topic and hash is 32. The block-scoped fields are absent on a - /// pending log (mined logs carry them all). - record chain-log { - address: list, - topics: list>, - data: list, - block-hash: option>, - block-number: option, - block-timestamp: option, - transaction-hash: option>, - transaction-index: option, - log-index: option, - removed: bool, - } - - /// A batch of logs delivered from one subscription. The alloy log type - /// carries no chain id, so it sits here once: every log in a delivery - /// shares the chain of the subscription that produced it. - record chain-logs { - chain-id: chain-id, - logs: list, - } - - /// A message delivered over the messaging interface. Defined here (rather - /// than only in `messaging.wit`) so the `event` variant can reference it - /// without a cross-interface use clause. - record message { - content-topic: string, - payload: list, - timestamp: u64, - /// Optional sender identity (protocol-dependent). - sender: option>, - } - - /// Fired by the host on a configured cadence. `fired-at` is the host's - /// wall-clock time (ms since Unix epoch, UTC) at which the tick was - /// generated. - record tick { - fired-at: u64, - } - - /// A host-observed status transition for a previously submitted - /// intent. Transport-blind: the subscriber sees only where the - /// intent is in its life, never how the host learnt it. - record intent-status-update { - /// Venue id the receipt was issued by. - venue: string, - /// The venue-scoped intent identifier, opaque to the host. - receipt: list, - /// Opaque versioned status body: a leading u8 version tag, - /// then that version's borsh payload (v1: lifecycle status, - /// optional settlement proof, optional failure reason). An - /// unknown tag is a decode error and the body is never empty. - /// The host never inspects it. - status: list, - } - - variant event { - block(block), - chain-logs(chain-logs), - tick(tick), - message(message), - intent-status(intent-status-update), - } - - /// Opaque config from module.toml [config] section. - type config = list>; - - /// The cross-domain failure vocabulary richer interfaces embed as a - /// case. Each payload-bearing case carries a human-readable detail; - /// `rate-limited` carries structured backoff guidance instead. - variant fault { - unsupported(string), - unavailable(string), - denied(string), - rate-limited(rate-limit), - timeout, - invalid-input(string), - internal(string), - } - - /// Backoff guidance for a `fault.rate-limited`. `retry-after-ms` is - /// the host's suggested wait before retrying, when known. - record rate-limit { - retry-after-ms: option, - } -} diff --git a/shepherd/tools/backtest-collect/backtest_collect.py b/tools/backtest-collect/backtest_collect.py similarity index 100% rename from shepherd/tools/backtest-collect/backtest_collect.py rename to tools/backtest-collect/backtest_collect.py diff --git a/shepherd/tools/backtest-collect/fixtures-2026-06-22.json b/tools/backtest-collect/fixtures-2026-06-22.json similarity index 100% rename from shepherd/tools/backtest-collect/fixtures-2026-06-22.json rename to tools/backtest-collect/fixtures-2026-06-22.json diff --git a/shepherd/tools/baseline-latency/.gitignore b/tools/baseline-latency/.gitignore similarity index 100% rename from shepherd/tools/baseline-latency/.gitignore rename to tools/baseline-latency/.gitignore diff --git a/shepherd/tools/baseline-latency/baseline_latency.py b/tools/baseline-latency/baseline_latency.py similarity index 100% rename from shepherd/tools/baseline-latency/baseline_latency.py rename to tools/baseline-latency/baseline_latency.py diff --git a/shepherd/tools/baseline-latency/data/arbitrum_one.json b/tools/baseline-latency/data/arbitrum_one.json similarity index 100% rename from shepherd/tools/baseline-latency/data/arbitrum_one.json rename to tools/baseline-latency/data/arbitrum_one.json diff --git a/shepherd/tools/baseline-latency/data/base.json b/tools/baseline-latency/data/base.json similarity index 100% rename from shepherd/tools/baseline-latency/data/base.json rename to tools/baseline-latency/data/base.json diff --git a/shepherd/tools/baseline-latency/data/gnosis.json b/tools/baseline-latency/data/gnosis.json similarity index 100% rename from shepherd/tools/baseline-latency/data/gnosis.json rename to tools/baseline-latency/data/gnosis.json diff --git a/shepherd/tools/baseline-latency/data/mainnet.json b/tools/baseline-latency/data/mainnet.json similarity index 100% rename from shepherd/tools/baseline-latency/data/mainnet.json rename to tools/baseline-latency/data/mainnet.json diff --git a/shepherd/tools/baseline-latency/data/sepolia.json b/tools/baseline-latency/data/sepolia.json similarity index 100% rename from shepherd/tools/baseline-latency/data/sepolia.json rename to tools/baseline-latency/data/sepolia.json diff --git a/shepherd/tools/load-gen/Cargo.toml b/tools/load-gen/Cargo.toml similarity index 100% rename from shepherd/tools/load-gen/Cargo.toml rename to tools/load-gen/Cargo.toml diff --git a/shepherd/tools/load-gen/src/main.rs b/tools/load-gen/src/main.rs similarity index 100% rename from shepherd/tools/load-gen/src/main.rs rename to tools/load-gen/src/main.rs diff --git a/shepherd/tools/orderbook-mock/Cargo.toml b/tools/orderbook-mock/Cargo.toml similarity index 100% rename from shepherd/tools/orderbook-mock/Cargo.toml rename to tools/orderbook-mock/Cargo.toml diff --git a/shepherd/tools/orderbook-mock/src/main.rs b/tools/orderbook-mock/src/main.rs similarity index 100% rename from shepherd/tools/orderbook-mock/src/main.rs rename to tools/orderbook-mock/src/main.rs diff --git a/videre/Cargo.repo.toml b/videre/Cargo.repo.toml deleted file mode 100644 index d01b0eb7..00000000 --- a/videre/Cargo.repo.toml +++ /dev/null @@ -1,77 +0,0 @@ -# Workspace root for the standalone videre-nexum-module repo; renamed to -# Cargo.toml at the carve. Carries no [patch]: nexum-* deps resolve from -# their real git tags. - -[workspace] -members = [ - "crates/no-std-probe", - "crates/videre-host", - "crates/videre-macros", - "crates/videre-sdk", - "crates/videre-test", - "modules/examples/echo-client", - "modules/examples/echo-keeper", - "modules/examples/echo-venue", - "modules/fixtures/flaky-venue", -] -resolver = "2" - -[workspace.package] -edition = "2024" -license = "AGPL-3.0" -repository = "https://github.com/nullislabs/videre-nexum-module" - -# Hoisted external deps. Core crates inherit with `dep.workspace = true`; -# guest modules express-declare their own external deps. -[workspace.dependencies] -# Error + async plumbing. -anyhow = "1" -thiserror = "2" -tokio = { version = "1", features = ["full"] } -futures = "0.3" -async-trait = "0.1" - -# Serde + config. -serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } -toml = "1" - -# Wire codec. -borsh = { version = "1", features = ["derive"] } - -# Observability. -tracing = "0.1" -strum = { version = "0.28", default-features = false, features = ["derive"] } - -# HTTP request/response vocabulary for the `Fetch` seam. -http = "1" - -# Hex codec for vector and golden files. -hex = "0.4" - -# Proc-macro toolkit. -proc-macro2 = "1" -quote = "1" -syn = { version = "2", features = ["full"] } - -# Guest bindings. -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } - -# WASM Component Model runtime (videre-host). -wasmtime = { version = "46", features = ["component-model"] } - -# Dev/test helpers. -tempfile = "3" - -[workspace.lints.rust] -unsafe_op_in_unsafe_fn = "warn" - -[workspace.lints.clippy] -dbg_macro = "deny" -todo = "deny" - -[profile.dev] -panic = "abort" - -[profile.release] -panic = "abort" diff --git a/videre/crates/no-std-probe/Cargo.toml b/videre/crates/no-std-probe/Cargo.toml deleted file mode 100644 index 0a5c6279..00000000 --- a/videre/crates/no-std-probe/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "no-std-probe" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Compile-only #![no_std] probe: the IntentBody derive must expand without the consumer's std prelude." - -[lib] -# Never shipped. Living in the workspace keeps the derive's no_std -# contract under the workspace check/clippy gate. - -[lints] -workspace = true - -[dependencies] -# Source of the `IntentBody` derive and trait the probe enum implements. -videre-sdk = { path = "../videre-sdk" } diff --git a/videre/crates/no-std-probe/src/lib.rs b/videre/crates/no-std-probe/src/lib.rs deleted file mode 100644 index fd455704..00000000 --- a/videre/crates/no-std-probe/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Compile-only `#![no_std]` probe: `#[derive(IntentBody)]` must expand -//! without the consumer's std prelude or an `extern crate alloc`. - -#![no_std] -#![warn(missing_docs)] - -use videre_sdk::IntentBody; - -/// The probe schema: one published version over a bare byte payload. -#[derive(IntentBody, Clone, Debug, PartialEq, Eq)] -pub enum ProbeBody { - /// First published version. - V1(u8), -} diff --git a/videre/crates/videre-host/Cargo.toml b/videre/crates/videre-host/Cargo.toml deleted file mode 100644 index bdad1c9d..00000000 --- a/videre/crates/videre-host/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "videre-host" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "The videre venue platform as one nexum-runtime extension: the venue-adapter provider kind, the venue registry service, the advisory egress-guard seam, and the videre:venue/client interface." - -[lints] -workspace = true - -[dependencies] -# The runtime seam this crate plugs into; the only nexum crate edge. -nexum-runtime = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } -# Encoder for the opaque status body the host event stream carries; the -# registry lowers an adapter-reported status through it. -nexum-status-body = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } - -wasmtime.workspace = true -anyhow.workspace = true -thiserror.workspace = true -async-trait.workspace = true -futures.workspace = true -serde.workspace = true -tokio.workspace = true -toml.workspace = true -tracing.workspace = true - -[dev-dependencies] -nexum-runtime = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0", features = ["test-utils"] } -nexum-tasks = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } -tempfile.workspace = true diff --git a/videre/crates/videre-host/src/bindings.rs b/videre/crates/videre-host/src/bindings.rs deleted file mode 100644 index 28f35449..00000000 --- a/videre/crates/videre-host/src/bindings.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! WIT bindings for the venue platform, generated by -//! `wasmtime::component::bindgen!`. -//! -//! The shared `nexum:host` interfaces are reused from the runtime's -//! `event-module` bindings via `with`, so the `chain`/`messaging` `Host` -//! impls and the `fault` type an adapter sees are the very ones the core -//! host constructs. The `videre:types` and `videre:value-flow` types -//! generate in the adapter bindgen and the client bindgen remaps onto -//! them, so one Rust type serves the registry and the adapter face alike. -//! `PartialEq` is derived so the registry can compare a polled status -//! against the last delivered one. - -/// The provider face: the `videre:venue/venue-adapter` world. An adapter -/// imports only the scoped transport it needs (chain and messaging; -/// outbound HTTP is wasi:http, linked and allowlisted separately) and -/// exports the `videre:venue/adapter` face plus `init`. -mod venue_adapter { - wasmtime::component::bindgen!({ - path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/deps/nexum-host", - "../../wit/videre-venue", - ], - world: "videre:venue/venue-adapter", - imports: { default: async }, - exports: { default: async }, - with: { - "nexum:host/types": nexum_runtime::bindings::nexum::host::types, - "nexum:host/chain": nexum_runtime::bindings::nexum::host::chain, - "nexum:host/messaging": nexum_runtime::bindings::nexum::host::messaging, - }, - additional_derives: [PartialEq], - }); -} - -pub use venue_adapter::VenueAdapter; - -/// The keeper-facing `videre:venue/client` import bound host-side. The -/// world imports the interface a module calls; the videre types it uses -/// are reused from the adapter bindings above via `with`, so the -/// `SubmitOutcome` and `VenueError` the registry hands back to a module -/// are the very ones an adapter's `submit` produced. Async, because the -/// `Host` impl awaits the per-adapter mutex and the adapter's own async -/// guest calls. -mod client_host { - wasmtime::component::bindgen!({ - inline: " - package videre:client-host; - world client-host { - import videre:venue/client@0.1.0; - } - ", - path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/deps/nexum-host", - "../../wit/videre-venue", - ], - imports: { default: async }, - with: { - "videre:value-flow/types": super::venue_adapter::videre::value_flow::types, - "videre:types/types": super::venue_adapter::videre::types::types, - }, - }); -} - -/// The host-bound client interface: the `Host` trait the registry implements -/// and the `add_to_linker` the videre extension's linker hook calls. -pub use client_host::videre::venue::client; -/// The shared intent ontology, re-exported at the plain spellings the -/// registry and the `client::Host` impl name. -pub use venue_adapter::videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, - UnsignedTx, VenueError, -}; -/// The value-flow vocabulary the header is expressed in. -pub use venue_adapter::videre::value_flow::types as value_flow; - -/// Operator-log rendering of the wire `venue-error`: the bindgen -/// `Display` is the `{0:?}` debug form, so logs render through this -/// instead and the rate-limit `retry-after-ms` hint survives. -pub(crate) fn venue_error_message(err: &VenueError) -> std::borrow::Cow<'_, str> { - use std::borrow::Cow; - match err { - VenueError::UnknownVenue => Cow::Borrowed("unknown venue"), - VenueError::InvalidBody(detail) => Cow::Owned(format!("invalid body: {detail}")), - VenueError::Unsupported => Cow::Borrowed("unsupported"), - VenueError::Denied(detail) => Cow::Owned(format!("denied: {detail}")), - VenueError::RateLimited(rate_limit) => match rate_limit.retry_after_ms { - Some(ms) => Cow::Owned(format!("rate limited, retry after {ms} ms")), - None => Cow::Borrowed("rate limited"), - }, - VenueError::Unavailable(detail) => Cow::Owned(format!("unavailable: {detail}")), - VenueError::Timeout => Cow::Borrowed("timeout"), - } -} - -#[cfg(test)] -mod display_smoke { - use super::{RateLimit, VenueError, venue_error_message}; - - #[test] - fn venue_error_message_keeps_the_rate_limit_hint() { - let hinted = VenueError::RateLimited(RateLimit { - retry_after_ms: Some(250), - }); - assert_eq!( - venue_error_message(&hinted), - "rate limited, retry after 250 ms" - ); - let unhinted = VenueError::RateLimited(RateLimit { - retry_after_ms: None, - }); - assert_eq!(venue_error_message(&unhinted), "rate limited"); - } -} - -/// Bindgen smoke for the `videre:value-flow` types package, compiled under -/// test through a throwaway world that imports the interface. Its value is -/// the identifier-hygiene gate: the test names every generated type, -/// variant, and field by its plain Rust spelling, so a WIT id that collided -/// with a Rust keyword would surface as an `r#` escape and fail to compile -/// here rather than in a downstream binding. -#[cfg(test)] -mod value_flow_smoke { - wasmtime::component::bindgen!({ - inline: " - package videre:value-flow-smoke; - world smoke { - import videre:value-flow/types@0.1.0; - } - ", - path: ["../../wit/videre-value-flow"], - }); - - #[test] - fn identifiers_bind_unescaped() { - use videre::value_flow::types::{Asset, AssetAmount, Erc20}; - - let erc20 = Erc20 { - token: vec![0u8; 20], - }; - let _ = Asset::Native; - let asset = Asset::Erc20(erc20); - - let amount = AssetAmount { - asset, - amount: Vec::new(), - }; - assert!(amount.amount.is_empty()); - } -} - -/// Bindgen smoke for the `videre:types` and `videre:venue` packages, -/// mirroring the value-flow smoke above: a throwaway world imports the -/// client interface and, transitively, the types interface and its -/// value-flow dependency. The test names every generated type, case, and -/// field by its plain Rust spelling, and a dummy `client` host impl pins -/// the five function signatures, so a keyword collision or an accidental -/// signature change fails this build rather than a downstream binding. -#[cfg(test)] -mod client_smoke { - wasmtime::component::bindgen!({ - inline: " - package videre:client-smoke; - world smoke { - import videre:venue/client@0.1.0; - } - ", - path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/deps/nexum-host", - "../../wit/videre-venue", - ], - }); - - use videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, - UnsignedTx, VenueError, - }; - use videre::value_flow::types::{Asset, AssetAmount}; - - struct DummyClient; - - impl videre::venue::client::Host for DummyClient { - fn quote(&mut self, _venue: String, _body: Vec) -> Result { - Err(VenueError::UnknownVenue) - } - - fn submit(&mut self, _venue: String, _body: Vec) -> Result { - Err(VenueError::UnknownVenue) - } - - fn observe(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { - Err(VenueError::UnknownVenue) - } - - fn status( - &mut self, - _venue: String, - _receipt: Vec, - ) -> Result { - Err(VenueError::UnknownVenue) - } - - fn cancel(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { - Err(VenueError::UnknownVenue) - } - } - - fn amount(bytes: Vec) -> AssetAmount { - AssetAmount { - asset: Asset::Native, - amount: bytes, - } - } - - #[test] - fn identifiers_bind_unescaped() { - use videre::venue::client::Host; - - let _ = AuthScheme::Eip1271; - let _ = AuthScheme::Eip712; - - let header = IntentHeader { - gives: amount(vec![1]), - wants: amount(Vec::new()), - settlement: Settlement { chain: 1 }, - authorisation: AuthScheme::Eip712, - }; - assert!(header.wants.amount.is_empty()); - - let _ = IntentStatus::Pending; - let _ = IntentStatus::Open; - let _ = IntentStatus::Fulfilled; - let _ = IntentStatus::Cancelled; - let _ = IntentStatus::Expired; - - let tx = UnsignedTx { - chain: 1, - to: Vec::new(), - value: Vec::new(), - data: Vec::new(), - }; - let _ = SubmitOutcome::Accepted(Vec::new()); - let _ = SubmitOutcome::RequiresSigning(tx); - - let quotation = Quotation { - gives: amount(vec![1]), - wants: amount(Vec::new()), - fee: amount(Vec::new()), - valid_until_ms: 0, - }; - assert!(quotation.fee.amount.is_empty()); - - let _ = VenueError::UnknownVenue; - let _ = VenueError::InvalidBody(String::new()); - let _ = VenueError::Unsupported; - let _ = VenueError::Denied(String::new()); - let _ = VenueError::RateLimited(RateLimit { - retry_after_ms: Some(250), - }); - let _ = VenueError::Unavailable(String::new()); - let _ = VenueError::Timeout; - - let mut client = DummyClient; - assert!(client.quote(String::new(), Vec::new()).is_err()); - assert!(client.submit(String::new(), Vec::new()).is_err()); - assert!(client.observe(String::new(), Vec::new()).is_err()); - assert!(client.status(String::new(), Vec::new()).is_err()); - assert!(client.cancel(String::new(), Vec::new()).is_err()); - } -} diff --git a/videre/crates/videre-host/src/client.rs b/videre/crates/videre-host/src/client.rs deleted file mode 100644 index cbe27e70..00000000 --- a/videre/crates/videre-host/src/client.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! `videre:venue/client`: the keeper-facing venue import. Every method -//! resolves the shared [`VenueRegistry`] from the store's service map under -//! the videre namespace and delegates; the registry owns the venue -//! resolution, per-adapter serialisation, guard seam (advisory-only for -//! now), and quota. The caller identity the registry meters against is this -//! store's module namespace. No registry service means no venues, so every -//! call resolves to `unknown-venue`. The `Host` trait is local to this -//! crate's bindgen, so implementing it for the runtime's `HostState` is -//! orphan-legal. - -use std::sync::Arc; - -use nexum_runtime::host::component::RuntimeTypes; -use nexum_runtime::host::state::HostState; - -use crate::bindings::client::Host; -use crate::bindings::{IntentStatus, Quotation, SubmitOutcome, VenueError}; -use crate::registry::{VenueId, VenueRegistry}; - -/// The registry published under the videre service namespace. -fn registry(state: &HostState) -> Result, VenueError> { - state - .services - .get::(VenueRegistry::NAMESPACE) - .ok_or(VenueError::UnknownVenue) -} - -impl Host for HostState { - async fn quote(&mut self, venue: String, body: Vec) -> Result { - registry(self)? - .quote(&self.run.module, &VenueId::from(venue), body) - .await - } - - async fn submit(&mut self, venue: String, body: Vec) -> Result { - registry(self)? - .submit(&self.run.module, &VenueId::from(venue), body) - .await - } - - async fn observe(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { - registry(self)?.observe(&VenueId::from(venue), receipt) - } - - async fn status( - &mut self, - venue: String, - receipt: Vec, - ) -> Result { - registry(self)?.status(&VenueId::from(venue), receipt).await - } - - async fn cancel(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { - registry(self)?.cancel(&VenueId::from(venue), receipt).await - } -} diff --git a/videre/crates/videre-host/src/handshake.rs b/videre/crates/videre-host/src/handshake.rs deleted file mode 100644 index a1aa23d7..00000000 --- a/videre/crates/videre-host/src/handshake.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! Install-time body-version handshake over the `[venue]` manifest -//! section: a keeper declares the one body-schema version it encodes, -//! an adapter the set it decodes, and a keeper boots only when every -//! installed venue adapter decodes its version, since any of them is a -//! legal runtime submit target. - -use std::collections::BTreeSet; - -use anyhow::{anyhow, bail}; -use nexum_runtime::host::extension::ProviderManifest; -use nexum_runtime::manifest::ExtensionSections; -use serde::Deserialize; -use tracing::error; - -use crate::registry::VenueAdapterKind; - -/// The manifest section the videre platform claims. -pub(crate) const SECTION: &str = "venue"; - -/// The claimed-section list handed to the runtime. -pub(crate) const SECTIONS: &[&str] = &[SECTION]; - -/// Keeper-side `[venue]`: the one body-schema version it encodes. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct KeeperSection { - body_version: u32, -} - -/// Adapter-side `[venue]`: the body-schema versions it decodes. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct AdapterSection { - body_versions: BTreeSet, -} - -/// Parse one `[venue]` value as `S`, tagging failures with the owner. -fn parse Deserialize<'de>>(owner: &str, value: &toml::Value) -> anyhow::Result { - value - .clone() - .try_into() - .map_err(|e| anyhow!("{owner} [venue]: {e}")) -} - -/// Admit one provider: a `[venue]` section, when present, must be the -/// adapter shape with a non-empty version set. -pub(crate) fn admit_provider(provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { - let Some(value) = sections.get(SECTION) else { - return Ok(()); - }; - let section: AdapterSection = parse(provider, value)?; - if section.body_versions.is_empty() { - bail!("{provider} [venue]: body_versions must not be empty"); - } - Ok(()) -} - -/// An adapter's declared decode set: its `[venue] body_versions`, empty -/// when the section is absent. -pub(crate) fn declared_versions( - provider: &str, - sections: &ExtensionSections, -) -> anyhow::Result> { - let Some(value) = sections.get(SECTION) else { - return Ok(BTreeSet::new()); - }; - let section: AdapterSection = parse(provider, value)?; - Ok(section.body_versions) -} - -/// Assert an adapter's `body-versions()` export equals its manifest -/// claim, refusing the install on divergence so the two sources of the -/// decode set cannot drift. -pub(crate) fn verify_exported_versions( - provider: &str, - declared: &BTreeSet, - exported: Vec, -) -> anyhow::Result<()> { - let exported: BTreeSet = exported.into_iter().collect(); - if exported != *declared { - bail!( - "{provider} exports body versions {exported:?}; the manifest [venue] \ - body_versions declares {declared:?}" - ); - } - Ok(()) -} - -/// The membership install predicate: a worker declaring `[venue] -/// body_version` is admitted only when every installed venue adapter's -/// `[venue] body_versions` set contains that version. Every installed -/// venue is a legal runtime submit target, so one non-decoding adapter -/// refuses the keeper. -pub(crate) fn admit_worker( - worker: &str, - sections: &ExtensionSections, - providers: &[ProviderManifest], -) -> anyhow::Result<()> { - let Some(value) = sections.get(SECTION) else { - return Ok(()); - }; - let KeeperSection { body_version } = parse(worker, value)?; - let adapters: Vec<&ProviderManifest> = providers - .iter() - .filter(|p| p.kind == VenueAdapterKind::KIND) - .collect(); - if adapters.is_empty() { - return Err(refuse( - worker, - body_version, - "no venue adapter declares [venue] body_versions", - )); - } - for provider in adapters { - let Some(value) = provider.sections.get(SECTION) else { - return Err(refuse( - worker, - body_version, - &format!("{} declares no [venue] body_versions", provider.name), - )); - }; - let section: AdapterSection = parse(&provider.name, value)?; - if !section.body_versions.contains(&body_version) { - return Err(refuse( - worker, - body_version, - &format!("{} decodes {:?}", provider.name, section.body_versions), - )); - } - } - Ok(()) -} - -/// Log and build the refusal for one keeper/adapter pairing. -fn refuse(worker: &str, body_version: u32, decoded: &str) -> anyhow::Error { - error!( - keeper = %worker, - body_version, - %decoded, - "body-version handshake refused the keeper/adapter pair", - ); - anyhow!("keeper {worker} encodes body version {body_version}; {decoded}") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sections(toml: &str) -> ExtensionSections { - let table: toml::Table = toml.parse().expect("parse"); - table.into_iter().collect() - } - - fn adapter(name: &str, toml: &str) -> ProviderManifest { - ProviderManifest { - name: name.to_owned(), - kind: VenueAdapterKind::KIND, - sections: sections(toml), - } - } - - /// A keeper whose version every installed adapter decodes is admitted. - #[test] - fn matching_pair_is_admitted() { - let keeper = sections("[venue]\nbody_version = 2"); - let adapters = [ - adapter("cow", "[venue]\nbody_versions = [1, 2]"), - adapter("uni", "[venue]\nbody_versions = [2, 3]"), - ]; - admit_worker("keeper", &keeper, &adapters).expect("admitted"); - } - - /// A keeper whose version no adapter decodes is refused, and the - /// refusal names the version and the declared set. - #[test] - fn mismatched_pair_is_refused() { - let keeper = sections("[venue]\nbody_version = 2"); - let adapters = [adapter("cow", "[venue]\nbody_versions = [1]")]; - let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); - let msg = err.to_string(); - assert!(msg.contains("body version 2"), "{msg}"); - assert!(msg.contains("cow decodes {1}"), "{msg}"); - } - - /// Membership is a conjunction over the installed adapters: one - /// non-decoding adapter refuses the keeper even when another decodes - /// its version, since either is a legal runtime submit target. - #[test] - fn one_non_decoding_adapter_refuses_the_keeper() { - let keeper = sections("[venue]\nbody_version = 2"); - let adapters = [ - adapter("cow", "[venue]\nbody_versions = [1, 2]"), - adapter("uni", "[venue]\nbody_versions = [1]"), - ]; - let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); - let msg = err.to_string(); - assert!(msg.contains("body version 2"), "{msg}"); - assert!(msg.contains("uni decodes {1}"), "{msg}"); - } - - /// A declaring keeper with no installed venue adapter is refused. - #[test] - fn undeclared_adapters_refuse_a_declaring_keeper() { - let keeper = sections("[venue]\nbody_version = 1"); - let err = admit_worker("keeper", &keeper, &[]).expect_err("refused"); - assert!(err.to_string().contains("no venue adapter declares")); - } - - /// An installed adapter without a `[venue]` section refuses a - /// declaring keeper: its decode set is undeclared, not universal. - #[test] - fn a_section_less_adapter_refuses_a_declaring_keeper() { - let keeper = sections("[venue]\nbody_version = 1"); - let adapters = [adapter("cow", "")]; - let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); - assert!(err.to_string().contains("cow declares no [venue]")); - } - - /// A provider of another kind never satisfies the membership check. - #[test] - fn other_provider_kinds_are_ignored() { - let keeper = sections("[venue]\nbody_version = 1"); - let mut other = adapter("oracle", "[venue]\nbody_versions = [1]"); - other.kind = "price-oracle"; - admit_worker("keeper", &keeper, &[other]).expect_err("refused"); - } - - /// Workers and providers without a `[venue]` section are admitted. - #[test] - fn undeclared_sections_are_admitted() { - admit_worker("keeper", &ExtensionSections::new(), &[]).expect("worker admitted"); - admit_provider("venue", &ExtensionSections::new()).expect("provider admitted"); - } - - /// The wrong-side spelling fails loudly on both faces. - #[test] - fn wrong_side_spelling_is_refused() { - let keeper = sections("[venue]\nbody_versions = [1]"); - admit_worker("keeper", &keeper, &[]).expect_err("keeper with the adapter key"); - - let venue = sections("[venue]\nbody_version = 1"); - admit_provider("venue", &venue).expect_err("adapter with the keeper key"); - } - - /// An adapter declaring an empty decode set is refused at install. - #[test] - fn empty_adapter_set_is_refused() { - let venue = sections("[venue]\nbody_versions = []"); - let err = admit_provider("venue", &venue).expect_err("refused"); - assert!(err.to_string().contains("must not be empty")); - } - - /// A well-formed adapter declaration is admitted. - #[test] - fn adapter_declaration_is_admitted() { - let venue = sections("[venue]\nbody_versions = [1, 2]"); - admit_provider("venue", &venue).expect("admitted"); - } - - /// The exported set must equal the manifest claim exactly; either - /// direction of drift refuses the install. - #[test] - fn exported_versions_must_equal_the_manifest_claim() { - let declared = declared_versions("venue", §ions("[venue]\nbody_versions = [1, 2]")) - .expect("declared"); - verify_exported_versions("venue", &declared, vec![2, 1]).expect("equal sets"); - - let err = verify_exported_versions("venue", &declared, vec![1]).expect_err("narrower"); - assert!( - err.to_string().contains("exports body versions {1}"), - "{err}" - ); - verify_exported_versions("venue", &declared, vec![1, 2, 3]).expect_err("wider"); - } - - /// A section-less adapter must export an empty set: an undeclared - /// manifest with a declaring export is drift, not a default. - #[test] - fn a_section_less_adapter_must_export_no_versions() { - let declared = declared_versions("venue", &ExtensionSections::new()).expect("declared"); - assert!(declared.is_empty()); - verify_exported_versions("venue", &declared, Vec::new()).expect("both undeclared"); - verify_exported_versions("venue", &declared, vec![1]).expect_err("export-only drift"); - } -} diff --git a/videre/crates/videre-host/src/lib.rs b/videre/crates/videre-host/src/lib.rs deleted file mode 100644 index d228e27a..00000000 --- a/videre/crates/videre-host/src/lib.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! The videre venue platform, packaged as one [`nexum_runtime`] -//! extension: the venue-adapter provider kind, the [`VenueRegistry`] -//! service, the advisory [`EgressGuard`] seam, and the keeper-facing -//! `videre:venue/client` interface. A composition root registers it all -//! with `builder.with_extensions([Arc::new(videre_host::platform(cfg))])`. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] - -pub mod bindings; -mod client; -mod handshake; -mod registry; - -use std::sync::Arc; -use std::time::Duration; - -use nexum_runtime::bindings::nexum::host::types::Event; -use nexum_runtime::engine_config::EngineConfig; -use nexum_runtime::host::component::RuntimeTypes; -use nexum_runtime::host::extension::{ - EventSources, Extension, ExtensionEvent, ExtensionEventStream, HostService, ProviderKind, - ProviderManifest, -}; -use nexum_runtime::host::state::HostState; -use nexum_runtime::manifest::{ExtensionSections, NamespaceCaps}; -use tokio::sync::mpsc; -use wasmtime::component::{HasSelf, Linker}; - -pub use registry::{ - DuplicateVenue, EgressGuard, GuardContext, GuardVerdict, IntentStatusUpdate, VenueActor, - VenueAdapterKind, VenueId, VenueInvoker, VenueRegistry, VenueRegistryBuilder, -}; - -/// The subscription kind the platform's status poller emits. -const INTENT_STATUS_KIND: &str = "intent-status"; - -/// Buffer for the status poll channel; small because the event loop -/// drains in real time. -const STATUS_CHANNEL_BUF: usize = 64; - -/// The venue platform over the config-resolved quota and watch policy, -/// with the unit guard. The single registration entrypoint. -pub fn platform(config: &EngineConfig) -> Videre { - Videre::from_registry( - VenueRegistryBuilder::new(config.limits.quota()) - .with_watch_limit(config.limits.watch()) - .build(), - ) -} - -/// The videre platform as one runtime extension. Registers the -/// `videre:venue/client` interface and its capability namespace on every -/// worker linker, publishes the [`VenueRegistry`] service, installs the -/// venue-adapter provider kind, and opens the status-poll event source. -pub struct Videre { - registry: Arc, -} - -impl Videre { - /// Assemble over a pre-built registry, for a custom [`EgressGuard`] - /// or policy; [`platform`] covers the config-resolved default. - pub fn from_registry(registry: VenueRegistry) -> Self { - Self { - registry: Arc::new(registry), - } - } -} - -impl Extension for Videre { - fn namespace(&self) -> &'static str { - VenueRegistry::NAMESPACE - } - - /// Only the keeper-facing `client` interface is a capability; the - /// `videre:types` and `videre:value-flow` packages are type-only and - /// need no declaration. - fn capabilities(&self) -> NamespaceCaps { - NamespaceCaps { - prefix: "videre:venue/", - ifaces: &["client"], - } - } - - fn link(&self, linker: &mut Linker>) -> anyhow::Result<()> { - bindings::client::add_to_linker::, HasSelf>>(linker, |state| { - state - })?; - Ok(()) - } - - fn service(&self) -> Option> { - Some(Arc::clone(&self.registry) as Arc) - } - - fn provider(&self) -> Option>> { - Some(Box::new(VenueAdapterKind)) - } - - fn manifest_sections(&self) -> &'static [&'static str] { - handshake::SECTIONS - } - - /// Adapter-side handshake shape check: a `[venue]` section must - /// declare a non-empty `body_versions` set. - fn admit_provider(&self, provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { - handshake::admit_provider(provider, sections) - } - - /// The body-version membership predicate: a keeper declaring - /// `[venue] body_version` boots only when every installed venue - /// adapter's `[venue] body_versions` set contains it. - fn admit_worker( - &self, - worker: &str, - sections: &ExtensionSections, - providers: &[ProviderManifest], - ) -> anyhow::Result<()> { - handshake::admit_worker(worker, sections, providers) - } - - fn subscriptions(&self) -> &'static [&'static str] { - &[INTENT_STATUS_KIND] - } - - /// The status poll source: on every cadence tick, poll each installed - /// adapter's status export through the shared registry and forward the - /// observed transitions. Opened only when a module subscribes and at - /// least one venue is installed. - fn events(&self, sources: &mut EventSources<'_>) -> anyhow::Result> { - if !sources.subscribed.contains(INTENT_STATUS_KIND) { - return Ok(Vec::new()); - } - let registry = (*self.registry).clone(); - if registry.venue_count() == 0 { - return Ok(Vec::new()); - } - let cadence = sources.config.limits.status_poll_interval(); - let (tx, rx) = mpsc::channel::(STATUS_CHANNEL_BUF); - sources.spawn(status_poll_task(registry, cadence, tx)); - let stream = futures::stream::unfold(rx, |mut rx| async move { - rx.recv().await.map(|item| (item, rx)) - }); - Ok(vec![Box::pin(stream)]) - } -} - -/// Poll loop behind [`Extension::events`]. Sleeps the cadence first so -/// the engine's boot dispatch settles before the first poll; ends when -/// the event loop drops its receiver. -async fn status_poll_task( - registry: VenueRegistry, - cadence: Duration, - tx: mpsc::Sender, -) { - loop { - tokio::time::sleep(cadence).await; - for update in registry.poll_status_transitions().await { - let event = ExtensionEvent { - kind: INTENT_STATUS_KIND, - attrs: vec![("venue", update.venue.clone())], - event: Event::IntentStatus(update), - }; - if tx.send(event).await.is_err() { - // Receiver dropped -> engine shutting down. - return; - } - } - } -} diff --git a/videre/crates/videre-host/src/registry.rs b/videre/crates/videre-host/src/registry.rs deleted file mode 100644 index 815205e6..00000000 --- a/videre/crates/videre-host/src/registry.rs +++ /dev/null @@ -1,1786 +0,0 @@ -//! The venue registry: the keeper-facing `videre:venue/client` import -//! resolved to installed venue adapters. -//! -//! A module's `client::submit(venue, body)` reaches the host here. The -//! registry resolves the venue id to the one installed adapter that answers -//! for it, then drives a fixed sequence against that adapter: derive the -//! header, run the guard interposition seam on it (advisory-only for now: -//! see [`EgressGuard`]), and only then submit. -//! Status and cancel are pass-throughs; they are not submissions, so they -//! skip the header, the guard, and the quota. Observe puts an -//! externally-obtained receipt under status watch without touching the -//! adapter at all. -//! -//! Invocation is serialised per adapter through the supervised-actor -//! primitive: each adapter sits behind its own [`ActorSlot`], so concurrent -//! client calls to the same venue queue while calls to different venues run -//! in parallel. The lock is held across the guest await, which is the whole -//! point - it is the actor boundary that keeps one adapter store -//! single-threaded. -//! -//! Fuel cannot cross stores, so a module that spams undecodable bodies would -//! otherwise burn an adapter's budget for free. Two mechanisms close that: -//! a per-caller quota gates every quote and submit before the adapter is -//! touched, and a decode failure (the adapter's `invalid-body`) is charged -//! to the calling module's quota, so a caller feeding garbage exhausts its -//! own budget rather than the adapter's. - -use std::collections::{HashMap, VecDeque}; -use std::fmt; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use anyhow::{Context, anyhow}; -use async_trait::async_trait; -use futures::future::BoxFuture; -use nexum_runtime::bindings::nexum; -use nexum_runtime::engine_config::{SubmitQuota, WatchLimit}; -use nexum_runtime::host::actor::{ActorFault, ActorSlot, Liveness, SupervisedStore}; -use nexum_runtime::host::component::RuntimeTypes; -use nexum_runtime::host::extension::{ - HostService, Installed, ProviderInstance, ProviderKind, downcast_service, -}; -use nexum_runtime::host::state::HostState; -use nexum_status_body::StatusBody; -use tokio::sync::Mutex as AsyncMutex; -use tracing::{info, warn}; -use wasmtime::Store; -use wasmtime::component::HasSelf; - -/// The registry-observed status transition delivered through the host -/// `event` variant, re-exported at the spelling the registry names. -pub use nexum_runtime::bindings::nexum::host::types::IntentStatusUpdate; - -use crate::bindings::{ - IntentHeader, IntentStatus, Quotation, RateLimit, SubmitOutcome, VenueAdapter, VenueError, -}; - -/// Venue identifier: the id an adapter registers under and a submission -/// names. Opaque beyond equality. -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -pub struct VenueId(String); - -impl VenueId { - /// The id at its wire spelling. - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl From for VenueId { - fn from(id: String) -> Self { - Self(id) - } -} - -impl From<&str> for VenueId { - fn from(id: &str) -> Self { - Self(id.to_owned()) - } -} - -impl fmt::Display for VenueId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -/// The guard interposition seam. The registry runs this on the -/// adapter-derived header after `derive-header` and before `submit`. -/// -/// Advisory-only: the checkpoint is not yet enforcing. A `Deny` verdict is -/// logged as a would-deny and the submission proceeds. The shipped policy is -/// the unit guard, which allows every egress; the egress-guard epic installs -/// the real facts-plus-analysers pipeline and turns the verdict enforcing, -/// without the registry changing shape. -pub trait EgressGuard: Send + Sync { - /// Decide whether the derived header may proceed to the adapter's submit. - fn check(&self, ctx: &GuardContext<'_>) -> GuardVerdict; -} - -/// The unit guard: allow every egress. -impl EgressGuard for () { - fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { - GuardVerdict::Allow - } -} - -/// What the guard sees: who is submitting, to which venue, and the header the -/// adapter derived from the opaque body. The header is the stable ontology -/// policy has teeth on; the raw body never reaches the guard. -pub struct GuardContext<'a> { - /// Namespace of the calling module. - pub caller: &'a str, - /// Venue the submission is routed to. - pub venue: &'a VenueId, - /// Adapter-derived header for the body. - pub header: &'a IntentHeader, -} - -/// The guard's decision on one egress. -pub enum GuardVerdict { - /// Forward the submission to the adapter. - Allow, - /// Refuse the egress with an operator-facing reason. Logged, not - /// enforced, while the seam is advisory-only. - Deny(String), -} - -/// The per-adapter invocation seam. One installed adapter answers for exactly -/// one venue; the registry owns the adapter's `Store` behind an async mutex -/// and reaches it only through this trait, so the registry's sequencing and -/// quota logic is testable against a stub that never spins up a wasmtime -/// store. -/// -/// The futures are boxed so the registry can hold heterogeneous adapters -/// behind one `dyn` slot without the whole registry turning generic over an -/// adapter type it never names. -pub trait VenueInvoker: Send { - /// Project the opaque body onto the stable header the guard runs on. - fn derive_header<'a>( - &'a mut self, - body: &'a [u8], - ) -> BoxFuture<'a, Result>; - - /// Price the opaque body at this adapter's venue. - fn quote<'a>(&'a mut self, body: &'a [u8]) -> BoxFuture<'a, Result>; - - /// Submit the opaque body to this adapter's venue. - fn submit<'a>(&'a mut self, body: &'a [u8]) - -> BoxFuture<'a, Result>; - - /// Report where a previously submitted intent is in its life. The receipt - /// is owned: it is used once, unlike the body a submission re-decodes. - fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result>; - - /// Ask the venue to withdraw an intent. - fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>>; -} - -/// The live adapter: a [`SupervisedStore`] plus the `venue-adapter` -/// bindings. Each guest call is refuelled by the primitive; a trap is -/// projected onto `unavailable` rather than propagated, because a -/// misbehaving adapter must not be the caller's fault and must not unwind -/// through the registry into the calling module's store. -pub struct VenueActor { - actor: SupervisedStore, - bindings: VenueAdapter, -} - -impl VenueActor { - /// Wrap an instantiated adapter store for routing, reporting traps on - /// the shared `liveness`. - pub fn new( - store: Store>, - bindings: VenueAdapter, - fuel_per_call: u64, - liveness: Liveness, - ) -> Self { - Self { - actor: SupervisedStore::new(store, fuel_per_call, liveness), - bindings, - } - } -} - -/// Project an actor fault into the venue-error space. The fault carries -/// the root cause only, so an operator sees why the adapter died without -/// the wasm frame list leaking to the calling module. -fn venue_fault(fault: ActorFault) -> VenueError { - VenueError::Unavailable(format!("adapter {fault}")) -} - -impl VenueInvoker for VenueActor { - fn derive_header<'a>( - &'a mut self, - body: &'a [u8], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - let adapter = self.bindings.videre_venue_adapter(); - self.actor - .call(async |store| adapter.call_derive_header(store, body).await) - .await - .map_err(venue_fault)? - }) - } - - fn quote<'a>(&'a mut self, body: &'a [u8]) -> BoxFuture<'a, Result> { - Box::pin(async move { - let adapter = self.bindings.videre_venue_adapter(); - self.actor - .call(async |store| adapter.call_quote(store, body).await) - .await - .map_err(venue_fault)? - }) - } - - fn submit<'a>( - &'a mut self, - body: &'a [u8], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - let adapter = self.bindings.videre_venue_adapter(); - self.actor - .call(async |store| adapter.call_submit(store, body).await) - .await - .map_err(venue_fault)? - }) - } - - fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result> { - Box::pin(async move { - let adapter = self.bindings.videre_venue_adapter(); - self.actor - .call(async |store| adapter.call_status(store, &receipt).await) - .await - .map_err(venue_fault)? - }) - } - - fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { - Box::pin(async move { - let adapter = self.bindings.videre_venue_adapter(); - self.actor - .call(async |store| adapter.call_cancel(store, &receipt).await) - .await - .map_err(venue_fault)? - }) - } -} - -/// One installed adapter behind its serialising slot. -type AdapterSlot = ActorSlot; - -/// One installed venue: the adapter slot plus the liveness the supervisor's -/// sweep shares with the actor. A dead entry stays installed, so the venue -/// resolves to `unavailable` (temporarily dead) rather than `unknown-venue` -/// (never installed) until the sweep restarts it. -struct InstalledVenue { - slot: AdapterSlot, - liveness: Liveness, -} - -/// Per-caller charge history, pruned to the quota window on each touch. -#[derive(Default)] -struct QuotaLedger { - per_caller: HashMap>, -} - -/// One receipt the registry polls for status transitions. `last` starts -/// `None` so the first successful poll always reports, giving a -/// subscriber the intent's current state without waiting for a change. -/// `expires_at` is the eviction deadline, pushed a full window out on -/// every successful non-terminal poll; `None` (deadline arithmetic -/// overflowed) never expires. -struct WatchedIntent { - venue: VenueId, - receipt: Vec, - last: Option, - expires_at: Option, -} - -/// A polled status is terminal when the intent can never change again: -/// the registry stops watching the receipt after reporting it. -fn is_terminal(status: IntentStatus) -> bool { - matches!( - status, - IntentStatus::Fulfilled | IntentStatus::Cancelled | IntentStatus::Expired - ) -} - -/// Lower a polled status onto the opaque status body the host `event` -/// stream carries. The registry attests the lifecycle state alone; proof -/// and failure reason ride the body only when the venue supplies them. -fn status_body(status: IntentStatus) -> StatusBody { - use nexum_status_body::IntentStatus as Lifecycle; - - let status = match status { - IntentStatus::Pending => Lifecycle::Pending, - IntentStatus::Open => Lifecycle::Open, - IntentStatus::Fulfilled => Lifecycle::Fulfilled, - IntentStatus::Cancelled => Lifecycle::Cancelled, - IntentStatus::Expired => Lifecycle::Expired, - }; - StatusBody { - status, - proof: None, - reason: None, - } -} - -/// The shared registry state. Cloning a [`VenueRegistry`] is an `Arc` bump; -/// every module store carries the same handle, so a submission from any -/// module reaches the same adapters and the same quota ledger. Adapters -/// install through the shared handle at provider boot, before any client -/// call routes. -struct VenueRegistryInner { - adapters: Mutex>, - guard: Arc, - quota: SubmitQuota, - ledger: Mutex, - watch_limit: WatchLimit, - /// Receipts under status watch, appended by accepted submissions and - /// explicit observes, pruned as they reach a terminal status, expire, - /// or overflow [`WatchLimit`]. - watched: Mutex>, -} - -/// The keeper-facing venue registry, cheap to clone and shared across every -/// module store. -#[derive(Clone)] -pub struct VenueRegistry { - inner: Arc, -} - -/// The registry is the venue-routing host service. -impl HostService for VenueRegistry {} - -impl VenueRegistry { - /// Service namespace the registry publishes under: the videre - /// extension's. - pub const NAMESPACE: &'static str = "videre"; - - /// Install an adapter under its venue id, sharing `liveness` with its - /// invoker. Rejects a duplicate id while the incumbent is alive: two - /// adapters answering the same venue would silently shadow one another, - /// which is a config error worth failing boot over. A dead incumbent is - /// replaced: that is the sweep restarting a trapped adapter. - pub fn install( - &self, - venue: VenueId, - liveness: Liveness, - invoker: impl VenueInvoker + 'static, - ) -> Result<(), DuplicateVenue> { - let mut adapters = self.inner.adapters.lock().expect("adapter map poisoned"); - if adapters.get(&venue).is_some_and(|v| v.liveness.is_alive()) { - return Err(DuplicateVenue { venue }); - } - adapters.insert( - venue, - InstalledVenue { - slot: Arc::new(AsyncMutex::new(invoker)), - liveness, - }, - ); - Ok(()) - } - - /// Resolve a venue id to its installed adapter slot. An uninstalled - /// venue is `unknown-venue`; an installed but dead one is `unavailable` - /// pending the supervisor's restart sweep, without touching its - /// poisoned store. - fn resolve(&self, venue: &VenueId) -> Result { - let adapters = self.inner.adapters.lock().expect("adapter map poisoned"); - let installed = adapters.get(venue).ok_or(VenueError::UnknownVenue)?; - if !installed.liveness.is_alive() { - return Err(VenueError::Unavailable(format!( - "venue {venue} is dead pending restart" - ))); - } - Ok(Arc::clone(&installed.slot)) - } - - /// Whether `caller` has budget left in the current window. Read-only: it - /// prunes aged charges but does not record one. - fn quota_admits(&self, caller: &str) -> bool { - let mut ledger = self.inner.ledger.lock().expect("quota ledger poisoned"); - let history = ledger.per_caller.entry(caller.to_owned()).or_default(); - prune(history, self.inner.quota.window); - (history.len() as u32) < self.inner.quota.max_charges - } - - /// Record one charge against `caller`'s budget. - fn charge(&self, caller: &str) { - let mut ledger = self.inner.ledger.lock().expect("quota ledger poisoned"); - let history = ledger.per_caller.entry(caller.to_owned()).or_default(); - prune(history, self.inner.quota.window); - history.push_back(Instant::now()); - } - - /// Submit an opaque body to `venue` on behalf of `caller`: resolve the - /// adapter, gate on the caller's quota, derive the header, run the guard - /// seam (advisory-only: a deny logs and the submission proceeds), then - /// forward to the adapter. A decode failure is charged to the - /// caller before returning, so a caller feeding garbage exhausts its own - /// budget and is stopped at the gate on the next call rather than - /// re-invoking the adapter. - /// - /// Charged once the header derives, ahead of the guard and adapter, so - /// a deny (when enforcing) or a venue outage is never a free retry. - /// Derive-stage venue errors other than a decode failure are left - /// uncharged and retryable. - pub async fn submit( - &self, - caller: &str, - venue: &VenueId, - body: Vec, - ) -> Result { - let slot = self.resolve(venue)?; - // Gate before touching the adapter so a quota-exhausted caller never - // reaches the adapter store or its mutex. Exhaustion is retryable - // once the window slides, so it is rate-limited, never denied. - if !self.quota_admits(caller) { - return Err(VenueError::RateLimited(RateLimit { - retry_after_ms: Some(window_ms(self.inner.quota.window)), - })); - } - let mut adapter = slot.lock().await; - let header = match adapter.derive_header(&body).await { - Ok(header) => header, - Err(e) => { - // Charge decode failures to the caller before the adapter is - // invoked again; other venue errors are not the caller's fault. - if matches!(e, VenueError::InvalidBody(_)) { - self.charge(caller); - } - return Err(e); - } - }; - let ctx = GuardContext { - caller, - venue, - header: &header, - }; - // Charge before the guard so an enforcing deny stays non-free. - self.charge(caller); - // Advisory-only checkpoint: a deny is logged, never enforced. - if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { - warn!( - caller, - venue = %venue, - reason, - "egress guard would deny - advisory-only, submission proceeds", - ); - } - let outcome = adapter.submit(&body).await?; - // An accepted receipt goes under status watch so subscribers see - // its transitions; requires-signing has no receipt to watch yet. - if let SubmitOutcome::Accepted(receipt) = &outcome { - self.watch(venue, receipt.clone()); - } - Ok(outcome) - } - - /// Price an opaque body at `venue` on behalf of `caller`. Not a - /// submission, so the header and guard are skipped (a quotation moves - /// no value), but it is adapter work on a caller-supplied body: the - /// caller's quota gates it and every quote spends one unit, so a - /// quote spammer exhausts its own budget, not the adapter's. - pub async fn quote( - &self, - caller: &str, - venue: &VenueId, - body: Vec, - ) -> Result { - let slot = self.resolve(venue)?; - if !self.quota_admits(caller) { - return Err(VenueError::RateLimited(RateLimit { - retry_after_ms: Some(window_ms(self.inner.quota.window)), - })); - } - self.charge(caller); - let mut adapter = slot.lock().await; - adapter.quote(&body).await - } - - /// Put an externally-obtained `(venue, receipt)` pair under status - /// watch: the `observe` half of the client face, for receipts the - /// pool never submitted (an accepted submit is watched implicitly). - /// Not a submission: no header, no guard, no quota; the watch cap - /// bounds it. Idempotent. - pub fn observe(&self, venue: &VenueId, receipt: Vec) -> Result<(), VenueError> { - let _ = self.resolve(venue)?; - if self.watch(venue, receipt) { - Ok(()) - } else { - Err(VenueError::Unavailable("status watch set full".to_owned())) - } - } - - /// Put a `(venue, receipt)` pair under status watch, reporting - /// whether it is watched. Idempotent: a re-submitted receipt keeps - /// its existing watch entry. Bounded: expired entries evict first, - /// and at the cap the new watch is refused and logged rather than - /// an existing live watch dropped. - fn watch(&self, venue: &VenueId, receipt: Vec) -> bool { - let (evicted, admitted) = { - let mut watched = self.inner.watched.lock().expect("watch list poisoned"); - let evicted = prune_expired(&mut watched); - if watched - .iter() - .any(|w| w.venue == *venue && w.receipt == receipt) - { - (evicted, true) - } else if watched.len() < self.inner.watch_limit.max_entries { - watched.push(WatchedIntent { - venue: venue.clone(), - receipt, - last: None, - expires_at: Instant::now().checked_add(self.inner.watch_limit.expiry), - }); - (evicted, true) - } else { - (evicted, false) - } - }; - if evicted > 0 { - warn!(evicted, "expired status watches evicted"); - } - if !admitted { - warn!( - venue = %venue, - "status watch set full - transitions for this receipt will not be reported", - ); - } - admitted - } - - /// Number of receipts currently under status watch. - pub fn watched_count(&self) -> usize { - self.inner - .watched - .lock() - .expect("watch list poisoned") - .len() - } - - /// Poll every watched receipt against its adapter's status export and - /// return the transitions: statuses that differ from the last one - /// reported for that receipt (the first successful poll always - /// reports). A terminal status is reported once and the receipt is - /// dropped from the watch; a failure leaves the entry untouched for - /// the next cadence. Expired entries are evicted unpolled and - /// unreported. - pub async fn poll_status_transitions(&self) -> Vec { - // Snapshot so the std mutex is never held across the guest await. - let (evicted, snapshot): (usize, Vec<(VenueId, Vec)>) = { - let mut watched = self.inner.watched.lock().expect("watch list poisoned"); - let evicted = prune_expired(&mut watched); - let snapshot = watched - .iter() - .map(|w| (w.venue.clone(), w.receipt.clone())) - .collect(); - (evicted, snapshot) - }; - if evicted > 0 { - warn!(evicted, "expired status watches evicted"); - } - let mut updates = Vec::new(); - for (venue, receipt) in snapshot { - // A dead venue fails to resolve; its watch stays for the - // cadence after the sweep restarts the adapter. - let Ok(slot) = self.resolve(&venue) else { - continue; - }; - let polled = { - let mut adapter = slot.lock().await; - adapter.status(receipt.clone()).await - }; - match polled { - Ok(status) => { - if let Some(update) = self.record_polled_status(&venue, &receipt, status) { - updates.push(update); - } - } - Err(err) => { - warn!( - venue = %venue, - error = %crate::bindings::venue_error_message(&err), - "status poll failed - retrying on the next cadence", - ); - } - } - } - updates - } - - /// Fold one polled status into the watch entry: `Some(update)` when it - /// differs from the last reported status, pruning the entry when the - /// status is terminal and refreshing its eviction deadline otherwise, - /// so expiry only fires on a venue that has gone silent. `None` also - /// covers an entry that disappeared while the poll was in flight, and - /// an update whose status body failed to encode (the entry is left - /// untouched for the next cadence). - fn record_polled_status( - &self, - venue: &VenueId, - receipt: &[u8], - status: IntentStatus, - ) -> Option { - let mut watched = self.inner.watched.lock().expect("watch list poisoned"); - let pos = watched - .iter() - .position(|w| w.venue == *venue && w.receipt == receipt)?; - let changed = watched[pos].last != Some(status); - let update = if changed { - match status_body(status).encode() { - Ok(body) => Some(IntentStatusUpdate { - venue: venue.as_str().to_owned(), - receipt: receipt.to_vec(), - status: body, - }), - Err(err) => { - warn!( - venue = %venue, - error = %err, - "status body failed to encode - retrying on the next cadence", - ); - return None; - } - } - } else { - None - }; - if is_terminal(status) { - watched.remove(pos); - } else { - watched[pos].last = Some(status); - watched[pos].expires_at = Instant::now().checked_add(self.inner.watch_limit.expiry); - } - update - } - - /// Report where a previously submitted intent is in its life. Not a - /// submission: no header, no guard, no quota, just the serialised call. - pub async fn status( - &self, - venue: &VenueId, - receipt: Vec, - ) -> Result { - let slot = self.resolve(venue)?; - let mut adapter = slot.lock().await; - adapter.status(receipt).await - } - - /// Ask the venue to withdraw an intent. Not a submission, so it skips the - /// header, guard, and quota like `status`. - pub async fn cancel(&self, venue: &VenueId, receipt: Vec) -> Result<(), VenueError> { - let slot = self.resolve(venue)?; - let mut adapter = slot.lock().await; - adapter.cancel(receipt).await - } - - /// Number of installed, routable adapters. - pub fn venue_count(&self) -> usize { - self.inner - .adapters - .lock() - .expect("adapter map poisoned") - .len() - } -} - -/// The venue-adapter provider kind: boots a `videre:venue/venue-adapter` -/// component and installs its actor in the venue registry. Registered -/// through the videre extension's provider slot. -pub struct VenueAdapterKind; - -impl VenueAdapterKind { - /// The manifest kind spelling. - pub const KIND: &'static str = "venue-adapter"; -} - -#[async_trait] -impl ProviderKind for VenueAdapterKind { - fn kind(&self) -> &'static str { - Self::KIND - } - - fn link(&self, linker: &mut wasmtime::component::Linker>) -> anyhow::Result<()> { - // The scoped transport only; the WASI base is the host's, and the - // withheld core interfaces fail instantiation. - nexum::host::chain::add_to_linker::, HasSelf>>(linker, |s| s)?; - nexum::host::messaging::add_to_linker::, HasSelf>>( - linker, - |s| s, - )?; - Ok(()) - } - - async fn install( - &self, - instance: ProviderInstance<'_, T>, - service: &Arc, - ) -> anyhow::Result { - let registry = downcast_service::(service) - .ok_or_else(|| anyhow!("the venue-adapter kind requires the venue-registry service"))?; - let ProviderInstance { - component, - linker, - mut store, - config, - sections, - fuel_per_call, - liveness, - } = instance; - let bindings = VenueAdapter::instantiate_async(&mut store, component, linker) - .await - .map_err(anyhow::Error::from) - .context("instantiate adapter")?; - // The venue id is the adapter's namespace: its manifest name. - let venue_id = VenueId::from(&*store.data().run.module); - // The manifest `[venue] body_versions` is the install-time - // authority the keeper handshake reads; the export must agree, - // so a manifest claiming versions the code does not decode never - // installs. - let declared = crate::handshake::declared_versions(venue_id.as_str(), sections)?; - let exported = bindings - .videre_venue_adapter() - .call_body_versions(&mut store) - .await - .map_err(anyhow::Error::from) - .context("read adapter body-versions")?; - crate::handshake::verify_exported_versions(venue_id.as_str(), &declared, exported)?; - match bindings - .call_init(&mut store, &config) - .await - .map_err(anyhow::Error::from)? - { - Ok(()) => info!(adapter = %venue_id, "adapter init succeeded"), - Err(e) => { - warn!( - adapter = %venue_id, - kind = nexum_runtime::host::error::fault_label(&e), - fault = %nexum_runtime::host::error::fault_message(&e), - "adapter init failed - loaded but marked dead", - ); - return Ok(Installed::Dead); - } - } - registry - .install( - venue_id.clone(), - liveness.clone(), - VenueActor::new(store, bindings, fuel_per_call, liveness), - ) - .with_context(|| format!("install adapter {venue_id}"))?; - Ok(Installed::Live) - } -} - -/// A quota window as whole milliseconds, saturating at `u64::MAX`. -fn window_ms(window: Duration) -> u64 { - u64::try_from(window.as_millis()).unwrap_or(u64::MAX) -} - -/// Drop watch entries whose eviction deadline has passed, returning how -/// many were evicted. -fn prune_expired(watched: &mut Vec) -> usize { - let now = Instant::now(); - let before = watched.len(); - watched.retain(|w| w.expires_at.is_none_or(|at| now < at)); - before - watched.len() -} - -/// Drop charge timestamps that have aged out of the window. -fn prune(history: &mut VecDeque, window: Duration) { - let now = Instant::now(); - while let Some(&front) = history.front() { - if now.duration_since(front) > window { - history.pop_front(); - } else { - break; - } - } -} - -/// Assembles a [`VenueRegistry`]'s policy: guard, quota, and watch bounds -/// freeze at build; adapters install afterwards through the shared handle -/// at provider boot. The guard defaults to the unit guard; the egress-guard -/// epic overrides it here. -pub struct VenueRegistryBuilder { - guard: Arc, - quota: SubmitQuota, - watch_limit: WatchLimit, -} - -impl VenueRegistryBuilder { - /// Start an empty builder with the given quota, the unit guard, and - /// the default watch limit. - pub fn new(quota: SubmitQuota) -> Self { - Self { - guard: Arc::new(()), - quota, - watch_limit: WatchLimit::default(), - } - } - - /// Override the guard policy. The egress-guard epic wires the real - /// pipeline through here; tests inject a denying policy to prove the - /// advisory seam. - pub fn with_guard(mut self, guard: Arc) -> Self { - self.guard = guard; - self - } - - /// Override the status-watch bounds. - pub fn with_watch_limit(mut self, watch_limit: WatchLimit) -> Self { - self.watch_limit = watch_limit; - self - } - - /// Freeze the builder into a shared registry. - pub fn build(self) -> VenueRegistry { - if self.quota.max_charges == 0 { - // A zero budget would refuse every submission; saturate up to one - // so a misconfigured quota still admits a single submission rather - // than bricking every venue. Mirrors the poison-policy clamp. - warn!("submission quota max_charges is 0; clamping to 1"); - } - let quota = SubmitQuota::new(self.quota.max_charges.max(1), self.quota.window); - if self.watch_limit.max_entries == 0 { - // A zero cap would refuse every watch; saturate up to one so a - // misconfigured bound still tracks a single receipt. - warn!("watch limit max_entries is 0; clamping to 1"); - } - let watch_limit = - WatchLimit::new(self.watch_limit.max_entries.max(1), self.watch_limit.expiry); - VenueRegistry { - inner: Arc::new(VenueRegistryInner { - adapters: Mutex::new(HashMap::new()), - guard: self.guard, - quota, - watch_limit, - ledger: Mutex::new(QuotaLedger::default()), - watched: Mutex::new(Vec::new()), - }), - } - } -} - -/// Two installed adapters claimed the same venue id. -#[derive(Debug, thiserror::Error)] -#[error("venue id {venue} is claimed by more than one installed adapter")] -pub struct DuplicateVenue { - /// The colliding venue id. - pub venue: VenueId, -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicUsize, Ordering}; - - use nexum_status_body::IntentStatus as Lifecycle; - - use crate::bindings::value_flow::{Asset, AssetAmount}; - use crate::bindings::{AuthScheme, IntentHeader, Settlement, UnsignedTx}; - - use super::*; - - /// The venue id every test installs its stub adapter under. - fn cow() -> VenueId { - VenueId::from("cow") - } - - /// Decode an update's opaque status body. - fn decoded(update: &IntentStatusUpdate) -> StatusBody { - StatusBody::decode(&update.status).expect("status body decodes") - } - - /// A body carrying a bare lifecycle state. - fn plain(status: Lifecycle) -> StatusBody { - StatusBody { - status, - proof: None, - reason: None, - } - } - - /// A programmable adapter that records call counts and returns canned - /// outcomes, so the registry's sequencing, guard seam, and quota are - /// tested without a wasmtime store. - #[derive(Default)] - struct StubCalls { - derive: AtomicUsize, - quote: AtomicUsize, - submit: AtomicUsize, - status: AtomicUsize, - cancel: AtomicUsize, - /// Highest number of overlapping invocations observed; proves the - /// per-adapter mutex serialises access. - max_concurrency: AtomicUsize, - live: AtomicUsize, - } - - struct StubAdapter { - calls: Arc, - derive: Result, - submit: Result, - /// Accept each submission with its body as the receipt, so one - /// stub can mint distinct receipts. - echo_receipt: bool, - /// Statuses served front-first by consecutive `status` calls; - /// once drained, every further call reports `open`. - status_script: VecDeque>, - } - - impl StubAdapter { - fn new(calls: Arc) -> Self { - Self { - calls, - derive: Ok(header()), - submit: Ok(SubmitOutcome::Accepted(b"receipt".to_vec())), - echo_receipt: false, - status_script: VecDeque::new(), - } - } - - fn with_receipt_echo(mut self) -> Self { - self.echo_receipt = true; - self - } - - fn with_derive(mut self, derive: Result) -> Self { - self.derive = derive; - self - } - - fn with_submit(mut self, submit: Result) -> Self { - self.submit = submit; - self - } - - fn with_status_script( - mut self, - script: impl IntoIterator>, - ) -> Self { - self.status_script = script.into_iter().collect(); - self - } - - async fn enter(&self) { - let live = self.calls.live.fetch_add(1, Ordering::SeqCst) + 1; - self.calls.max_concurrency.fetch_max(live, Ordering::SeqCst); - // Yield inside the critical section so any missing serialisation - // would let a second call observe `live == 2`. - tokio::task::yield_now().await; - self.calls.live.fetch_sub(1, Ordering::SeqCst); - } - } - - impl VenueInvoker for StubAdapter { - fn derive_header<'a>( - &'a mut self, - _body: &'a [u8], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - self.calls.derive.fetch_add(1, Ordering::SeqCst); - self.enter().await; - self.derive.clone() - }) - } - - fn quote<'a>( - &'a mut self, - _body: &'a [u8], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - self.calls.quote.fetch_add(1, Ordering::SeqCst); - self.enter().await; - Ok(quotation()) - }) - } - - fn submit<'a>( - &'a mut self, - body: &'a [u8], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - self.calls.submit.fetch_add(1, Ordering::SeqCst); - self.enter().await; - if self.echo_receipt { - return Ok(SubmitOutcome::Accepted(body.to_vec())); - } - self.submit.clone() - }) - } - - fn status(&mut self, _receipt: Vec) -> BoxFuture<'_, Result> { - Box::pin(async move { - self.calls.status.fetch_add(1, Ordering::SeqCst); - self.status_script - .pop_front() - .unwrap_or(Ok(IntentStatus::Open)) - }) - } - - fn cancel(&mut self, _receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { - Box::pin(async move { - self.calls.cancel.fetch_add(1, Ordering::SeqCst); - Ok(()) - }) - } - } - - /// A guard that refuses every egress with a fixed reason. - struct DenyGuard; - impl EgressGuard for DenyGuard { - fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { - GuardVerdict::Deny("blocked by test policy".to_owned()) - } - } - - fn quotation() -> Quotation { - Quotation { - gives: AssetAmount { - asset: Asset::Native, - amount: vec![1], - }, - wants: AssetAmount { - asset: Asset::Native, - amount: Vec::new(), - }, - fee: AssetAmount { - asset: Asset::Native, - amount: Vec::new(), - }, - valid_until_ms: 1_700_000_000_000, - } - } - - fn header() -> IntentHeader { - IntentHeader { - gives: AssetAmount { - asset: Asset::Native, - amount: vec![1], - }, - wants: AssetAmount { - asset: Asset::Native, - amount: Vec::new(), - }, - settlement: Settlement { chain: 1 }, - authorisation: AuthScheme::Eip712, - } - } - - fn registry_with( - quota: SubmitQuota, - guard: Option>, - adapter: StubAdapter, - ) -> VenueRegistry { - let mut builder = VenueRegistryBuilder::new(quota); - if let Some(guard) = guard { - builder = builder.with_guard(guard); - } - let registry = builder.build(); - registry - .install(cow(), Liveness::default(), adapter) - .expect("install adapter"); - registry - } - - #[tokio::test] - async fn submit_round_trips_through_derive_guard_submit() { - let calls = Arc::new(StubCalls::default()); - let registry = registry_with( - SubmitQuota::default(), - None, - StubAdapter::new(calls.clone()), - ); - - let outcome = registry - .submit("mod-a", &cow(), b"body".to_vec()) - .await - .expect("submit succeeds"); - - assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"receipt")); - assert_eq!(calls.derive.load(Ordering::SeqCst), 1); - assert_eq!(calls.submit.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn unknown_venue_is_rejected_without_touching_an_adapter() { - let calls = Arc::new(StubCalls::default()); - let registry = registry_with( - SubmitQuota::default(), - None, - StubAdapter::new(calls.clone()), - ); - - let err = registry - .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) - .await - .expect_err("unknown venue rejected"); - - assert!(matches!(err, VenueError::UnknownVenue)); - assert_eq!(calls.derive.load(Ordering::SeqCst), 0); - assert_eq!(calls.submit.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn guard_deny_is_advisory_and_does_not_block_submit() { - let calls = Arc::new(StubCalls::default()); - let registry = registry_with( - SubmitQuota::default(), - Some(Arc::new(DenyGuard)), - StubAdapter::new(calls.clone()), - ); - - let outcome = registry - .submit("mod-a", &cow(), b"body".to_vec()) - .await - .expect("advisory deny does not block"); - - // The seam runs on the derived header but only logs: derive ran and - // the submission still reached the adapter. - assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"receipt")); - assert_eq!(calls.derive.load(Ordering::SeqCst), 1); - assert_eq!(calls.submit.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn repeated_guard_denies_exhaust_the_caller_quota() { - let calls = Arc::new(StubCalls::default()); - let quota = SubmitQuota::new(2, Duration::from_secs(3600)); - let registry = registry_with( - quota, - Some(Arc::new(DenyGuard)), - StubAdapter::new(calls.clone()), - ); - - // Each denied submit spends exactly one unit: the second is still - // admitted, so a deny is never double-charged. - assert!( - registry - .submit("mod-a", &cow(), b"b".to_vec()) - .await - .is_ok() - ); - assert!( - registry - .submit("mod-a", &cow(), b"b".to_vec()) - .await - .is_ok() - ); - // The deny loop is rate-limited at the gate, not free. - assert!(matches!( - registry.submit("mod-a", &cow(), b"b".to_vec()).await, - Err(VenueError::RateLimited(_)) - )); - assert_eq!(calls.derive.load(Ordering::SeqCst), 2); - assert_eq!(calls.submit.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn quote_reaches_the_adapter_without_header_or_guard() { - let calls = Arc::new(StubCalls::default()); - // A denying guard proves quotes skip the seam: no value moves. - let registry = registry_with( - SubmitQuota::default(), - Some(Arc::new(DenyGuard)), - StubAdapter::new(calls.clone()), - ); - - let quoted = registry - .quote("mod-a", &cow(), b"body".to_vec()) - .await - .expect("quote succeeds"); - - assert_eq!(quoted, quotation()); - assert_eq!(calls.quote.load(Ordering::SeqCst), 1); - assert_eq!(calls.derive.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn quote_spends_the_caller_quota() { - let calls = Arc::new(StubCalls::default()); - let quota = SubmitQuota::new(1, Duration::from_secs(3600)); - let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); - - assert!(registry.quote("mod-a", &cow(), b"b".to_vec()).await.is_ok()); - // The quote spent the only unit: both a further quote and a - // submit are stopped at the gate. - assert!(matches!( - registry.quote("mod-a", &cow(), b"b".to_vec()).await, - Err(VenueError::RateLimited(_)) - )); - assert!(matches!( - registry.submit("mod-a", &cow(), b"b".to_vec()).await, - Err(VenueError::RateLimited(_)) - )); - assert_eq!(calls.quote.load(Ordering::SeqCst), 1); - assert_eq!(calls.submit.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn quote_to_an_unknown_venue_is_rejected() { - let calls = Arc::new(StubCalls::default()); - let registry = registry_with( - SubmitQuota::default(), - None, - StubAdapter::new(calls.clone()), - ); - - assert!(matches!( - registry - .quote("mod-a", &VenueId::from("unlisted"), b"b".to_vec()) - .await, - Err(VenueError::UnknownVenue) - )); - assert_eq!(calls.quote.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn submission_quota_rate_limits_once_the_budget_is_spent() { - let calls = Arc::new(StubCalls::default()); - let quota = SubmitQuota::new(2, Duration::from_secs(3600)); - let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); - - assert!( - registry - .submit("mod-a", &cow(), b"b".to_vec()) - .await - .is_ok() - ); - assert!( - registry - .submit("mod-a", &cow(), b"b".to_vec()) - .await - .is_ok() - ); - let err = registry - .submit("mod-a", &cow(), b"b".to_vec()) - .await - .expect_err("third submit over quota"); - - // Exhaustion is retryable once the window slides: rate-limited - // carrying the window, never denied. - assert!(matches!( - err, - VenueError::RateLimited(rl) if rl.retry_after_ms == Some(3_600_000) - )); - // The over-quota call is stopped at the gate, so the adapter saw only - // the two admitted submits. - assert_eq!(calls.submit.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn quota_is_per_caller() { - let calls = Arc::new(StubCalls::default()); - let quota = SubmitQuota::new(1, Duration::from_secs(3600)); - let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); - - assert!( - registry - .submit("mod-a", &cow(), b"b".to_vec()) - .await - .is_ok() - ); - assert!( - registry - .submit("mod-a", &cow(), b"b".to_vec()) - .await - .is_err(), - "mod-a is over its own budget" - ); - // A different caller has its own budget. - assert!( - registry - .submit("mod-b", &cow(), b"b".to_vec()) - .await - .is_ok(), - "mod-b has an independent budget" - ); - } - - #[tokio::test] - async fn decode_failures_are_charged_and_stop_re_invoking_the_adapter() { - let calls = Arc::new(StubCalls::default()); - let quota = SubmitQuota::new(1, Duration::from_secs(3600)); - let adapter = - StubAdapter::new(calls.clone()).with_derive(Err(VenueError::InvalidBody("bad".into()))); - let registry = registry_with(quota, None, adapter); - - // First garbage body: derive fails, the failure is charged. - let first = registry.submit("mod-a", &cow(), b"junk".to_vec()).await; - assert!(matches!(first, Err(VenueError::InvalidBody(_)))); - // Second: the charge from the decode failure exhausts the budget, so - // the caller is stopped at the gate and the adapter is not re-invoked. - let second = registry.submit("mod-a", &cow(), b"junk".to_vec()).await; - assert!(matches!(second, Err(VenueError::RateLimited(_)))); - assert_eq!( - calls.derive.load(Ordering::SeqCst), - 1, - "adapter derive-header was invoked exactly once", - ); - } - - #[tokio::test] - async fn non_decode_venue_errors_are_not_charged() { - let calls = Arc::new(StubCalls::default()); - let quota = SubmitQuota::new(1, Duration::from_secs(3600)); - let adapter = StubAdapter::new(calls.clone()) - .with_derive(Err(VenueError::Unavailable("rpc down".into()))); - let registry = registry_with(quota, None, adapter); - - assert!(matches!( - registry.submit("mod-a", &cow(), b"b".to_vec()).await, - Err(VenueError::Unavailable(_)) - )); - // A venue-side failure did not spend the caller's budget: it may try - // again, so derive is reached a second time. - assert!(matches!( - registry.submit("mod-a", &cow(), b"b".to_vec()).await, - Err(VenueError::Unavailable(_)) - )); - assert_eq!(calls.derive.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn status_and_cancel_pass_through_without_quota() { - let calls = Arc::new(StubCalls::default()); - // A spent budget must not block reads: status and cancel are not - // submissions. - let quota = SubmitQuota::new(1, Duration::from_secs(3600)); - let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); - - assert!(matches!( - registry.status(&cow(), b"r".to_vec()).await, - Ok(IntentStatus::Open) - )); - assert!(registry.cancel(&cow(), b"r".to_vec()).await.is_ok()); - assert_eq!(calls.status.load(Ordering::SeqCst), 1); - assert_eq!(calls.cancel.load(Ordering::SeqCst), 1); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn concurrent_calls_to_one_adapter_are_serialised() { - let calls = Arc::new(StubCalls::default()); - let quota = SubmitQuota::new(1000, Duration::from_secs(3600)); - let registry = registry_with(quota, None, StubAdapter::new(calls.clone())); - - let mut handles = Vec::new(); - for _ in 0..8 { - let registry = registry.clone(); - handles.push(tokio::spawn(async move { - let _ = registry.submit("mod-a", &cow(), b"b".to_vec()).await; - })); - } - for h in handles { - h.await.expect("task joins"); - } - // The adapter mutex is held across the guest await, so no two calls - // ever overlapped inside the adapter. - assert_eq!(calls.max_concurrency.load(Ordering::SeqCst), 1); - } - - #[test] - fn duplicate_venue_id_is_rejected() { - let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); - let a = Arc::new(StubCalls::default()); - let b = Arc::new(StubCalls::default()); - registry - .install(cow(), Liveness::default(), StubAdapter::new(a)) - .expect("first install"); - let err = registry - .install(cow(), Liveness::default(), StubAdapter::new(b)) - .expect_err("second install collides"); - assert_eq!(err.venue, cow()); - } - - #[tokio::test] - async fn dead_venue_is_unavailable_not_unknown() { - let calls = Arc::new(StubCalls::default()); - let liveness = Liveness::default(); - let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); - registry - .install(cow(), liveness.clone(), StubAdapter::new(calls.clone())) - .expect("install adapter"); - liveness.mark_dead(); - - // Temporarily dead resolves distinctly from never installed, and - // the dead adapter's slot is never entered. - assert!(matches!( - registry.submit("mod-a", &cow(), b"b".to_vec()).await, - Err(VenueError::Unavailable(_)) - )); - assert!(matches!( - registry - .submit("mod-a", &VenueId::from("unlisted"), b"b".to_vec()) - .await, - Err(VenueError::UnknownVenue) - )); - assert_eq!(calls.derive.load(Ordering::SeqCst), 0); - } - - #[test] - fn a_dead_incumbent_is_replaced_on_reinstall() { - let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); - let liveness = Liveness::default(); - registry - .install( - cow(), - liveness.clone(), - StubAdapter::new(Arc::new(StubCalls::default())), - ) - .expect("first install"); - liveness.mark_dead(); - registry - .install( - cow(), - Liveness::default(), - StubAdapter::new(Arc::new(StubCalls::default())), - ) - .expect("a restart replaces the dead incumbent"); - assert_eq!(registry.venue_count(), 1); - } - - #[test] - fn zero_quota_saturates_to_one() { - let registry = - VenueRegistryBuilder::new(SubmitQuota::new(0, Duration::from_secs(60))).build(); - assert_eq!(registry.inner.quota.max_charges, 1); - } - - #[test] - fn zero_watch_cap_saturates_to_one() { - let registry = VenueRegistryBuilder::new(SubmitQuota::default()) - .with_watch_limit(WatchLimit::new(0, Duration::from_secs(60))) - .build(); - assert_eq!(registry.inner.watch_limit.max_entries, 1); - } - - // ── status watch + polling ──────────────────────────────────────── - - #[tokio::test] - async fn accepted_submission_goes_under_status_watch() { - let calls = Arc::new(StubCalls::default()); - let registry = registry_with(SubmitQuota::default(), None, StubAdapter::new(calls)); - - assert_eq!(registry.watched_count(), 0); - registry - .submit("mod-a", &cow(), b"body".to_vec()) - .await - .expect("submit succeeds"); - assert_eq!(registry.watched_count(), 1); - - // Re-submitting the same receipt does not double-watch it. - registry - .submit("mod-a", &cow(), b"body".to_vec()) - .await - .expect("submit succeeds"); - assert_eq!(registry.watched_count(), 1); - } - - #[tokio::test] - async fn observe_watches_an_externally_obtained_receipt() { - let calls = Arc::new(StubCalls::default()); - let adapter = - StubAdapter::new(calls.clone()).with_status_script([Ok(IntentStatus::Fulfilled)]); - let registry = registry_with(SubmitQuota::default(), None, adapter); - - registry - .observe(&cow(), b"onchain".to_vec()) - .expect("observe succeeds"); - // Re-observing keeps the existing entry. - registry - .observe(&cow(), b"onchain".to_vec()) - .expect("observe is idempotent"); - assert_eq!(registry.watched_count(), 1); - // No adapter work happened at observe time. - assert_eq!(calls.status.load(Ordering::SeqCst), 0); - assert_eq!(calls.submit.load(Ordering::SeqCst), 0); - - // The watch polls like a submitted one: the terminal status - // reports once and prunes the entry. - let updates = registry.poll_status_transitions().await; - assert_eq!(updates.len(), 1); - assert_eq!(updates[0].receipt, b"onchain"); - assert_eq!(decoded(&updates[0]), plain(Lifecycle::Fulfilled)); - assert_eq!(registry.watched_count(), 0); - } - - #[test] - fn observe_rejects_an_unknown_venue() { - let registry = registry_with( - SubmitQuota::default(), - None, - StubAdapter::new(Arc::new(StubCalls::default())), - ); - assert!(matches!( - registry.observe(&VenueId::from("unlisted"), b"r".to_vec()), - Err(VenueError::UnknownVenue) - )); - assert_eq!(registry.watched_count(), 0); - } - - #[test] - fn observe_of_a_dead_venue_is_unavailable() { - let liveness = Liveness::default(); - let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); - registry - .install( - cow(), - liveness.clone(), - StubAdapter::new(Arc::new(StubCalls::default())), - ) - .expect("install adapter"); - liveness.mark_dead(); - assert!(matches!( - registry.observe(&cow(), b"r".to_vec()), - Err(VenueError::Unavailable(_)) - )); - assert_eq!(registry.watched_count(), 0); - } - - #[test] - fn observe_at_the_watch_cap_is_refused_typedly() { - let limit = WatchLimit::new(1, Duration::from_secs(3600)); - let registry = - watch_bounded_registry(limit, StubAdapter::new(Arc::new(StubCalls::default()))); - - registry.observe(&cow(), b"a".to_vec()).expect("admitted"); - let err = registry - .observe(&cow(), b"b".to_vec()) - .expect_err("overflow refused"); - assert!(matches!(err, VenueError::Unavailable(_))); - // The live watch is kept; the overflow was refused. - assert_eq!(registry.watched_count(), 1); - } - - #[tokio::test] - async fn requires_signing_outcome_is_not_watched() { - let calls = Arc::new(StubCalls::default()); - let adapter = - StubAdapter::new(calls).with_submit(Ok(SubmitOutcome::RequiresSigning(UnsignedTx { - chain: 1, - to: vec![0u8; 20], - value: Vec::new(), - data: Vec::new(), - }))); - let registry = registry_with(SubmitQuota::default(), None, adapter); - - registry - .submit("mod-a", &cow(), b"body".to_vec()) - .await - .expect("submit succeeds"); - // No receipt exists yet, so there is nothing to poll. - assert_eq!(registry.watched_count(), 0); - assert!(registry.poll_status_transitions().await.is_empty()); - } - - #[tokio::test] - async fn poll_reports_the_first_status_then_dedupes_repeats() { - let calls = Arc::new(StubCalls::default()); - let registry = registry_with( - SubmitQuota::default(), - None, - StubAdapter::new(calls.clone()), - ); - registry - .submit("mod-a", &cow(), b"body".to_vec()) - .await - .expect("submit succeeds"); - - // First poll: `last` is unset, so the current status reports. - let first = registry.poll_status_transitions().await; - assert_eq!(first.len(), 1); - assert_eq!(first[0].venue, "cow"); - assert_eq!(first[0].receipt, b"receipt"); - assert_eq!(decoded(&first[0]), plain(Lifecycle::Open)); - - // Second poll: same status, nothing to report. - assert!(registry.poll_status_transitions().await.is_empty()); - assert_eq!(calls.status.load(Ordering::SeqCst), 2); - assert_eq!(registry.watched_count(), 1, "open is not terminal"); - } - - #[tokio::test] - async fn poll_reports_each_transition_and_prunes_on_terminal() { - let calls = Arc::new(StubCalls::default()); - let adapter = StubAdapter::new(calls).with_status_script([ - Ok(IntentStatus::Pending), - Ok(IntentStatus::Pending), - Ok(IntentStatus::Open), - Ok(IntentStatus::Fulfilled), - ]); - let registry = registry_with(SubmitQuota::default(), None, adapter); - registry - .submit("mod-a", &cow(), b"body".to_vec()) - .await - .expect("submit succeeds"); - - let mut seen = Vec::new(); - for _ in 0..4 { - seen.extend(registry.poll_status_transitions().await); - } - let statuses: Vec = seen.iter().map(decoded).collect(); - assert_eq!( - statuses, - vec![ - plain(Lifecycle::Pending), - plain(Lifecycle::Open), - plain(Lifecycle::Fulfilled), - ], - "the repeated pending is deduplicated; each transition reports once", - ); - assert_eq!(registry.watched_count(), 0, "fulfilled prunes the watch"); - // A further poll has nothing left to ask the adapter about. - assert!(registry.poll_status_transitions().await.is_empty()); - } - - #[tokio::test] - async fn poll_failure_keeps_the_watch_for_the_next_cadence() { - let calls = Arc::new(StubCalls::default()); - let adapter = StubAdapter::new(calls) - .with_status_script([Err(VenueError::Unavailable("venue down".into()))]); - let registry = registry_with(SubmitQuota::default(), None, adapter); - registry - .submit("mod-a", &cow(), b"body".to_vec()) - .await - .expect("submit succeeds"); - - assert!(registry.poll_status_transitions().await.is_empty()); - assert_eq!( - registry.watched_count(), - 1, - "transient failure keeps the entry" - ); - - // The venue recovered: the next poll reports the current status. - let updates = registry.poll_status_transitions().await; - assert_eq!(updates.len(), 1); - assert_eq!(decoded(&updates[0]), plain(Lifecycle::Open)); - } - - /// A registry with the given watch bounds and one echo-receipt-capable - /// stub adapter under `cow`. - fn watch_bounded_registry(watch_limit: WatchLimit, adapter: StubAdapter) -> VenueRegistry { - let registry = VenueRegistryBuilder::new(SubmitQuota::default()) - .with_watch_limit(watch_limit) - .build(); - registry - .install(cow(), Liveness::default(), adapter) - .expect("install adapter"); - registry - } - - #[tokio::test] - async fn watch_cap_refuses_the_overflow_and_never_drops_live_watches() { - let calls = Arc::new(StubCalls::default()); - let adapter = StubAdapter::new(calls) - .with_receipt_echo() - .with_status_script([Ok(IntentStatus::Pending), Ok(IntentStatus::Pending)]); - let limit = WatchLimit::new(2, Duration::from_secs(3600)); - let registry = watch_bounded_registry(limit, adapter); - - for body in [b"a".to_vec(), b"b".to_vec(), b"c".to_vec()] { - registry - .submit("mod-a", &cow(), body) - .await - .expect("submit succeeds"); - } - assert_eq!(registry.watched_count(), 2, "the cap bounds the set"); - - // The live pending watches kept their tracking; only the overflow - // watch was refused. - let updates = registry.poll_status_transitions().await; - let receipts: Vec<&[u8]> = updates.iter().map(|u| u.receipt.as_slice()).collect(); - assert_eq!(receipts, vec![b"a".as_slice(), b"b".as_slice()]); - assert!( - updates - .iter() - .all(|u| decoded(u) == plain(Lifecycle::Pending)) - ); - } - - #[tokio::test] - async fn pending_polls_keep_a_live_watch_across_expiry_windows() { - let calls = Arc::new(StubCalls::default()); - let adapter = StubAdapter::new(calls).with_status_script([ - Ok(IntentStatus::Pending), - Ok(IntentStatus::Pending), - Ok(IntentStatus::Fulfilled), - ]); - let expiry = Duration::from_secs(1); - let registry = watch_bounded_registry(WatchLimit::new(8, expiry), adapter); - - registry - .submit("mod-a", &cow(), b"body".to_vec()) - .await - .expect("submit succeeds"); - let deadline_at = |registry: &VenueRegistry| { - let watched = registry.inner.watched.lock().expect("watch list poisoned"); - watched[0].expires_at - }; - let inserted = deadline_at(®istry); - - // Two pending polls, each pushing the deadline a full window out. - let mut reported = Vec::new(); - for _ in 0..2 { - reported.extend(registry.poll_status_transitions().await); - assert_eq!( - registry.watched_count(), - 1, - "a reporting venue stays watched" - ); - assert!( - deadline_at(®istry) > inserted, - "the poll refreshed the deadline" - ); - tokio::time::sleep(expiry * 7 / 10).await; - } - - // Well past the insert-time window, the terminal transition still - // reports and prunes the watch. - reported.extend(registry.poll_status_transitions().await); - let statuses: Vec = reported.iter().map(decoded).collect(); - assert_eq!( - statuses, - vec![plain(Lifecycle::Pending), plain(Lifecycle::Fulfilled)], - ); - assert_eq!(registry.watched_count(), 0); - } - - #[tokio::test] - async fn expired_watches_are_evicted_unpolled() { - let calls = Arc::new(StubCalls::default()); - let limit = WatchLimit::new(8, Duration::ZERO); - let registry = watch_bounded_registry(limit, StubAdapter::new(calls.clone())); - - registry - .submit("mod-a", &cow(), b"body".to_vec()) - .await - .expect("submit succeeds"); - assert_eq!(registry.watched_count(), 1); - - // The entry expired before the cadence: evicted without a venue call. - assert!(registry.poll_status_transitions().await.is_empty()); - assert_eq!(registry.watched_count(), 0); - assert_eq!(calls.status.load(Ordering::SeqCst), 0); - } - - #[tokio::test] - async fn expiry_frees_room_at_the_cap() { - let calls = Arc::new(StubCalls::default()); - let limit = WatchLimit::new(1, Duration::ZERO); - let registry = watch_bounded_registry(limit, StubAdapter::new(calls).with_receipt_echo()); - - registry - .submit("mod-a", &cow(), b"a".to_vec()) - .await - .expect("submit succeeds"); - registry - .submit("mod-a", &cow(), b"b".to_vec()) - .await - .expect("submit succeeds"); - - // The expired first watch was evicted at insert, admitting the second. - let watched = registry.inner.watched.lock().expect("watch list poisoned"); - assert_eq!(watched.len(), 1); - assert_eq!(watched[0].receipt, b"b"); - } - - #[test] - fn every_lifecycle_state_lowers_onto_the_status_body() { - for (wire, lowered) in [ - (IntentStatus::Pending, Lifecycle::Pending), - (IntentStatus::Open, Lifecycle::Open), - (IntentStatus::Fulfilled, Lifecycle::Fulfilled), - (IntentStatus::Cancelled, Lifecycle::Cancelled), - (IntentStatus::Expired, Lifecycle::Expired), - ] { - assert_eq!(status_body(wire), plain(lowered)); - } - } -} diff --git a/videre/crates/videre-host/tests/platform.rs b/videre/crates/videre-host/tests/platform.rs deleted file mode 100644 index eeb5c05f..00000000 --- a/videre/crates/videre-host/tests/platform.rs +++ /dev/null @@ -1,1068 +0,0 @@ -//! E2E coverage for the videre platform over the generic runtime seam: -//! the venue-adapter provider boot, the client -> registry -> adapter -//! round trip, the status-poll event source, and the trap-to-recovery -//! sweeps. Exercises pre-built wasm artefacts and skips gracefully when -//! an artefact is absent. - -use std::collections::VecDeque; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -use futures::future::BoxFuture; -use nexum_runtime::bindings::nexum; -use nexum_runtime::engine_config::{ - AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, PoisonLimitsSection, -}; -use nexum_runtime::host::component::ChainMethod; -use nexum_runtime::host::extension::{EventSources, Extension, ExtensionEvent}; -use nexum_runtime::host::state::HostState; -use nexum_runtime::manifest::CapabilityRegistry; -use nexum_runtime::supervisor::{Supervisor, build_linker, build_provider_linker}; -use nexum_runtime::test_utils::{MockChainProvider, MockStateStore, MockTypes, mock_components}; -use videre_host::bindings::{ - IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueError, value_flow, -}; -use videre_host::{ - VenueAdapterKind, VenueId, VenueInvoker, VenueRegistry, VenueRegistryBuilder, Videre, platform, -}; -use wasmtime::component::Linker; - -/// The subscription kind the platform's status poller emits. -const INTENT_STATUS: &str = "intent-status"; - -// ── fixtures + assembly ─────────────────────────────────────────────── - -/// Workspace-root-relative path: the nearest ancestor whose `Cargo.toml` -/// declares `[workspace]` (the umbrella in the monorepo, the repo root -/// standalone). -fn workspace_path(relative: &str) -> PathBuf { - let mut dir = Path::new(env!("CARGO_MANIFEST_DIR")); - loop { - if std::fs::read_to_string(dir.join("Cargo.toml")).is_ok_and(|t| t.contains("[workspace]")) - { - return dir.join(relative); - } - dir = dir.parent().expect("workspace root above crate dir"); - } -} - -/// Group-root-relative path: the crate's grandparent (`videre/` in the -/// monorepo, the repo root standalone), under which this group's `modules/` -/// live. Layout-agnostic, unlike a workspace-root path that would need the -/// `videre/` group prefix only in the monorepo. -fn group_path(relative: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("group root") - .join(relative) -} - -/// Path to a module's `.wasm` artefact under the workspace target dir, -/// or `None` with a skip message when it is not built. -fn module_wasm_or_skip(module_name: &str) -> Option { - let artifact = module_name.replace('-', "_"); - let p = workspace_path(&format!("target/wasm32-wasip2/release/{artifact}.wasm")); - if p.exists() { - Some(p) - } else { - eprintln!( - "SKIP: {} not found - build with `cargo build -p {module_name} --target wasm32-wasip2 --release`", - p.display() - ); - None - } -} - -fn make_wasmtime_engine() -> wasmtime::Engine { - let mut config = wasmtime::Config::new(); - config.wasm_component_model(true); - config.consume_fuel(true); - wasmtime::Engine::new(&config).expect("wasmtime engine") -} - -/// The platform under test plus the extension slice the boot paths take. -/// The concrete handle stays available for the event-source calls. -fn videre_assembly(videre: &Arc) -> Vec>> { - vec![Arc::clone(videre) as Arc>] -} - -fn make_linker( - engine: &wasmtime::Engine, - extensions: &[Arc>], -) -> Linker> { - build_linker::(engine, extensions).expect("build_linker") -} - -/// The registry the booted supervisor publishes under the videre -/// namespace. -fn registry_of(supervisor: &Supervisor) -> Arc { - supervisor - .services() - .get::(VenueRegistry::NAMESPACE) - .expect("registry service") -} - -/// A test block that drives dispatch and the dispatch-time sweeps. -fn block(chain_id: u64) -> nexum::host::types::Block { - nexum::host::types::Block { - chain_id, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - } -} - -/// Wrap a polled transition as the extension event the platform emits. -fn status_event(update: videre_host::IntentStatusUpdate) -> ExtensionEvent { - ExtensionEvent { - kind: INTENT_STATUS, - attrs: vec![("venue", update.venue.clone())], - event: nexum::host::types::Event::IntentStatus(update), - } -} - -// ── world contract ──────────────────────────────────────────────────── - -/// The per-component venue-adapter world contract: an adapter built -/// through `#[videre_sdk::venue]` imports exactly the scoped -/// transport its manifest declares (`chain`), by construction of the -/// emitted world. The venue side never depended on toolchain elision; -/// this pins that it does not regress to it. -#[test] -fn e2e_echo_venue_component_imports_equal_declared_capabilities() { - let Some(wasm) = module_wasm_or_skip("echo-venue") else { - return; - }; - let engine = make_wasmtime_engine(); - let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); - let imports: Vec = component - .component_type() - .imports(&engine) - .map(|(name, _)| name.to_owned()) - .collect(); - - // Capability-bearing imports resolve to exactly the declared set. - let registry = CapabilityRegistry::core(); - let caps: std::collections::BTreeSet<&str> = imports - .iter() - .filter_map(|name| registry.wit_import_to_cap(name)) - .collect(); - assert_eq!( - caps, - std::collections::BTreeSet::from(["chain"]), - "imports were: {imports:?}" - ); - - // No host key-material or persistence interface leaks in: an adapter - // structurally cannot reach messaging it never declared, local-store, - // identity, or logging. - assert!( - imports.iter().all(|name| !name.contains("messaging") - && !name.contains("local-store") - && !name.contains("identity") - && !name.contains("logging")), - "imports were: {imports:?}" - ); -} - -/// The shipped cow adapter honours the same contract: outbound HTTP is -/// its only capability, so the component structurally cannot reach -/// chain, messaging, host key material, or persistence. -#[test] -fn e2e_cow_venue_component_imports_equal_declared_capabilities() { - let wasm = workspace_path("target/wasm32-wasip2/release/cow_venue.wasm"); - if !wasm.exists() { - eprintln!( - "SKIP: {} not found - build with `just build-cow-venue`", - wasm.display() - ); - return; - } - let engine = make_wasmtime_engine(); - let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); - let imports: Vec = component - .component_type() - .imports(&engine) - .map(|(name, _)| name.to_owned()) - .collect(); - - let registry = CapabilityRegistry::core(); - let caps: std::collections::BTreeSet<&str> = imports - .iter() - .filter_map(|name| registry.wit_import_to_cap(name)) - .collect(); - assert_eq!( - caps, - std::collections::BTreeSet::from(["http"]), - "imports were: {imports:?}" - ); - assert!( - imports.iter().all(|name| !name.contains("nexum:host/chain") - && !name.contains("messaging") - && !name.contains("local-store") - && !name.contains("identity") - && !name.contains("logging")), - "imports were: {imports:?}" - ); -} - -/// The venue-adapter provider linker binds only the scoped transport -/// (chain, messaging, wasi base, allowlisted http) and withholds the -/// core-only interfaces. Assembling it proves the scope wires without a -/// duplicate-definition clash between the shared `nexum:host` interfaces. -#[tokio::test] -async fn provider_linker_assembles_with_scoped_transport() { - let engine = make_wasmtime_engine(); - build_provider_linker::(&engine, &VenueAdapterKind) - .expect("provider linker assembles"); -} - -// ── intent-status subscription E2E ──────────────────────────────────── - -/// A scripted venue adapter for the registry: accepts every submission -/// with a fixed receipt and serves statuses front-first from a script; -/// once drained, every further call reports `open`. -struct ScriptedAdapter { - statuses: VecDeque, -} - -impl ScriptedAdapter { - fn new(statuses: impl IntoIterator) -> Self { - Self { - statuses: statuses.into_iter().collect(), - } - } -} - -fn native(bytes: Vec) -> value_flow::AssetAmount { - value_flow::AssetAmount { - asset: value_flow::Asset::Native, - amount: bytes, - } -} - -impl VenueInvoker for ScriptedAdapter { - fn derive_header<'a>( - &'a mut self, - _body: &'a [u8], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - Ok(IntentHeader { - gives: native(vec![1]), - wants: native(Vec::new()), - settlement: Settlement { chain: 1 }, - authorisation: videre_host::bindings::AuthScheme::Eip712, - }) - }) - } - - fn quote<'a>(&'a mut self, _body: &'a [u8]) -> BoxFuture<'a, Result> { - Box::pin(async move { - Ok(Quotation { - gives: native(vec![1]), - wants: native(Vec::new()), - fee: native(Vec::new()), - valid_until_ms: 1_700_000_000_000, - }) - }) - } - - fn submit<'a>( - &'a mut self, - _body: &'a [u8], - ) -> BoxFuture<'a, Result> { - Box::pin(async move { Ok(SubmitOutcome::Accepted(b"receipt".to_vec())) }) - } - - fn status(&mut self, _receipt: Vec) -> BoxFuture<'_, Result> { - Box::pin(async move { Ok(self.statuses.pop_front().unwrap_or(IntentStatus::Open)) }) - } - - fn cancel(&mut self, _receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { - Box::pin(async move { Ok(()) }) - } -} - -/// A registry with one scripted adapter installed under `cow`. -fn scripted_registry(adapter: ScriptedAdapter) -> VenueRegistry { - let registry = VenueRegistryBuilder::new(Default::default()).build(); - registry - .install( - VenueId::from("cow"), - nexum_runtime::host::actor::Liveness::default(), - adapter, - ) - .expect("install scripted adapter"); - registry -} - -/// Write a manifest subscribing the example module to intent-status -/// events from the `cow` venue. -fn intent_status_manifest(dir: &Path) -> PathBuf { - let manifest = dir.join("module.toml"); - std::fs::write( - &manifest, - r#" -[module] -name = "example" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "intent-status" -venue = "cow" -"#, - ) - .expect("write manifest"); - manifest -} - -/// Boot the example module against the given videre platform. -async fn boot_example(videre: &Arc, wasm: &Path, manifest: &Path) -> Supervisor { - let engine = make_wasmtime_engine(); - let extensions = videre_assembly(videre); - let linker = make_linker(&engine, &extensions); - let components = mock_components(); - let limits = ModuleLimits::default(); - Supervisor::boot_single( - &engine, - &linker, - wasm, - Some(manifest), - &components, - &limits, - &extensions, - None, - ) - .await - .expect("boot_single") -} - -/// The acceptance path: a module subscribed to `intent-status` receives -/// the transitions the registry observed by polling the adapter's status -/// export, and a transition from a venue outside its filter is not -/// delivered. -#[tokio::test] -async fn e2e_intent_status_subscription_receives_polled_transitions() { - let Some(wasm) = module_wasm_or_skip("example") else { - return; - }; - let dir = tempfile::tempdir().expect("tempdir"); - let manifest = intent_status_manifest(dir.path()); - - let registry = scripted_registry(ScriptedAdapter::new([ - IntentStatus::Pending, - IntentStatus::Fulfilled, - ])); - let videre = Arc::new(Videre::from_registry(registry.clone())); - let mut supervisor = boot_example(&videre, &wasm, &manifest).await; - assert!( - supervisor - .extension_subscription_kinds() - .contains(INTENT_STATUS) - ); - - // The registry watches the receipt of an accepted submission and polls - // the adapter's status export; each poll here observes a transition. - registry - .submit("test-caller", &VenueId::from("cow"), b"body".to_vec()) - .await - .expect("submit"); - - let mut delivered = 0; - for _ in 0..2 { - for update in registry.poll_status_transitions().await { - delivered += supervisor - .dispatch_extension_event(status_event(update)) - .await; - } - } - assert_eq!(delivered, 2, "pending then fulfilled, one subscriber each"); - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - - // A venue outside the module's filter is not delivered. - let foreign = videre_host::IntentStatusUpdate { - venue: "other".to_owned(), - receipt: b"receipt".to_vec(), - status: nexum_status_body::StatusBody { - status: nexum_status_body::IntentStatus::Open, - proof: None, - reason: None, - } - .encode() - .expect("encode"), - }; - assert_eq!( - supervisor - .dispatch_extension_event(status_event(foreign)) - .await, - 0 - ); -} - -/// ethflow-watcher (built by `#[videre_sdk::keeper]`) boots on the venue -/// platform with its shipped manifest and handles a delivered cow status -/// transition without trapping. -#[tokio::test] -async fn e2e_ethflow_watcher_boots_and_handles_intent_status() { - let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { - return; - }; - let manifest = workspace_path("shepherd/modules/ethflow-watcher/module.toml"); - let videre = Arc::new(platform(&EngineConfig::default())); - let mut supervisor = boot_example(&videre, &wasm, &manifest).await; - assert_eq!(supervisor.alive_count(), 1); - assert!( - supervisor - .extension_subscription_kinds() - .contains(INTENT_STATUS) - ); - - let update = videre_host::IntentStatusUpdate { - venue: "cow".to_owned(), - receipt: vec![0xAB; 56], - status: nexum_status_body::StatusBody { - status: nexum_status_body::IntentStatus::Open, - proof: None, - reason: None, - } - .encode() - .expect("encode"), - }; - assert_eq!( - supervisor - .dispatch_extension_event(status_event(update)) - .await, - 1 - ); - assert_eq!(supervisor.alive_count(), 1); -} - -/// The event-loop wiring, through the real seam: the platform's `events` -/// source opens against the booted service map, its poll task drives the -/// supervisor, and the module's handler observably ran (its log line is -/// retained). -#[tokio::test] -async fn e2e_intent_status_flows_through_the_event_loop() { - use nexum_tasks::{TaskManager, TaskSet}; - - let Some(wasm) = module_wasm_or_skip("example") else { - return; - }; - let dir = tempfile::tempdir().expect("tempdir"); - let manifest = intent_status_manifest(dir.path()); - - let registry = scripted_registry(ScriptedAdapter::new([])); - let videre = Arc::new(Videre::from_registry(registry.clone())); - - let engine = make_wasmtime_engine(); - let extensions = videre_assembly(&videre); - let linker = make_linker(&engine, &extensions); - let components = mock_components(); - let logs = components.logs.clone(); - let limits = ModuleLimits::default(); - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &extensions, - None, - ) - .await - .expect("boot_single"); - - registry - .submit("test-caller", &VenueId::from("cow"), b"body".to_vec()) - .await - .expect("submit"); - - // A fast cadence so the 300 ms window sees the first poll. - let mut config = EngineConfig::default(); - config.limits.status_poll.interval_ms = Some(10); - - let manager = TaskManager::new(); - let executor = manager.executor(); - let mut tasks = TaskSet::new(); - let subscribed = supervisor.extension_subscription_kinds(); - let streams = { - let mut sources = EventSources::new( - &config, - supervisor.services(), - &subscribed, - &executor, - &mut tasks, - ); - Extension::::events(&*videre, &mut sources).expect("open event source") - }; - assert_eq!(streams.len(), 1, "one status-poll stream opened"); - - nexum_runtime::runtime::event_loop::run( - &mut supervisor, - Vec::new(), - Vec::new(), - streams, - tasks, - tokio::time::sleep(Duration::from_millis(300)), - ) - .await; - - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - let runs = logs.list_runs("example"); - assert_eq!(runs.len(), 1, "one run recorded for the example module"); - let page = logs.read(&runs[0].run, 0); - assert!( - page.records - .iter() - .any(|r| r.message.contains("intent status update from venue cow")), - "the module's on_intent_status handler ran; records were: {:?}", - page.records - .iter() - .map(|r| r.message.as_str()) - .collect::>(), - ); -} - -/// With no subscriber (or no installed venue) the platform opens no -/// event source. -#[tokio::test] -async fn event_source_stays_closed_without_subscribers_or_venues() { - use nexum_tasks::{TaskManager, TaskSet}; - - let config = EngineConfig::default(); - let manager = TaskManager::new(); - let executor = manager.executor(); - let services = nexum_runtime::host::extension::HostServices::default(); - - // A venue is installed but nothing subscribes. - let with_venue = Arc::new(Videre::from_registry(scripted_registry( - ScriptedAdapter::new([]), - ))); - let empty = std::collections::BTreeSet::new(); - let mut tasks = TaskSet::new(); - let mut sources = EventSources::new(&config, &services, &empty, &executor, &mut tasks); - let streams = Extension::::events(&*with_venue, &mut sources).expect("events"); - assert!(streams.is_empty(), "no subscriber, no stream"); - - // A subscriber exists but no venue is installed. - let no_venue = Arc::new(platform(&config)); - let subscribed: std::collections::BTreeSet = - std::iter::once(INTENT_STATUS.to_owned()).collect(); - let mut tasks = TaskSet::new(); - let mut sources = EventSources::new(&config, &services, &subscribed, &executor, &mut tasks); - let streams = Extension::::events(&*no_venue, &mut sources).expect("events"); - assert!(streams.is_empty(), "no venue, no stream"); -} - -// ── echo round trip ─────────────────────────────────────────────────── - -/// The acceptance path, end to end over two real components: the -/// echo-client module submits through `videre:venue/client`, the host -/// registry forwards to the installed echo-venue adapter, and the module -/// receives the fulfilled `intent-status` the registry polls back. Proves -/// the intent core round-trips module -> host registry -> venue adapter -/// with no scripted stand-ins on either side. -#[tokio::test] -async fn e2e_echo_module_registry_adapter_round_trip() { - let (Some(adapter_wasm), Some(module_wasm)) = ( - module_wasm_or_skip("echo-venue"), - module_wasm_or_skip("echo-client"), - ) else { - return; - }; - - // The adapter reads eth_blockNumber on submit to justify its `chain` - // grant; program the mock so that read succeeds. The response body is - // discarded by the adapter, so any Ok value serves. - let chain = MockChainProvider::new(); - chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); - let components = nexum_runtime::test_utils::mock_components_from(chain, MockStateStore::new()); - let logs = components.logs.clone(); - - let engine = make_wasmtime_engine(); - let config = EngineConfig { - adapters: vec![AdapterEntry { - path: adapter_wasm, - manifest: Some(group_path("modules/examples/echo-venue/module.toml")), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - modules: vec![ModuleEntry { - path: module_wasm, - manifest: Some(group_path("modules/examples/echo-client/module.toml")), - }], - ..Default::default() - }; - let videre = Arc::new(platform(&config)); - let extensions = videre_assembly(&videre); - let linker = make_linker(&engine, &extensions); - - let mut supervisor = - Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) - .await - .expect("boot"); - assert_eq!( - supervisor.adapter_alive_count(), - 1, - "echo-venue is routable" - ); - assert_eq!(supervisor.alive_count(), 1, "echo-client is alive"); - assert!( - supervisor - .extension_subscription_kinds() - .contains(INTENT_STATUS) - ); - - // A block drives the module's on_block, which submits to the echo venue - // through the shared registry; the registry watches the accepted receipt. - assert_eq!(supervisor.dispatch_block(block(1)).await, 1); - - // Poll the registry the module submitted through and fan its transitions - // back to the module. echo-venue settles instantly, so the first poll - // reports a terminal status and the watch is pruned. - let registry = registry_of(&supervisor); - let mut delivered = 0; - for _ in 0..2 { - for update in registry.poll_status_transitions().await { - assert_eq!(update.venue, "echo-venue"); - let body = - nexum_status_body::StatusBody::decode(&update.status).expect("status body decodes"); - assert_eq!( - body.status, - nexum_status_body::IntentStatus::Fulfilled, - "echo settles instantly", - ); - delivered += supervisor - .dispatch_extension_event(status_event(update)) - .await; - } - } - assert_eq!( - delivered, 1, - "one terminal status delivered to the subscriber" - ); - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - - // The module observably completed the round trip: it quoted, it - // submitted, and it received the settled status from the echo venue. - let runs = logs.list_runs("echo-client"); - assert_eq!(runs.len(), 1, "one run recorded for echo-client"); - let page = logs.read(&runs[0].run, 0); - let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); - assert!( - messages - .iter() - .any(|m| m.contains("quoted") && m.contains("echo-venue")), - "module quoted through the client face; records were: {messages:?}", - ); - assert!( - messages - .iter() - .any(|m| m.contains("submitted") && m.contains("echo-venue")), - "module submitted through the client face; records were: {messages:?}", - ); - assert!( - messages - .iter() - .any(|m| m.contains("intent status from venue echo-venue")), - "module received the settled status; records were: {messages:?}", - ); -} - -/// The blessed keeper path over the same two real components: the -/// echo-keeper module (built by `#[videre_sdk::keeper]`) drives the -/// echo-venue adapter through the typed `VenueClient` - -/// quote, submit, status, cancel, all with a typed body - and receives -/// the fulfilled `intent-status` the registry polls back. Proves the -/// macro-emitted worker and the typed client end to end, with no -/// hand-written byte marshalling on the keeper side. -#[tokio::test] -async fn e2e_keeper_module_drives_the_venue_through_the_typed_client() { - let (Some(adapter_wasm), Some(module_wasm)) = ( - module_wasm_or_skip("echo-venue"), - module_wasm_or_skip("echo-keeper"), - ) else { - return; - }; - - let chain = MockChainProvider::new(); - chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); - let components = nexum_runtime::test_utils::mock_components_from(chain, MockStateStore::new()); - let logs = components.logs.clone(); - - let engine = make_wasmtime_engine(); - let config = EngineConfig { - adapters: vec![AdapterEntry { - path: adapter_wasm, - manifest: Some(group_path("modules/examples/echo-venue/module.toml")), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - modules: vec![ModuleEntry { - path: module_wasm, - manifest: Some(group_path("modules/examples/echo-keeper/module.toml")), - }], - ..Default::default() - }; - let videre = Arc::new(platform(&config)); - let extensions = videre_assembly(&videre); - let linker = make_linker(&engine, &extensions); - - let mut supervisor = - Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) - .await - .expect("boot"); - assert_eq!( - supervisor.adapter_alive_count(), - 1, - "echo-venue is routable" - ); - assert_eq!(supervisor.alive_count(), 1, "echo-keeper is alive"); - - // One block drives the keeper's async on_block: quote, submit, - // status, cancel, all through the typed client. - assert_eq!(supervisor.dispatch_block(block(1)).await, 1); - - // The accepted receipt is under status watch; echo settles - // instantly, so the first poll fans the terminal status back. - let registry = registry_of(&supervisor); - let mut delivered = 0; - for _ in 0..2 { - for update in registry.poll_status_transitions().await { - assert_eq!(update.venue, "echo-venue"); - delivered += supervisor - .dispatch_extension_event(status_event(update)) - .await; - } - } - assert_eq!(delivered, 1, "one terminal status delivered to the keeper"); - assert_eq!(supervisor.alive_count(), 1, "keeper must remain alive"); - - // Every typed verb observably ran. - let runs = logs.list_runs("echo-keeper"); - assert_eq!(runs.len(), 1, "one run recorded for echo-keeper"); - let page = logs.read(&runs[0].run, 0); - let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); - for needle in [ - "quoted at echo-venue", - "submitted to echo-venue", - "status at echo-venue", - "cancelled at echo-venue", - "intent status from venue echo-venue", - ] { - assert!( - messages.iter().any(|m| m.contains(needle)), - "missing `{needle}`; records were: {messages:?}", - ); - } -} - -/// The shepherd bundle pair: twap-monitor (a `#[videre_sdk::keeper]` -/// worker) boots against the installed cow adapter - the body-version -/// handshake admits the pair - and a Sepolia block dispatch reaches it -/// and keeps it alive. The chainless poll surfaces a fault the strategy -/// absorbs, so no orderbook traffic occurs. -#[tokio::test] -async fn e2e_twap_monitor_boots_against_the_cow_adapter() { - let (Some(adapter_wasm), Some(module_wasm)) = ( - module_wasm_or_skip("cow-venue"), - module_wasm_or_skip("twap-monitor"), - ) else { - return; - }; - - let components = mock_components(); - let engine = make_wasmtime_engine(); - let config = EngineConfig { - adapters: vec![AdapterEntry { - path: adapter_wasm, - manifest: Some(workspace_path("shepherd/crates/cow-venue/module.toml")), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - modules: vec![ModuleEntry { - path: module_wasm, - manifest: Some(workspace_path("shepherd/modules/twap-monitor/module.toml")), - }], - ..Default::default() - }; - let videre = Arc::new(platform(&config)); - let extensions = videre_assembly(&videre); - let linker = make_linker(&engine, &extensions); - - let mut supervisor = - Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) - .await - .expect("boot"); - assert_eq!(supervisor.adapter_alive_count(), 1, "cow is routable"); - assert_eq!(supervisor.alive_count(), 1, "twap-monitor is alive"); - - // twap-monitor subscribes to Sepolia blocks (poll path); with no - // watches indexed the sweep is empty and the keeper stays alive. - assert_eq!(supervisor.dispatch_block(block(11_155_111)).await, 1); - assert_eq!(supervisor.alive_count(), 1); -} - -/// The body-version handshake refuses a mismatched pair: an adapter -/// decoding only v1 against a keeper encoding v2 fails the boot at the -/// keeper's install, before instantiation, naming both sides' versions. -#[tokio::test] -async fn e2e_mismatched_body_versions_refuse_the_pair_at_boot() { - let (Some(adapter_wasm), Some(module_wasm)) = ( - module_wasm_or_skip("echo-venue"), - module_wasm_or_skip("echo-client"), - ) else { - return; - }; - - let dir = tempfile::tempdir().expect("tempdir"); - let adapter_manifest = dir.path().join("echo-venue.toml"); - std::fs::write( - &adapter_manifest, - r#" -[module] -name = "echo-venue" -kind = "venue-adapter" - -[capabilities] -required = ["chain"] - -[venue] -body_versions = [1] -"#, - ) - .expect("write adapter manifest"); - let keeper_manifest = dir.path().join("echo-client.toml"); - std::fs::write( - &keeper_manifest, - r#" -[module] -name = "echo-client" - -[capabilities] -required = ["client", "logging"] - -[venue] -body_version = 2 -"#, - ) - .expect("write keeper manifest"); - - let engine = make_wasmtime_engine(); - let config = EngineConfig { - adapters: vec![AdapterEntry { - path: adapter_wasm, - manifest: Some(adapter_manifest), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - modules: vec![ModuleEntry { - path: module_wasm, - manifest: Some(keeper_manifest), - }], - ..Default::default() - }; - let videre = Arc::new(platform(&config)); - let extensions = videre_assembly(&videre); - let linker = make_linker(&engine, &extensions); - let components = mock_components(); - - let Err(err) = - Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await - else { - panic!("mismatched pair must refuse to boot"); - }; - let chain = format!("{err:#}"); - assert!(chain.contains("body version 2"), "{chain}"); - assert!(chain.contains("echo-venue decodes {1}"), "{chain}"); -} - -/// An adapter whose manifest claims versions its code does not decode -/// fails its own install: the `body-versions()` export must equal the -/// manifest `[venue] body_versions` set. -#[tokio::test] -async fn e2e_manifest_export_divergence_refuses_the_adapter_at_boot() { - let Some(adapter_wasm) = module_wasm_or_skip("echo-venue") else { - return; - }; - - let dir = tempfile::tempdir().expect("tempdir"); - let adapter_manifest = dir.path().join("echo-venue.toml"); - std::fs::write( - &adapter_manifest, - r#" -[module] -name = "echo-venue" -kind = "venue-adapter" - -[capabilities] -required = ["chain"] - -[venue] -body_versions = [1, 2] -"#, - ) - .expect("write adapter manifest"); - - let engine = make_wasmtime_engine(); - let config = EngineConfig { - adapters: vec![AdapterEntry { - path: adapter_wasm, - manifest: Some(adapter_manifest), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - ..Default::default() - }; - let videre = Arc::new(platform(&config)); - let extensions = videre_assembly(&videre); - let linker = make_linker(&engine, &extensions); - let components = mock_components(); - - let Err(err) = - Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await - else { - panic!("a diverging adapter must refuse to boot"); - }; - let chain = format!("{err:#}"); - assert!(chain.contains("exports body versions {1}"), "{chain}"); - assert!(chain.contains("declares {1, 2}"), "{chain}"); -} - -// ── venue-adapter trap recovery ─────────────────────────────────────── - -/// Boot one flaky-venue adapter over the mock chain, whose head starts at -/// the fixture's poison sentinel. Returns the chain handle so the test can -/// let the venue recover. -async fn boot_flaky_venue( - adapter_wasm: PathBuf, - limits: ModuleLimits, -) -> (Supervisor, MockChainProvider) { - let chain = MockChainProvider::new(); - chain.on_method(ChainMethod::EthBlockNumber, "\"0xdead\""); - let components = - nexum_runtime::test_utils::mock_components_from(chain.clone(), MockStateStore::new()); - let engine = make_wasmtime_engine(); - let config = EngineConfig { - adapters: vec![AdapterEntry { - path: adapter_wasm, - manifest: Some(group_path("modules/fixtures/flaky-venue/module.toml")), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - limits, - ..Default::default() - }; - let videre = Arc::new(platform(&config)); - let extensions = videre_assembly(&videre); - let linker = make_linker(&engine, &extensions); - let supervisor = Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) - .await - .expect("boot"); - (supervisor, chain) -} - -/// The full trap-to-recovery lifecycle over a real wasm adapter: a trapped -/// venue is temporarily dead (`unavailable`, not `unknown-venue`) and the -/// provider restart sweep reinstantiates it after backoff, after which a -/// submit succeeds again. -#[tokio::test] -async fn e2e_trapped_adapter_is_swept_and_restarts() { - let Some(wasm) = module_wasm_or_skip("flaky-venue") else { - return; - }; - let (mut supervisor, chain) = boot_flaky_venue(wasm, ModuleLimits::default()).await; - assert_eq!(supervisor.adapter_count(), 1); - assert_eq!(supervisor.adapter_alive_count(), 1, "boots alive"); - let registry = registry_of(&supervisor); - let venue = VenueId::from("flaky-venue"); - - // The poison head detonates submit: the guest panic traps the store - // and the shared liveness drops. - let err = registry - .submit("mod-a", &venue, b"body".to_vec()) - .await - .expect_err("the poison head traps the adapter"); - assert!(matches!(err, VenueError::Unavailable(_)), "{err:?}"); - assert_eq!( - supervisor.adapter_alive_count(), - 0, - "the trap drops liveness" - ); - - // Temporarily dead resolves distinctly from never installed. - assert!(matches!( - registry.submit("mod-a", &venue, b"body".to_vec()).await, - Err(VenueError::Unavailable(_)) - )); - assert!(matches!( - registry - .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) - .await, - Err(VenueError::UnknownVenue) - )); - - // The venue recovers; past the 1s backoff the dispatch-time sweep - // reinstalls the adapter on a fresh store. - chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); - tokio::time::sleep(Duration::from_millis(1_200)).await; - supervisor.dispatch_block(block(1)).await; - assert_eq!(supervisor.adapter_alive_count(), 1, "the sweep revived it"); - let outcome = registry - .submit("mod-a", &venue, b"body".to_vec()) - .await - .expect("the recovered adapter accepts"); - assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"body")); -} - -/// A crash-looping adapter is quarantined by the provider poison sweep: -/// at the threshold the restarts stop, and the venue stays dead past every -/// backoff until an operator intervenes. -#[tokio::test] -async fn e2e_crash_looping_adapter_is_poisoned() { - let Some(wasm) = module_wasm_or_skip("flaky-venue") else { - return; - }; - let limits = ModuleLimits { - poison: PoisonLimitsSection { - max_failures: Some(2), - window_secs: Some(600), - }, - ..ModuleLimits::default() - }; - // The chain head stays at the poison sentinel for the whole test: every - // submit after a restart traps again. - let (mut supervisor, _chain) = boot_flaky_venue(wasm, limits).await; - let registry = registry_of(&supervisor); - let venue = VenueId::from("flaky-venue"); - - // Trap 1, then a successful restart past the 1s backoff. - let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; - tokio::time::sleep(Duration::from_millis(1_200)).await; - supervisor.dispatch_block(block(1)).await; - assert_eq!(supervisor.adapter_alive_count(), 1, "first restart lands"); - - // Trap 2 crosses the 2-failure threshold: the sweep quarantines the - // adapter instead of scheduling another restart. - let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; - supervisor.dispatch_block(block(1)).await; - assert_eq!(supervisor.adapter_alive_count(), 0, "quarantined"); - - // Past every backoff the poisoned adapter stays dead and unavailable. - tokio::time::sleep(Duration::from_millis(1_500)).await; - supervisor.dispatch_block(block(1)).await; - assert_eq!( - supervisor.adapter_alive_count(), - 0, - "no restart while poisoned" - ); - assert!(matches!( - registry.submit("mod-a", &venue, b"body".to_vec()).await, - Err(VenueError::Unavailable(_)) - )); -} diff --git a/videre/crates/videre-host/tests/zero_leak.rs b/videre/crates/videre-host/tests/zero_leak.rs deleted file mode 100644 index f4221101..00000000 --- a/videre/crates/videre-host/tests/zero_leak.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! Zero-leak oracle: the host boots the echo venue and routes a worker's -//! submission purely through the generic extension seam, while the -//! `nexum-runtime` crate graph reaches no venue-shaped crate. - -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::Arc; - -use nexum_runtime::bindings::nexum; -use nexum_runtime::engine_config::{AdapterEntry, EngineConfig, ModuleEntry}; -use nexum_runtime::host::component::ChainMethod; -use nexum_runtime::host::extension::Extension; -use nexum_runtime::supervisor::{Supervisor, build_linker}; -use nexum_runtime::test_utils::{ - MockChainProvider, MockStateStore, MockTypes, mock_components_from, -}; -use videre_host::{VenueRegistry, platform}; - -/// Workspace-root-relative path: the nearest ancestor whose `Cargo.toml` -/// declares `[workspace]` (the umbrella in the monorepo, the repo root -/// standalone). -fn workspace_path(relative: &str) -> PathBuf { - let mut dir = Path::new(env!("CARGO_MANIFEST_DIR")); - loop { - if std::fs::read_to_string(dir.join("Cargo.toml")).is_ok_and(|t| t.contains("[workspace]")) - { - return dir.join(relative); - } - dir = dir.parent().expect("workspace root above crate dir"); - } -} - -/// Group-root-relative path: the crate's grandparent (`videre/` in the -/// monorepo, the repo root standalone), under which this group's `modules/` -/// live. Layout-agnostic, unlike a workspace-root path that would need the -/// `videre/` group prefix only in the monorepo. -fn group_path(relative: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("group root") - .join(relative) -} - -/// Path to a module's `.wasm` artefact under the workspace target dir. -/// A missing artefact is a hard failure under CI (the gate may not skip -/// itself) and a soft skip locally. -fn module_wasm_or_skip(module_name: &str) -> Option { - let artifact = module_name.replace('-', "_"); - let p = workspace_path(&format!("target/wasm32-wasip2/release/{artifact}.wasm")); - if p.exists() { - return Some(p); - } - assert!( - std::env::var_os("CI").is_none(), - "{} must be prebuilt in CI", - p.display() - ); - eprintln!( - "SKIP: {} not found - build with `cargo build -p {module_name} --target wasm32-wasip2 --release`", - p.display() - ); - None -} - -/// The boot oracle: the venue adapter installs and a worker's submission -/// reaches it, with the platform supplied only as a generic extension. -#[tokio::test] -async fn e2e_echo_venue_boots_and_submits_through_the_generic_seam() { - let (Some(adapter_wasm), Some(module_wasm)) = ( - module_wasm_or_skip("echo-venue"), - module_wasm_or_skip("echo-client"), - ) else { - return; - }; - - // The adapter reads eth_blockNumber on submit to justify its `chain` - // grant; program the mock so that read succeeds. - let chain = MockChainProvider::new(); - chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); - let components = mock_components_from(chain, MockStateStore::new()); - - let mut engine_config = wasmtime::Config::new(); - engine_config.wasm_component_model(true); - engine_config.consume_fuel(true); - let engine = wasmtime::Engine::new(&engine_config).expect("wasmtime engine"); - - let config = EngineConfig { - adapters: vec![AdapterEntry { - path: adapter_wasm, - manifest: Some(group_path("modules/examples/echo-venue/module.toml")), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - modules: vec![ModuleEntry { - path: module_wasm, - manifest: Some(group_path("modules/examples/echo-client/module.toml")), - }], - ..Default::default() - }; - let extensions: Vec>> = vec![Arc::new(platform(&config))]; - let linker = build_linker::(&engine, &extensions).expect("build_linker"); - - let mut supervisor = - Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) - .await - .expect("boot"); - assert_eq!(supervisor.adapter_alive_count(), 1, "echo-venue installed"); - assert_eq!(supervisor.alive_count(), 1, "echo-client alive"); - - // One block drives the worker's on_block submission; the registry the - // extension published on the service map observes the accepted receipt. - let block = nexum::host::types::Block { - chain_id: 1, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - }; - assert_eq!(supervisor.dispatch_block(block).await, 1); - let registry = supervisor - .services() - .get::(VenueRegistry::NAMESPACE) - .expect("registry service"); - let updates = registry.poll_status_transitions().await; - assert!( - updates.iter().any(|u| u.venue == "echo-venue"), - "the submission reached the venue; updates were: {updates:?}" - ); -} - -/// The graph oracle: `cargo tree` for the host crate (normal + build -/// edges) names no videre, intent, venue, or cow crate. -#[test] -fn host_crate_graph_reaches_no_venue_shaped_crate() { - // nexum-runtime resolves as an out-of-workspace dep in the standalone - // repo, where cargo rejects --all-features; it is venue-free under every - // feature, so the default-feature graph is a sound oracle. - let output = Command::new(env!("CARGO")) - .args([ - "tree", - "-p", - "nexum-runtime", - "-e", - "normal,build", - "--prefix", - "none", - "--locked", - ]) - .current_dir(workspace_path("")) - .output() - .expect("cargo tree runs"); - assert!( - output.status.success(), - "cargo tree failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - let tree = String::from_utf8_lossy(&output.stdout); - let reached: Vec<&str> = tree - .lines() - .filter_map(|line| line.split_whitespace().next()) - .filter(|name| { - let name = name.to_lowercase(); - ["videre", "intent", "venue", "cow"] - .iter() - .any(|word| name.contains(word)) - }) - .collect(); - assert!( - reached.is_empty(), - "venue-shaped crates reached: {reached:?}" - ); -} diff --git a/videre/crates/videre-macros/Cargo.toml b/videre/crates/videre-macros/Cargo.toml deleted file mode 100644 index 36d97fd8..00000000 --- a/videre/crates/videre-macros/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "videre-macros" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Proc-macro glue for the videre personas: #[venue] turns an impl VenueAdapter into the per-cdylib wit-bindgen and adapter export; #[keeper] emits the worker world wired to the typed venue client; derive(IntentBody) emits the versioned body codec." - -[lib] -proc-macro = true - -[lints] -workspace = true - -[dependencies] -nexum-world = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } -proc-macro2.workspace = true -quote.workspace = true -syn = { workspace = true, features = ["full"] } diff --git a/videre/crates/videre-macros/src/intent_body.rs b/videre/crates/videre-macros/src/intent_body.rs deleted file mode 100644 index 8ebbc3d6..00000000 --- a/videre/crates/videre-macros/src/intent_body.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Expansion for `#[derive(IntentBody)]`: the borsh codec over a -//! per-venue version enum. -//! -//! The derive enforces the outer-enum shape at compile time (an enum of -//! newtype variants, one published body version per variant) and emits -//! `to_bytes` / `from_bytes` whose wire form is the borsh enum layout: a -//! one-byte version tag (the variant's declaration index) followed by the -//! borsh-encoded payload. Decoding matches the tag itself, so an unknown -//! version surfaces as the typed `BodyError::UnknownVersion` rather than -//! a stringly borsh error, and a known version delegates the payload to -//! its type's `BorshDeserialize`. -//! -//! Generated code names the venue SDK by its crate path -//! (`::videre_sdk`), so the derive is only usable through that -//! crate's re-export. The expansion names only `::core` and the SDK's -//! `__private` re-exports (borsh, `alloc`), so a `#![no_std]` consumer -//! needs no `extern crate alloc`. - -use proc_macro2::TokenStream; -use quote::quote; -use syn::{Data, DeriveInput, Fields}; - -/// Expand the derive input into the `IntentBody` impl, or a compile -/// error naming the shape rule the input broke. -pub(crate) fn expand(input: &DeriveInput) -> syn::Result { - let name = &input.ident; - - if !input.generics.params.is_empty() { - return Err(syn::Error::new_spanned( - &input.generics, - "#[derive(IntentBody)] does not support generic version enums: a wire schema has \ - exactly one shape", - )); - } - - let Data::Enum(data) = &input.data else { - return Err(syn::Error::new_spanned( - name, - "#[derive(IntentBody)] applies to the outer per-venue version enum: an enum with one \ - newtype variant per published body version", - )); - }; - - if data.variants.is_empty() { - return Err(syn::Error::new_spanned( - name, - "#[derive(IntentBody)] needs at least one version variant", - )); - } - if data.variants.len() > usize::from(u8::MAX) + 1 { - return Err(syn::Error::new_spanned( - name, - "#[derive(IntentBody)] supports at most 256 versions: the wire tag is one byte", - )); - } - - let mut encode_arms = Vec::with_capacity(data.variants.len()); - let mut decode_arms = Vec::with_capacity(data.variants.len()); - for (index, variant) in data.variants.iter().enumerate() { - if let Some((eq, _)) = &variant.discriminant { - return Err(syn::Error::new_spanned( - eq, - "#[derive(IntentBody)] does not support explicit discriminants: the version tag \ - is the variant's declaration index, so append new versions at the end", - )); - } - let payload_ty = match &variant.fields { - Fields::Unnamed(fields) if fields.unnamed.len() == 1 => &fields.unnamed[0].ty, - _ => { - return Err(syn::Error::new_spanned( - &variant.ident, - "#[derive(IntentBody)] version variants carry exactly one unnamed payload \ - field, e.g. `V1(BodyV1)`", - )); - } - }; - - let ident = &variant.ident; - let tag = proc_macro2::Literal::u8_suffixed( - u8::try_from(index).expect("variant count checked above"), - ); - - encode_arms.push(quote! { - Self::#ident(payload) => { - let mut out = ::videre_sdk::body::__private::alloc::vec::Vec::new(); - out.push(#tag); - ::videre_sdk::body::__private::borsh::to_writer(&mut out, payload).map_err( - |err| ::videre_sdk::body::BodyError::Encode { - version: #tag, - detail: ::videre_sdk::body::__private::alloc::string::ToString::to_string(&err), - }, - )?; - ::core::result::Result::Ok(out) - } - }); - decode_arms.push(quote! { - #tag => ::core::result::Result::Ok(Self::#ident( - ::videre_sdk::body::__private::borsh::from_slice::<#payload_ty>(payload) - .map_err(|err| ::videre_sdk::body::BodyError::Malformed { - version: #tag, - detail: ::videre_sdk::body::__private::alloc::string::ToString::to_string( - &err, - ), - })?, - )), - }); - } - - Ok(quote! { - #[automatically_derived] - impl ::videre_sdk::body::__private::Derived for #name {} - - #[automatically_derived] - impl ::videre_sdk::body::IntentBody for #name { - fn to_bytes( - &self, - ) -> ::core::result::Result< - ::videre_sdk::body::__private::alloc::vec::Vec, - ::videre_sdk::body::BodyError, - > { - match self { - #(#encode_arms)* - } - } - - fn from_bytes( - bytes: &[u8], - ) -> ::core::result::Result { - let (version, payload) = bytes - .split_first() - .ok_or(::videre_sdk::body::BodyError::Empty)?; - match *version { - #(#decode_arms)* - version => ::core::result::Result::Err( - ::videre_sdk::body::BodyError::UnknownVersion { version }, - ), - } - } - } - }) -} diff --git a/videre/crates/videre-macros/src/keeper.rs b/videre/crates/videre-macros/src/keeper.rs deleted file mode 100644 index 568796d8..00000000 --- a/videre/crates/videre-macros/src/keeper.rs +++ /dev/null @@ -1,292 +0,0 @@ -//! Expansion for `#[keeper]`: the keeper-worker mirror of `#[module]`. -//! -//! Same world synthesis and event dispatch as the plain module macro, -//! with the keeper deltas: the `client` capability is required, the -//! videre interfaces remap onto the SDK bindings (one shim set, one -//! type identity for the typed client), async handlers complete via -//! `videre_sdk::rt::complete`, and `ClientError` folds into the wire -//! fault so `?` works in handlers. - -use proc_macro2::TokenStream; -use quote::quote; -use syn::{ImplItem, ItemImpl}; - -/// The handler names recognised on a `#[keeper]` impl; the `#[module]` -/// set, since a keeper is a plain worker. -const HANDLERS: [&str; 6] = [ - "init", - "on_block", - "on_chain_logs", - "on_tick", - "on_message", - "on_intent_status", -]; - -/// The manifest capability granting the client import. -const CLIENT_CAPABILITY: &str = "client"; - -/// The import the `client` capability must map to. -const CLIENT_IMPORT: &str = "videre:venue/client@0.1.0"; - -/// WIT packages the client import needs on the resolve path, in -/// dependency order. -const CLIENT_PACKAGES: [&str; 3] = ["videre-value-flow", "videre-types", "videre-venue"]; - -/// The fault detail for a handler future that suspended. -const SUSPENDED: &str = "keeper handler suspended: guest futures complete in one poll"; - -/// Expand the handler impl into the keeper module glue, or a compile -/// error naming the rule the input broke. -pub(crate) fn expand(input: &ItemImpl) -> syn::Result { - let self_ty = &input.self_ty; - if !crate::is_plain_type(self_ty) { - return Err(syn::Error::new_spanned( - self_ty, - "#[videre_sdk::keeper] must be applied to an inherent impl of a named type", - )); - } - if let Some((_, trait_path, _)) = &input.trait_ { - return Err(syn::Error::new_spanned( - trait_path, - "#[videre_sdk::keeper] must be applied to an inherent impl, not a trait impl", - )); - } - if !input.generics.params.is_empty() { - return Err(syn::Error::new_spanned( - &input.generics, - "#[videre_sdk::keeper] must be applied to a non-generic impl", - )); - } - - // Reserve the `on_` prefix for the recognised handler set, exactly - // as `#[module]` does: a typo'd handler must not silently no-op. - for item in &input.items { - if let ImplItem::Fn(f) = item { - let name = f.sig.ident.to_string(); - if name.starts_with("on_") && !HANDLERS.contains(&name.as_str()) { - return Err(syn::Error::new_spanned( - &f.sig.ident, - format!( - "`{name}` is not a recognised #[videre_sdk::keeper] handler; expected one \ - of {HANDLERS:?} (rename helpers so they do not start with `on_`)" - ), - )); - } - } - } - - // Present handlers with their asyncness: async ones are completed - // on the synchronous guest boundary by the emitted dispatch. - let present: Vec<(&str, bool)> = input - .items - .iter() - .filter_map(|item| match item { - ImplItem::Fn(f) => { - let name = f.sig.ident.to_string(); - HANDLERS - .into_iter() - .find(|h| *h == name) - .map(|h| (h, f.sig.asyncness.is_some())) - } - _ => None, - }) - .collect(); - if present.is_empty() { - return Err(syn::Error::new_spanned( - self_ty, - "#[videre_sdk::keeper] found no recognised handlers on this impl; define at least one \ - of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_intent_status`", - )); - } - let handler = |name: &str| present.iter().find(|(h, _)| *h == name).copied(); - - let (anchors, module_world) = derive_keeper_world() - .map_err(|msg| syn::Error::new(proc_macro2::Span::call_site(), msg))?; - let wit_paths = crate::resolve_wit_packages(&module_world.packages) - .map_err(|msg| syn::Error::new(proc_macro2::Span::call_site(), msg))?; - let inline_world = &module_world.wit; - let adapter_caps: Vec = module_world - .adapters - .iter() - .map(|cap| syn::Ident::new(cap, proc_macro2::Span::call_site())) - .collect(); - - // Complete an async handler's future in one poll; a suspension is a - // typed internal fault, never a hang. - let drive = |call: TokenStream| { - quote! { - match ::videre_sdk::rt::complete(#call) { - ::core::option::Option::Some(result) => result, - ::core::option::Option::None => ::core::result::Result::Err( - nexum::host::types::Fault::Internal( - ::std::string::String::from(#SUSPENDED), - ), - ), - } - } - }; - - let init_impl = match handler("init") { - Some((_, is_async)) => { - let call = quote! { <#self_ty>::init(config) }; - let body = if is_async { drive(call) } else { call }; - quote! { - fn init( - config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, - ) -> ::core::result::Result<(), Fault> { - #body - } - } - } - None => quote! { - fn init( - _config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, - ) -> ::core::result::Result<(), Fault> { - ::core::result::Result::Ok(()) - } - }, - }; - - let arm = |name: &str, variant: &str| -> TokenStream { - let variant = syn::Ident::new(variant, proc_macro2::Span::call_site()); - match handler(name) { - Some((_, is_async)) => { - let call = syn::Ident::new(name, proc_macro2::Span::call_site()); - let call = quote! { <#self_ty>::#call(payload) }; - let body = if is_async { drive(call) } else { call }; - quote! { nexum::host::types::Event::#variant(payload) => #body, } - } - None => quote! { - nexum::host::types::Event::#variant(_) => ::core::result::Result::Ok(()), - }, - } - }; - let block_arm = arm("on_block", "Block"); - let logs_arm = arm("on_chain_logs", "ChainLogs"); - let tick_arm = arm("on_tick", "Tick"); - let message_arm = arm("on_message", "Message"); - let intent_status_arm = arm("on_intent_status", "IntentStatus"); - - Ok(quote! { - // Anchor a rebuild on the manifest and the extension registry: - // the emitted world is derived from them. - #(const _: &[u8] = ::core::include_bytes!(#anchors);)* - - wit_bindgen::generate!({ - inline: #inline_world, - path: [#(#wit_paths),*], - world: "nexum:module-world/module", - generate_all, - with: { - "videre:types/types@0.1.0": ::videre_sdk::bindings::videre::types::types, - "videre:value-flow/types@0.1.0": - ::videre_sdk::bindings::videre::value_flow::types, - "videre:venue/client@0.1.0": - ::videre_sdk::bindings::videre::venue::client, - }, - }); - - ::nexum_sdk::bind_host_via_wit_bindgen!(caps: [#(#adapter_caps),*]); - - #input - - // Folds a typed client failure into the wire fault, so `?` - // applies to client calls inside handlers. - impl ::core::convert::From<::videre_sdk::ClientError> for nexum::host::types::Fault { - fn from(err: ::videre_sdk::ClientError) -> Self { - ::core::convert::Into::into(::nexum_sdk::host::Fault::from(err)) - } - } - - #[doc(hidden)] - struct __VidereKeeperExport; - - impl Guest for __VidereKeeperExport { - #init_impl - - fn on_event(event: nexum::host::types::Event) -> ::core::result::Result<(), Fault> { - match event { - #block_arm - #logs_arm - #tick_arm - #message_arm - #intent_status_arm - } - } - } - - export!(__VidereKeeperExport); - }) -} - -/// The canonical `client` extension row, injected when the composition -/// root's registry does not carry one. -fn client_row() -> nexum_world::ExtensionRow { - nexum_world::ExtensionRow { - name: CLIENT_CAPABILITY.to_owned(), - import: CLIENT_IMPORT.to_owned(), - packages: CLIENT_PACKAGES.map(str::to_owned).into(), - } -} - -/// Read the consuming crate's `module.toml`, require the worker shape -/// (no `[module] kind`) and the `client` capability, and synthesize the -/// per-module world with the client extension row guaranteed. Returns -/// the rebuild anchor paths alongside the world. -fn derive_keeper_world() -> Result<(Vec, nexum_world::ModuleWorld), String> { - let manifest_path = crate::manifest_dir()?.join("module.toml"); - let text = std::fs::read_to_string(&manifest_path).map_err(|e| { - format!( - "could not read {} ({e}); #[videre_sdk::keeper] derives the component's WIT world \ - from the manifest's [capabilities] section, so the manifest must sit next to \ - Cargo.toml", - manifest_path.display() - ) - })?; - if let Some(kind) = nexum_world::manifest_kind(&text) - .map_err(|e| format!("{}: {e}", manifest_path.display()))? - { - return Err(format!( - "{}: a #[videre_sdk::keeper] module is a plain worker; drop `[module] kind = \ - \"{kind}\"`", - manifest_path.display() - )); - } - let declared = nexum_world::manifest_capabilities(&text) - .map_err(|e| format!("{}: {e}", manifest_path.display()))?; - if !declared.iter().any(|cap| cap == CLIENT_CAPABILITY) { - return Err(format!( - "{}: a keeper drives venues through `{CLIENT_IMPORT}`; declare the \ - `{CLIENT_CAPABILITY}` capability under [capabilities]", - manifest_path.display() - )); - } - let manifest_path = manifest_path.to_string_lossy().into_owned(); - - let mut anchors = vec![manifest_path.clone()]; - let mut extensions = match nexum_world::find_extensions_manifest(&crate::manifest_dir()?) { - None => Vec::new(), - Some(registry) => { - let text = std::fs::read_to_string(®istry) - .map_err(|e| format!("could not read {}: {e}", registry.display()))?; - let rows = nexum_world::manifest_extensions(&text) - .map_err(|e| format!("{}: {e}", registry.display()))?; - anchors.push(registry.to_string_lossy().into_owned()); - rows - } - }; - match extensions.iter().find(|row| row.name == CLIENT_CAPABILITY) { - None => extensions.push(client_row()), - Some(row) if row.import == CLIENT_IMPORT => {} - Some(row) => { - return Err(format!( - "the registered `{CLIENT_CAPABILITY}` extension imports `{}`; \ - #[videre_sdk::keeper] requires `{CLIENT_IMPORT}`", - row.import - )); - } - } - let module_world = nexum_world::synthesize(&declared, &extensions) - .map_err(|e| format!("{manifest_path}: {e}"))?; - Ok((anchors, module_world)) -} diff --git a/videre/crates/videre-macros/src/lib.rs b/videre/crates/videre-macros/src/lib.rs deleted file mode 100644 index 665fb88a..00000000 --- a/videre/crates/videre-macros/src/lib.rs +++ /dev/null @@ -1,270 +0,0 @@ -//! Proc-macro glue for the two videre personas. -//! -//! [`venue`] is the single blessed venue authoring path: applied to an -//! `impl VenueAdapter` block it emits the per-cdylib wit-bindgen for a -//! manifest-derived world exporting `videre:venue/adapter`, asserts the -//! manifest kind, and expands to the SDK's internal export codegen. -//! -//! [`keeper`] is its worker mirror: applied to a handler impl it emits -//! the per-cdylib wit-bindgen for a manifest-derived module world, -//! wires the `videre:venue/client` import onto the SDK's shared shims, -//! and dispatches events to the handlers, completing async ones on the -//! synchronous guest boundary. -//! -//! [`derive@IntentBody`] implements the venue SDK's versioned body codec -//! over a per-venue version enum. -//! -//! The plain module macro (`#[module]`) lives in `nexum-module-macros`. -//! -//! Consumers reach these through the SDK re-exports -//! (`videre_sdk::venue`, `videre_sdk::keeper`, `videre_sdk::IntentBody`) -//! rather than depending on this crate directly. - -mod intent_body; -mod keeper; -mod world; - -use proc_macro::TokenStream; -use quote::quote; -use syn::{DeriveInput, ItemImpl, Type}; - -/// Derive the venue SDK's `IntentBody` codec on the outer per-venue -/// version enum: one newtype variant per published body version, each -/// payload a borsh type. -/// -/// The wire form is the borsh enum layout (a one-byte tag, the variant's -/// declaration index, then the borsh payload), so the tag order is the -/// schema: append new versions, never reorder. Decoding an unknown tag -/// fails typedly as `BodyError::UnknownVersion`. -/// -/// Generated code resolves the SDK by crate path, so use the -/// `videre_sdk::IntentBody` re-export with `videre-sdk` as a -/// direct dependency. -#[proc_macro_derive(IntentBody)] -pub fn derive_intent_body(input: TokenStream) -> TokenStream { - let input = syn::parse_macro_input!(input as DeriveInput); - intent_body::expand(&input) - .unwrap_or_else(syn::Error::into_compile_error) - .into() -} - -/// The manifest `kind` a venue adapter must declare. Mirrors the -/// venue-adapter provider kind's manifest spelling. -const VENUE_KIND: &str = "venue-adapter"; - -/// Generate the per-cdylib glue for a venue adapter. -/// -/// Apply to the adapter's `impl VenueAdapter for MyVenue` block: the -/// macro reads the crate's `module.toml`, asserts its `[module] kind` -/// is `venue-adapter`, synthesizes a per-component world exporting the -/// `videre:venue/adapter` face and importing exactly the manifest's -/// declared scoped transport, then emits `wit_bindgen::generate!`, the -/// untouched trait impl, and the SDK's internal export codegen wiring -/// the world's `Guest` faces through the trait. So the built component -/// imports what the manifest declares and nothing else, by construction -/// of the emitted world. -/// -/// The generated world remaps `videre:types/types`, -/// `videre:value-flow/types`, and `nexum:host/types` onto the SDK's -/// bindings, so the impl speaks `videre_sdk` types directly and shares -/// type identity with the conformance kit and the client core. -/// -/// A venue's capabilities are scoped transport only: an undeclared -/// capability's bindings do not exist (using one is a compile error), -/// and a capability outside the venue-permitted set (`chain`, -/// `messaging`, `http`) is rejected at expansion. -/// -/// The same crate-root resolution invariants as `#[module]` apply: the -/// wit-bindgen output lands at the module crate root (so the export -/// codegen resolves `Guest`, `exports`, and `export!` there), and the -/// consuming crate must declare `wit-bindgen` and `videre-sdk` as -/// direct dependencies. -#[proc_macro_attribute] -pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { - if !attr.is_empty() { - return syn::Error::new( - proc_macro2::Span::call_site(), - "#[videre_sdk::venue] takes no arguments", - ) - .to_compile_error() - .into(); - } - - let input = syn::parse_macro_input!(item as ItemImpl); - - let Some((None, trait_path, _)) = &input.trait_ else { - return syn::Error::new_spanned( - &input.self_ty, - "#[videre_sdk::venue] must be applied to an `impl VenueAdapter for ...` block", - ) - .to_compile_error() - .into(); - }; - if trait_path - .segments - .last() - .is_none_or(|segment| segment.ident != "VenueAdapter") - { - return syn::Error::new_spanned( - trait_path, - "#[videre_sdk::venue] must be applied to an impl of `videre_sdk::VenueAdapter`", - ) - .to_compile_error() - .into(); - } - let self_ty = &input.self_ty; - if !is_plain_type(self_ty) { - return syn::Error::new_spanned( - self_ty, - "#[videre_sdk::venue] must be applied to an impl on a named type", - ) - .to_compile_error() - .into(); - } - if !input.generics.params.is_empty() { - return syn::Error::new_spanned( - &input.generics, - "#[videre_sdk::venue] must be applied to a non-generic impl", - ) - .to_compile_error() - .into(); - } - - let (manifest_path, venue_world) = match derive_venue_world() { - Ok(parts) => parts, - Err(msg) => { - return syn::Error::new(proc_macro2::Span::call_site(), msg) - .to_compile_error() - .into(); - } - }; - let wit_paths = match resolve_wit_packages(&venue_world.packages) { - Ok(paths) => paths, - Err(msg) => { - return syn::Error::new(proc_macro2::Span::call_site(), msg) - .to_compile_error() - .into(); - } - }; - let inline_world = &venue_world.wit; - - quote! { - // Anchor a rebuild on the manifest: the emitted world is derived - // from it, so an edited [capabilities] must recompile the adapter. - const _: &[u8] = ::core::include_bytes!(#manifest_path); - - wit_bindgen::generate!({ - inline: #inline_world, - path: [#(#wit_paths),*], - world: "nexum:venue-world/venue-adapter", - generate_all, - with: { - "nexum:host/types@0.1.0": ::videre_sdk::bindings::nexum::host::types, - "videre:types/types@0.1.0": ::videre_sdk::bindings::videre::types::types, - "videre:value-flow/types@0.1.0": - ::videre_sdk::bindings::videre::value_flow::types, - }, - }); - - #input - - ::videre_sdk::__export_venue_adapter!(#self_ty); - } - .into() -} - -/// Generate the per-cdylib glue for a keeper: a worker that drives -/// venues through the typed client. -/// -/// The keeper-author mirror of `#[module]`. Apply to an `impl` block -/// whose associated functions are the event handlers (`init`, -/// `on_block`, `on_chain_logs`, `on_tick`, `on_message`, -/// `on_intent_status`); handlers may be `async` and are completed on -/// the synchronous guest boundary (`videre_sdk::rt::complete`), so a -/// handler can await the typed `VenueClient` directly. -/// -/// The macro reads the crate's `module.toml`, requires the `client` -/// capability (the `videre:venue/client` import is what makes a keeper -/// a keeper), synthesizes the per-module world exactly as `#[module]` -/// does, and remaps the videre interfaces onto the SDK bindings: the -/// module's client import resolves to the SDK's shared shims, so the -/// `VenueClient` a handler drives and the wire speak one set of types. -/// A `From` impl onto the wire fault is emitted, so `?` -/// applies to client calls inside handlers. -/// -/// The same crate-root resolution invariants as `#[module]` apply, and -/// the consuming crate must declare `wit-bindgen`, `videre-sdk`, and -/// `nexum-sdk` as direct dependencies. -#[proc_macro_attribute] -pub fn keeper(attr: TokenStream, item: TokenStream) -> TokenStream { - if !attr.is_empty() { - return syn::Error::new( - proc_macro2::Span::call_site(), - "#[videre_sdk::keeper] takes no arguments", - ) - .to_compile_error() - .into(); - } - let input = syn::parse_macro_input!(item as ItemImpl); - keeper::expand(&input) - .unwrap_or_else(syn::Error::into_compile_error) - .into() -} - -/// Whether a type is a plain named path (`Foo`), the only shape a module -/// export type may take. -pub(crate) fn is_plain_type(ty: &Type) -> bool { - matches!(ty, Type::Path(tp) if tp.qself.is_none()) -} - -/// The consuming crate's manifest directory, the root every crate-local -/// lookup starts from. -pub(crate) fn manifest_dir() -> Result { - std::env::var("CARGO_MANIFEST_DIR") - .map(std::path::PathBuf::from) - .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string()) -} - -/// Read the consuming crate's `module.toml`, assert it declares the -/// venue-adapter kind, and synthesize the per-component venue-adapter -/// world from its `[capabilities]` declarations. Returns the manifest -/// path (for the rebuild anchor) alongside the world. -fn derive_venue_world() -> Result<(String, nexum_world::ModuleWorld), String> { - let manifest_path = manifest_dir()?.join("module.toml"); - let text = std::fs::read_to_string(&manifest_path).map_err(|e| { - format!( - "could not read {} ({e}); #[videre_sdk::venue] derives the component's WIT world \ - from the manifest's [capabilities] section, so the manifest must sit next to \ - Cargo.toml", - manifest_path.display() - ) - })?; - let kind = nexum_world::manifest_kind(&text) - .map_err(|e| format!("{}: {e}", manifest_path.display()))?; - if kind.as_deref() != Some(VENUE_KIND) { - return Err(format!( - "{}: [module] kind must be \"{VENUE_KIND}\" for a #[videre_sdk::venue] adapter, \ - found {}", - manifest_path.display(), - kind.map_or_else(|| "none".to_owned(), |kind| format!("\"{kind}\"")), - )); - } - let declared = nexum_world::manifest_capabilities(&text) - .map_err(|e| format!("{}: {e}", manifest_path.display()))?; - let manifest_path = manifest_path.to_string_lossy().into_owned(); - let venue_world = - world::synthesize_venue(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; - Ok((manifest_path, venue_world)) -} - -/// Resolve each needed WIT package directory crate-locally (vendored -/// `wit/deps/`, then own `wit/`), falling back through -/// ancestors for the transitional monorepo layout. -pub(crate) fn resolve_wit_packages(packages: &[String]) -> Result, String> { - Ok( - nexum_world::resolve_wit_packages(&manifest_dir()?, packages)? - .into_iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect(), - ) -} diff --git a/videre/crates/videre-macros/src/world.rs b/videre/crates/videre-macros/src/world.rs deleted file mode 100644 index 30a059ad..00000000 --- a/videre/crates/videre-macros/src/world.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! World wiring for the venue macro: the venue-adapter world synthesis. -//! The module world synthesis, the core capability table, and the -//! extension registry parsing live in `nexum-world`. - -pub use nexum_world::ModuleWorld; - -/// Capabilities a venue adapter may import. A venue speaks one venue's -/// protocol over scoped transport and nothing else: chain RPC, -/// messaging, and outbound HTTP (granted through the SDK's wasi:http -/// client, so no world import). It structurally cannot reach host key -/// material or persistent state, so `local-store`, `remote-store`, -/// `identity`, and `logging` are refused rather than silently imported. -const VENUE_CAPABILITIES: &[&str] = &["chain", "messaging", "http"]; - -/// Build the per-component venue-adapter world from the declared -/// capability names. The world exports `init` and the -/// `videre:venue/adapter` face and imports exactly the declared scoped -/// transport, so a macro-built adapter's imports equal its declarations -/// by construction. A capability outside the venue-permitted set is a -/// compile error: an adapter that reaches for host key material or -/// persistent state is rejected at expansion, not at boot. -pub fn synthesize_venue(declared: &[String]) -> Result { - for name in declared { - if !VENUE_CAPABILITIES.contains(&name.as_str()) { - let permitted = VENUE_CAPABILITIES.join(", "); - return Err(format!( - "capability `{name}` is not available to a venue adapter; a venue may import \ - only scoped transport ({permitted}) and structurally cannot touch local-store, \ - remote-store, identity, or logging" - )); - } - } - - let mut imports = String::new(); - // The export face (`videre:venue/adapter`, its types, and the - // value-flow vocabulary they are expressed in) needs the videre - // packages on the resolve path beyond the leaf host package, in - // dependency order: a package precedes its dependants. - let mut packages: Vec = [ - "videre-value-flow", - "videre-types", - "nexum-host", - "videre-venue", - ] - .map(str::to_owned) - .into(); - for cap in nexum_world::CORE { - if !declared.iter().any(|d| d == cap.name) { - continue; - } - if let Some(import) = cap.import { - imports.push_str(&format!(" import {import};\n")); - } - // Accumulate any extra WIT packages a venue capability needs, - // exactly as the module synthesis does. All venue-permitted - // capabilities are packageless today, so this leaves the base set - // untouched; mirroring the loop keeps a future venue capability - // from silently failing to reach its package onto the resolve - // path. - for package in cap.packages { - if !packages.iter().any(|p| p == package) { - packages.push((*package).to_owned()); - } - } - } - - let mut wit = String::from( - "package nexum:venue-world;\n\nworld venue-adapter {\n \ - use nexum:host/types@0.1.0.{config, fault};\n\n", - ); - wit.push_str(&imports); - wit.push_str( - "\n export init: func(config: config) -> result<_, fault>;\n \ - export videre:venue/adapter@0.1.0;\n}\n", - ); - - Ok(ModuleWorld { - wit, - packages, - // The venue export glue wires the adapter's associated functions - // to the world's Guest traits directly; there is no host-trait - // adapter to bind, so no capability idents to pass on. - adapters: Vec::new(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The package set every venue world resolves against: the exported - /// adapter face pulls the videre vocabulary, in dependency order. - const VENUE_PACKAGES: [&str; 4] = [ - "videre-value-flow", - "videre-types", - "nexum-host", - "videre-venue", - ]; - - #[test] - fn venue_world_exports_the_adapter_face() { - let world = synthesize_venue(&["chain".to_string()]).unwrap(); - assert!(world.wit.starts_with("package nexum:venue-world;")); - assert!(world.wit.contains("world venue-adapter {")); - assert!( - world - .wit - .contains("export init: func(config: config) -> result<_, fault>;") - ); - assert!(world.wit.contains("export videre:venue/adapter@0.1.0;")); - assert_eq!(world.packages, VENUE_PACKAGES); - assert!(world.adapters.is_empty()); - } - - #[test] - fn venue_world_imports_only_declared_transport() { - let world = synthesize_venue(&["chain".to_string()]).unwrap(); - assert!(world.wit.contains("import nexum:host/chain@0.1.0;")); - assert!(!world.wit.contains("import nexum:host/messaging")); - - let both = synthesize_venue(&["chain".to_string(), "messaging".to_string()]).unwrap(); - assert!(both.wit.contains("import nexum:host/chain@0.1.0;")); - assert!(both.wit.contains("import nexum:host/messaging@0.1.0;")); - } - - #[test] - fn venue_world_grants_http_without_a_world_import() { - let world = synthesize_venue(&["http".to_string()]).unwrap(); - assert!(!world.wit.contains("import")); - assert!(!world.wit.contains("wasi:http")); - assert_eq!(world.packages, VENUE_PACKAGES); - } - - #[test] - fn venue_world_with_no_capabilities_imports_nothing() { - let world = synthesize_venue(&[]).unwrap(); - assert!(!world.wit.contains("import")); - assert!(world.wit.contains("export videre:venue/adapter@0.1.0;")); - } - - #[test] - fn venue_world_refuses_non_transport_capabilities() { - for cap in [ - "local-store", - "remote-store", - "identity", - "logging", - "client", - ] { - let err = synthesize_venue(&[cap.to_string()]).unwrap_err(); - assert!(err.contains(cap), "message was: {err}"); - assert!(err.contains("venue adapter"), "message was: {err}"); - } - } -} diff --git a/videre/crates/videre-sdk/Cargo.toml b/videre/crates/videre-sdk/Cargo.toml deleted file mode 100644 index db851169..00000000 --- a/videre/crates/videre-sdk/Cargo.toml +++ /dev/null @@ -1,42 +0,0 @@ -[package] -name = "videre-sdk" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Guest-side videre SDK: the VenueAdapter trait mirroring the venue-adapter world, the borsh-versioned IntentBody codec, the typed venue client over the native-AFIT transport seam, the generic keeper sweep assembler, and typed wrappers over the scoped transport imports." - -[lib] -# Plain library - adapters link this and emit their own cdylib for the -# WASM Component. Building on the host target is also supported so the -# codec, client core, and conversions are unit-testable without a wasm -# toolchain (the wit-bindgen import shims compile to unreachable stubs -# off-wasm). - -[lints] -workspace = true - -[dependencies] -# Backs the `IntentBody` wire codec; re-exported (`body::__private`) for -# the derive's generated code so an adapter crate needs no direct borsh -# declaration unless its payloads derive the borsh traits themselves. -borsh.workspace = true -# Source of `#[venue]` and the `IntentBody` derive, re-exported at the -# crate root next to the trait they implement against. -videre-macros = { path = "../videre-macros" } -# Host-neutral SDK layer this crate builds on: the `ChainHost` seam the -# chain wrapper implements, the shared `Fault` vocabulary, and the -# wasi:http `fetch` surface re-exported as `transport::http`. -nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } -# The standard request/response types the `Fetch` seam (and the -# `TimedFetch` middleware over it) is expressed in. -http.workspace = true -strum.workspace = true -thiserror.workspace = true -# Best-effort fault logs on the sweep's non-critical store cleanups. -tracing.workspace = true -wit-bindgen.workspace = true - -[dev-dependencies] -# In-memory `LocalStoreHost` behind the keeper sweep tests. -nexum-sdk-test = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } diff --git a/videre/crates/videre-sdk/src/adapter.rs b/videre/crates/videre-sdk/src/adapter.rs deleted file mode 100644 index 0e6f3edb..00000000 --- a/videre/crates/videre-sdk/src/adapter.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! The [`VenueAdapter`] trait and the internal export codegen that turns -//! an impl of it into the component's `venue-adapter` world surface. -//! -//! The trait mirrors the world's export face one to one: `init` from the -//! world itself, the intent functions and the body-version declaration -//! from `videre:venue/adapter`. -//! Functions are associated (no `self`): the component model instantiates -//! one adapter per venue and calls exports statically, so adapter state -//! lives in the adapter's own statics, exactly as in event modules. - -use crate::{Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueError}; - -/// One venue's protocol speaker: the guest-side face of the -/// `venue-adapter` world. Implement it on a unit struct and apply -/// [`#[videre_sdk::venue]`](crate::venue) to the impl; bodies and -/// receipts arrive as the opaque bytes the wire carries, and impls -/// recover typing through [`IntentBody`](crate::IntentBody) (whose -/// [`BodyError`](crate::BodyError) converts into [`VenueError`] via `?`). -pub trait VenueAdapter { - /// Configure the adapter from its `[config]` table before any - /// submission. Mirrors the event-module `init`, so the supervisor - /// boots both component kinds through the same machinery. - fn init(config: Config) -> Result<(), Fault>; - - /// Body-schema versions this adapter decodes. Install asserts it - /// equals the manifest `[venue] body_versions` set. Defaults to - /// declaring none. - fn body_versions() -> Vec { - Vec::new() - } - - /// Project an opaque intent body onto the stable header guard - /// policy runs on. Must be a pure derivation: no transport, no side - /// effects, so the host can inspect a header before deciding to - /// submit. The host's guard checkpoint is advisory-only until the - /// egress-guard epic lands: a would-deny is logged, not enforced. - fn derive_header(body: Vec) -> Result; - - /// Price an opaque intent body: an indicative quotation, not an - /// offer the venue is bound to fill. - fn quote(body: Vec) -> Result; - - /// Submit an opaque intent body to this adapter's venue. Success is - /// either the venue's receipt or `requires-signing`: a transaction - /// the host must sign and send before the intent exists. - fn submit(body: Vec) -> Result; - - /// Report where a previously submitted intent is in its life. - fn status(receipt: Vec) -> Result; - - /// Ask the venue to withdraw an intent. Success means the venue - /// accepted the cancellation, not that an in-flight settlement can - /// no longer win the race. - fn cancel(receipt: Vec) -> Result<(), VenueError>; -} - -/// Internal codegen `#[videre_sdk::venue]` expands to: a hidden shim -/// wiring a [`VenueAdapter`] impl to the macro-synthesized world's -/// `Guest` faces, then that world's `export!`. `Guest`, `exports`, and -/// `export!` resolve at the expansion site (the adapter crate root, -/// where the attribute put the world's bindgen), so the macro is -/// meaningful only inside the attribute's output. Not public API. -#[doc(hidden)] -#[macro_export] -macro_rules! __export_venue_adapter { - ($adapter:ty) => { - #[doc(hidden)] - struct __VidereVenueAdapterExport; - - impl Guest for __VidereVenueAdapterExport { - fn init( - config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, - ) -> ::core::result::Result<(), $crate::Fault> { - <$adapter as $crate::VenueAdapter>::init(config) - } - } - - impl exports::videre::venue::adapter::Guest for __VidereVenueAdapterExport { - fn body_versions() -> ::std::vec::Vec { - <$adapter as $crate::VenueAdapter>::body_versions() - } - - fn derive_header( - body: ::std::vec::Vec, - ) -> ::core::result::Result<$crate::IntentHeader, $crate::VenueError> { - <$adapter as $crate::VenueAdapter>::derive_header(body) - } - - fn quote( - body: ::std::vec::Vec, - ) -> ::core::result::Result<$crate::Quotation, $crate::VenueError> { - <$adapter as $crate::VenueAdapter>::quote(body) - } - - fn submit( - body: ::std::vec::Vec, - ) -> ::core::result::Result<$crate::SubmitOutcome, $crate::VenueError> { - <$adapter as $crate::VenueAdapter>::submit(body) - } - - fn status( - receipt: ::std::vec::Vec, - ) -> ::core::result::Result<$crate::IntentStatus, $crate::VenueError> { - <$adapter as $crate::VenueAdapter>::status(receipt) - } - - fn cancel( - receipt: ::std::vec::Vec, - ) -> ::core::result::Result<(), $crate::VenueError> { - <$adapter as $crate::VenueAdapter>::cancel(receipt) - } - } - - export!(__VidereVenueAdapterExport); - }; -} diff --git a/videre/crates/videre-sdk/src/bindings.rs b/videre/crates/videre-sdk/src/bindings.rs deleted file mode 100644 index 77a561a4..00000000 --- a/videre/crates/videre-sdk/src/bindings.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Guest bindings for the videre SDK, generated once from an -//! import-only inline world carrying every interface both personas -//! speak: the videre type vocabulary, the host types and scoped -//! transport, and the keeper-facing `videre:venue/client` shims. -//! -//! Unlike event modules, which run `wit_bindgen::generate!` per cdylib, -//! the SDK generates these bindings once: the -//! [`VenueAdapter`](crate::VenueAdapter) trait, the typed transport -//! wrappers, and the client core are all expressed over them. The -//! per-cdylib bindgens (`#[videre_sdk::venue]`, `#[videre_sdk::keeper]`) -//! remap the shared interfaces onto these modules with `with`, so a -//! macro-built component shares type identity (and, for the keeper's -//! client, one shim set) with the SDK and the conformance kit. -//! -//! The world is import-only on purpose: an embedded world section is -//! unioned into every linking module at componentization, where an -//! unused import prunes but an export is a hard obligation. Keeping the -//! adapter export out of the SDK world is what lets a keeper module -//! link this crate without being asked to be a venue; the export face -//! is emitted per-cdylib by `#[videre_sdk::venue]` alone. - -wit_bindgen::generate!({ - inline: "package videre:sdk-shims; - -world sdk-imports { - import videre:types/types@0.1.0; - import videre:value-flow/types@0.1.0; - import nexum:host/types@0.1.0; - import nexum:host/chain@0.1.0; - import nexum:host/messaging@0.1.0; - import videre:venue/client@0.1.0; -} -", - path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/deps/nexum-host", - "../../wit/videre-venue", - ], - world: "videre:sdk-shims/sdk-imports", - generate_all, - additional_derives: [PartialEq], -}); diff --git a/videre/crates/videre-sdk/src/body.rs b/videre/crates/videre-sdk/src/body.rs deleted file mode 100644 index 33c40378..00000000 --- a/videre/crates/videre-sdk/src/body.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! The versioned intent-body codec: [`IntentBody`] and its typed -//! [`BodyError`]. -//! -//! An intent body crosses the pool and adapter boundaries as opaque -//! bytes; typing is recovered guest-side against the venue's published -//! schema. That schema is an outer version enum whose wire form is the -//! borsh enum layout: a one-byte version tag (the variant's declaration -//! index) followed by the borsh-encoded payload. `#[derive(IntentBody)]` -//! (re-exported at the crate root) implements the codec over such an -//! enum and is the intended way to get an impl; the derive owns the tag -//! handling, so an unknown version fails as the typed -//! [`BodyError::UnknownVersion`] instead of a stringly borsh error. -//! -//! The one non-obvious invariant: the tag order is the schema. Venues -//! append new versions at the end and never reorder or remove variants. - -use strum::IntoStaticStr; - -use crate::VenueError; - -/// The codec between a venue's typed body enum and the opaque bytes the -/// pool and adapter boundaries carry. Sealed to -/// `#[derive(IntentBody)]` on the outer version enum: the derive owns -/// the tag rules, so no hand impl can break them. -pub trait IntentBody: Sized + __private::Derived { - /// Encode as the one-byte version tag plus the borsh payload. - fn to_bytes(&self) -> Result, BodyError>; - - /// Decode, failing typedly on an empty body, an unknown version - /// tag, or a payload that does not parse as the tagged version - /// (including trailing bytes). - fn from_bytes(bytes: &[u8]) -> Result; -} - -/// Why a body failed to cross the [`IntentBody`] codec. -/// -/// `IntoStaticStr` yields a snake_case label per case for log and -/// metric fields. -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum BodyError { - /// No bytes at all: not even a version tag. - #[error("empty body: missing the version tag")] - Empty, - /// The version tag names no published version of this body. The - /// decodable-future-versions story lives here: a v1 adapter handed - /// a v2 body reports the exact unknown tag instead of garbling the - /// payload. - #[error("unknown body version {version}")] - UnknownVersion { - /// The unrecognised wire tag. - version: u8, - }, - /// The tag named a known version but its payload did not decode - /// (malformed borsh or trailing bytes). - #[error("malformed version {version} payload: {detail}")] - Malformed { - /// The wire tag whose payload failed. - version: u8, - /// Borsh's decode failure detail. - detail: String, - }, - /// A payload failed to encode. Only reachable through a fallible - /// custom `BorshSerialize` impl; derived payloads encode - /// infallibly. - #[error("version {version} payload failed to encode: {detail}")] - Encode { - /// The wire tag whose payload failed. - version: u8, - /// Borsh's encode failure detail. - detail: String, - }, -} - -/// Fold a codec failure into the wire error an adapter returns: decode -/// failures are the caller's malformed body (`invalid-body`); an encode -/// failure is the adapter's own bug, reported retryable (`unavailable`). -impl From for VenueError { - fn from(err: BodyError) -> Self { - match err { - BodyError::Empty | BodyError::UnknownVersion { .. } | BodyError::Malformed { .. } => { - VenueError::InvalidBody(err.to_string()) - } - BodyError::Encode { .. } => VenueError::Unavailable(err.to_string()), - } - } -} - -/// Re-exports for `#[derive(IntentBody)]` generated code only; not a -/// public surface. `alloc` rides along so the expansion resolves in a -/// `#![no_std]` consumer without its own `extern crate alloc`. -#[doc(hidden)] -pub mod __private { - pub extern crate alloc; - - pub use borsh; - - /// The [`IntentBody`](super::IntentBody) seal: implemented only by - /// `#[derive(IntentBody)]` expansions. - pub trait Derived {} -} diff --git a/videre/crates/videre-sdk/src/client.rs b/videre/crates/videre-sdk/src/client.rs deleted file mode 100644 index 63ff375d..00000000 --- a/videre/crates/videre-sdk/src/client.rs +++ /dev/null @@ -1,310 +0,0 @@ -//! The typed venue client: [`VenueClient`] binds one [`Venue`] over the -//! byte-level [`VenueTransport`] seam. -//! -//! The wire carries opaque bodies and a stringly venue selector; typing -//! is recovered here. A venue is named once, as a [`Venue`] marker -//! carrying its [`VenueId`] and body schema, and every call encodes -//! through [`IntentBody`] before the seam, so keeper code never handles -//! wire bytes. [`HostVenues`] is the seam bound to the module's own -//! `videre:venue/client` import; tests and in-process adapters -//! implement [`VenueTransport`] directly. The transport methods are -//! native AFIT, so dispatch is static and nothing on the call path -//! boxes. - -use std::borrow::Cow; -use std::fmt; -use std::future::Future; -use std::marker::PhantomData; - -use strum::IntoStaticStr; - -use crate::bindings::videre::venue::client as shims; -use crate::{BodyError, IntentBody, IntentStatus, Quotation, SubmitOutcome, VenueFault}; - -/// Venue identifier: the id an adapter registers under and every client -/// call routes to. Opaque beyond equality. -#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct VenueId(Cow<'static, str>); - -impl VenueId { - /// Wrap a static id without allocating: the [`Venue::ID`] spelling. - #[must_use] - pub const fn from_static(id: &'static str) -> Self { - Self(Cow::Borrowed(id)) - } - - /// The id at its wire spelling. - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl From for VenueId { - fn from(id: String) -> Self { - Self(Cow::Owned(id)) - } -} - -impl From<&str> for VenueId { - fn from(id: &str) -> Self { - Self(Cow::Owned(id.to_owned())) - } -} - -impl AsRef for VenueId { - fn as_ref(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for VenueId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -/// One venue as a keeper types it: the id its adapter registers under -/// and the body schema it decodes. Implement on a unit marker -/// (`struct CowVenue;`) and drive it through [`VenueClient`]. -pub trait Venue { - /// The id the venue's adapter registers under. - const ID: VenueId; - - /// The versioned body schema the venue decodes. - type Body: IntentBody; -} - -/// Sealing marker for [`VenueTransport`]: a transport opts in by also -/// implementing it. -#[doc(hidden)] -pub mod sealed { - pub trait SealedTransport {} -} - -/// The byte-level seam under the typed client: `videre:venue/client` -/// with the venue named per call. Native AFIT, so a [`VenueClient`] -/// over any transport dispatches statically. [`HostVenues`] binds it to -/// the module's own import; tests implement it in memory. -/// -/// Sealed: a transport opts in by also implementing the sealing marker. -pub trait VenueTransport: sealed::SealedTransport { - /// Price an opaque intent body at the named venue. - fn quote( - &self, - venue: &VenueId, - body: Vec, - ) -> impl Future>; - - /// Submit an opaque intent body to the named venue. - fn submit( - &self, - venue: &VenueId, - body: Vec, - ) -> impl Future>; - - /// Put an externally-obtained receipt under the host's status - /// watch; an accepted submit is watched implicitly. Defaults to - /// `unsupported`: a transport that can watch foreign receipts opts - /// in. - fn observe( - &self, - venue: &VenueId, - receipt: &[u8], - ) -> impl Future> { - let _ = (venue, receipt); - async { Err(VenueFault::Unsupported) } - } - - /// Report where a previously submitted intent is in its life. - fn status( - &self, - venue: &VenueId, - receipt: &[u8], - ) -> impl Future>; - - /// Ask the venue to withdraw an intent. Success means the venue - /// accepted the cancellation, not that an in-flight settlement can - /// no longer win the race. - fn cancel( - &self, - venue: &VenueId, - receipt: &[u8], - ) -> impl Future>; -} - -/// The module's `videre:venue/client` import behind the -/// [`VenueTransport`] seam: the transport every guest-side -/// [`VenueClient`] defaults to. -#[derive(Clone, Copy, Debug, Default)] -pub struct HostVenues; - -impl sealed::SealedTransport for HostVenues {} - -impl VenueTransport for HostVenues { - async fn quote(&self, venue: &VenueId, body: Vec) -> Result { - shims::quote(venue.as_str(), &body).map_err(VenueFault::from) - } - - async fn submit(&self, venue: &VenueId, body: Vec) -> Result { - shims::submit(venue.as_str(), &body).map_err(VenueFault::from) - } - - async fn observe(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { - shims::observe(venue.as_str(), receipt).map_err(VenueFault::from) - } - - async fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result { - shims::status(venue.as_str(), receipt).map_err(VenueFault::from) - } - - async fn cancel(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { - shims::cancel(venue.as_str(), receipt).map_err(VenueFault::from) - } -} - -/// A typed client bound to one [`Venue`]: encodes the venue's -/// [`IntentBody`] to wire bytes and forwards through the -/// [`VenueTransport`] seam under [`Venue::ID`]. Zero-sized over the -/// default [`HostVenues`] transport. -pub struct VenueClient { - transport: T, - venue: PhantomData, -} - -impl VenueClient { - /// Bind the venue over the module's own `videre:venue/client` - /// import. - #[must_use] - pub const fn new() -> Self { - Self { - transport: HostVenues, - venue: PhantomData, - } - } -} - -impl Default for VenueClient { - fn default() -> Self { - Self::new() - } -} - -impl VenueClient { - /// Bind the venue over a caller-supplied transport (tests, - /// in-process adapters). - pub const fn with_transport(transport: T) -> Self { - Self { - transport, - venue: PhantomData, - } - } - - /// The venue id every call on this client routes to. - #[must_use] - pub fn venue(&self) -> VenueId { - V::ID - } - - /// Encode the typed body and price it at the bound venue. The - /// returned [`Quoted`] carries the encoded bytes, so `submit` sends - /// exactly the body the venue priced. - pub async fn quote(&self, body: &V::Body) -> Result, ClientError> { - let bytes = body.to_bytes()?; - let quotation = self.transport.quote(&V::ID, bytes.clone()).await?; - Ok(Quoted { - client: self, - bytes, - quotation, - }) - } - - /// Encode the typed body and submit it to the bound venue. - pub async fn submit(&self, body: &V::Body) -> Result { - let bytes = body.to_bytes()?; - Ok(self.transport.submit(&V::ID, bytes).await?) - } - - /// Put an externally-obtained receipt under the host's status - /// watch at the bound venue. - pub async fn observe(&self, receipt: &[u8]) -> Result<(), ClientError> { - Ok(self.transport.observe(&V::ID, receipt).await?) - } - - /// Report where a previously submitted intent is in its life. - pub async fn status(&self, receipt: &[u8]) -> Result { - Ok(self.transport.status(&V::ID, receipt).await?) - } - - /// Ask the bound venue to withdraw an intent. - pub async fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { - Ok(self.transport.cancel(&V::ID, receipt).await?) - } -} - -impl Clone for VenueClient { - fn clone(&self) -> Self { - Self { - transport: self.transport.clone(), - venue: PhantomData, - } - } -} - -impl fmt::Debug for VenueClient { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("VenueClient") - .field("venue", &V::ID) - .finish_non_exhaustive() - } -} - -/// A priced intent: the quotation plus the exact bytes it prices, bound -/// to the client that fetched it. Consuming it with -/// [`submit`](Self::submit) is the only way from a quote to a -/// submission, so a keeper cannot submit a body other than the one -/// quoted. -pub struct Quoted<'a, V: Venue, T: VenueTransport> { - client: &'a VenueClient, - bytes: Vec, - quotation: Quotation, -} - -impl Quoted<'_, V, T> { - /// The venue's indicative quotation for the body. - #[must_use] - pub fn quotation(&self) -> &Quotation { - &self.quotation - } - - /// Submit the quoted body to the venue that priced it. - pub async fn submit(self) -> Result { - Ok(self.client.transport.submit(&V::ID, self.bytes).await?) - } -} - -impl fmt::Debug for Quoted<'_, V, T> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Quoted") - .field("venue", &V::ID) - .field("quotation", &self.quotation) - .finish_non_exhaustive() - } -} - -/// Why a typed client call failed: before the wire (the body failed to -/// encode) or beyond it (the registry or venue refused). -/// -/// `IntoStaticStr` yields a snake_case label per case for log and -/// metric fields. -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum ClientError { - /// The typed body failed to encode; nothing crossed the wire. - #[error(transparent)] - Body(#[from] BodyError), - /// The registry or the venue behind it refused the call. - #[error(transparent)] - Venue(#[from] VenueFault), -} diff --git a/videre/crates/videre-sdk/src/faults.rs b/videre/crates/videre-sdk/src/faults.rs deleted file mode 100644 index ac57203f..00000000 --- a/videre/crates/videre-sdk/src/faults.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! Conversions between the three failure vocabularies an adapter -//! touches: the wire [`Fault`] its exports return, the SDK-neutral -//! [`host::Fault`] the transport seams speak, and the [`VenueError`] the -//! intent face reports; plus [`VenueFault`], the owned client-side -//! mirror of the wire error. -//! -//! Every conversion here is lossy only downward (a structured case folds -//! to a payload-bearing string case, never the reverse), so `?` in an -//! adapter always preserves the most structured form the target -//! vocabulary can carry. - -use nexum_sdk::host; -use strum::IntoStaticStr; - -use crate::bindings::nexum::host::types::RateLimit as WireRateLimit; -use crate::client::ClientError; -use crate::{Fault, RateLimit, VenueError}; - -/// Owned mirror of the wire `venue-error` with `Display`: what typed -/// client code reports when the registry or a venue refuses. The -/// structured retry hint (`rate-limited`'s `retry-after-ms`) survives -/// the lift. -/// -/// `IntoStaticStr` yields a snake_case label per case for log and -/// metric fields. -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum VenueFault { - /// No adapter is registered under the named venue id. - #[error("unknown venue")] - UnknownVenue, - /// The venue rejected the body as malformed. - #[error("invalid body: {0}")] - InvalidBody(String), - /// The venue does not support the operation. - #[error("unsupported")] - Unsupported, - /// The venue or a policy refused the call. - #[error("denied: {0}")] - Denied(String), - /// The venue throttled the call. - #[error("rate limited{}", retry_after_ms.map_or_else(String::new, |ms| format!(", retry after {ms} ms")))] - RateLimited { - /// Venue-suggested wait before retrying, in milliseconds. - retry_after_ms: Option, - }, - /// The venue is temporarily unreachable or failing. - #[error("unavailable: {0}")] - Unavailable(String), - /// The call timed out. - #[error("timeout")] - Timeout, -} - -/// Lift the wire error into the owned mirror. Exhaustive: the wire enum -/// is this crate's own bindgen, so a new WIT case fails here first. -impl From for VenueFault { - fn from(err: VenueError) -> Self { - match err { - VenueError::UnknownVenue => Self::UnknownVenue, - VenueError::InvalidBody(s) => Self::InvalidBody(s), - VenueError::Unsupported => Self::Unsupported, - VenueError::Denied(s) => Self::Denied(s), - VenueError::RateLimited(rl) => Self::RateLimited { - retry_after_ms: rl.retry_after_ms, - }, - VenueError::Unavailable(s) => Self::Unavailable(s), - VenueError::Timeout => Self::Timeout, - } - } -} - -/// Lift the wire fault into the SDK-neutral vocabulary the transport -/// seams and `nexum-sdk` helpers speak. Exhaustive: the wire enum is -/// this crate's own bindgen, so a new WIT case fails here first. -pub fn fault_into_sdk(fault: Fault) -> host::Fault { - match fault { - Fault::Unsupported(s) => host::Fault::Unsupported(s), - Fault::Unavailable(s) => host::Fault::Unavailable(s), - Fault::Denied(s) => host::Fault::Denied(s), - Fault::RateLimited(rl) => host::Fault::RateLimited(host::RateLimit { - retry_after_ms: rl.retry_after_ms, - }), - Fault::Timeout => host::Fault::Timeout, - Fault::InvalidInput(s) => host::Fault::InvalidInput(s), - Fault::Internal(s) => host::Fault::Internal(s), - } -} - -/// Lower the SDK-neutral fault back into the wire fault an adapter's -/// `init` returns, so a helper's `host::Fault` propagates with `?`. -/// -/// Carries a wildcard arm because `host::Fault` is `#[non_exhaustive]`: -/// a future SDK case lands as `internal` carrying its `Display` detail. -impl From for Fault { - fn from(fault: host::Fault) -> Self { - match fault { - host::Fault::Unsupported(s) => Fault::Unsupported(s), - host::Fault::Unavailable(s) => Fault::Unavailable(s), - host::Fault::Denied(s) => Fault::Denied(s), - host::Fault::RateLimited(rl) => Fault::RateLimited(WireRateLimit { - retry_after_ms: rl.retry_after_ms, - }), - host::Fault::Timeout => Fault::Timeout, - host::Fault::InvalidInput(s) => Fault::InvalidInput(s), - host::Fault::Internal(s) => Fault::Internal(s), - other => Fault::Internal(other.to_string()), - } - } -} - -/// Fold a transport fault into the venue error an intent function -/// returns: `denied`, `rate-limited`, `timeout`, and `unsupported` map -/// structurally; `unavailable` keeps its detail; the caller-shaped cases -/// (`invalid-input`, `internal`) fold to retryable `unavailable` because -/// inside an intent function the transport's caller is the adapter -/// itself, never the module. -impl From for VenueError { - fn from(fault: host::Fault) -> Self { - match fault { - host::Fault::Denied(s) => VenueError::Denied(s), - host::Fault::Unsupported(_) => VenueError::Unsupported, - host::Fault::RateLimited(rl) => VenueError::RateLimited(RateLimit { - retry_after_ms: rl.retry_after_ms, - }), - host::Fault::Timeout => VenueError::Timeout, - host::Fault::Unavailable(s) => VenueError::Unavailable(s), - other => VenueError::Unavailable(other.to_string()), - } - } -} - -/// Fold a typed client failure into the SDK-neutral fault a keeper -/// handler returns: an encode failure and a misnamed venue are the -/// caller's `invalid-input`; venue refusals map structurally. -impl From for host::Fault { - fn from(err: ClientError) -> Self { - match err { - ClientError::Body(body) => host::Fault::InvalidInput(body.to_string()), - ClientError::Venue(fault) => match fault { - VenueFault::UnknownVenue => host::Fault::InvalidInput(fault.to_string()), - VenueFault::InvalidBody(s) => host::Fault::InvalidInput(s), - VenueFault::Unsupported => host::Fault::Unsupported(fault.to_string()), - VenueFault::Denied(s) => host::Fault::Denied(s), - VenueFault::RateLimited { retry_after_ms } => { - host::Fault::RateLimited(host::RateLimit { retry_after_ms }) - } - VenueFault::Unavailable(s) => host::Fault::Unavailable(s), - VenueFault::Timeout => host::Fault::Timeout, - }, - } - } -} - -/// Fold a wasi:http fetch failure into the venue error an intent -/// function returns: an allowlist refusal stays `denied`, a timeout is -/// `timeout`, and transport failures (including a request the adapter -/// itself malformed) are retryable `unavailable`. -impl From for VenueError { - fn from(err: nexum_sdk::http::FetchError) -> Self { - use nexum_sdk::http::FetchError; - match err { - FetchError::Denied => VenueError::Denied(err.to_string()), - FetchError::Timeout(_) => VenueError::Timeout, - // `FetchError` is `#[non_exhaustive]`: a future transport - // case folds to retryable `unavailable` with its detail. - _ => VenueError::Unavailable(err.to_string()), - } - } -} - -#[cfg(test)] -mod tests { - use nexum_sdk::host; - - use crate::{Fault, VenueError}; - - #[test] - fn wire_fault_round_trips_through_sdk() { - let cases = [ - Fault::Unsupported("u".into()), - Fault::Unavailable("u".into()), - Fault::Denied("d".into()), - Fault::RateLimited(crate::bindings::nexum::host::types::RateLimit { - retry_after_ms: Some(250), - }), - Fault::Timeout, - Fault::InvalidInput("i".into()), - Fault::Internal("i".into()), - ]; - for case in cases { - let there = super::fault_into_sdk(case.clone()); - assert_eq!(Fault::from(there), case); - } - } - - #[test] - fn transport_fault_folds_to_venue_error_by_shape() { - assert_eq!( - VenueError::from(host::Fault::Denied("nope".into())), - VenueError::Denied("nope".into()), - ); - assert_eq!(VenueError::from(host::Fault::Timeout), VenueError::Timeout); - assert!(matches!( - VenueError::from(host::Fault::RateLimited(host::RateLimit { - retry_after_ms: Some(250), - })), - VenueError::RateLimited(rl) if rl.retry_after_ms == Some(250) - )); - assert!(matches!( - VenueError::from(host::Fault::InvalidInput("bug".into())), - VenueError::Unavailable(_) - )); - } - - #[test] - fn fetch_error_folds_to_venue_error_by_shape() { - use nexum_sdk::http::FetchError; - assert!(matches!( - VenueError::from(FetchError::Denied), - VenueError::Denied(_) - )); - assert!(matches!( - VenueError::from(FetchError::Transport("reset".into())), - VenueError::Unavailable(_) - )); - assert!(matches!( - VenueError::from(FetchError::InvalidRequest("bad url".into())), - VenueError::Unavailable(_) - )); - } -} diff --git a/videre/crates/videre-sdk/src/keeper.rs b/videre/crates/videre-sdk/src/keeper.rs deleted file mode 100644 index b073ea71..00000000 --- a/videre/crates/videre-sdk/src/keeper.rs +++ /dev/null @@ -1,534 +0,0 @@ -//! The generic keeper sweep: one pass assembling the world-neutral -//! stores - [`WatchSet`] to [`Gates`] to [`ConditionalSource::poll`] to -//! [`Retrier`] to [`Journal`] - and routing submissions through the -//! [`VenueTransport`] seam. -//! -//! [`Sweep`] is the shared poll outcome: the concrete -//! [`ConditionalSource::Outcome`] a keeper's sources produce so -//! [`Keeper::sweep`] can act on every one of them. The world-neutral -//! primitives stay in `nexum_sdk::keeper`; this module only assembles -//! them. - -use nexum_sdk::host::{Fault, LocalStoreHost}; -use nexum_sdk::keeper::{ - ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, -}; -use nexum_sdk::prelude::{hex, keccak256}; - -use crate::client::{VenueId, VenueTransport}; -use crate::{SubmitOutcome, UnsignedTx, VenueFault}; - -/// What one poll asks the sweep to do with its watch. -#[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Sweep { - /// Submit these encoded intent-body bytes to the bound venue. - Submit(Vec), - /// Nothing to do yet; the next tick re-polls. - WaitBlock, - /// Gate the watch for `seconds` on the epoch clock. - Backoff { - /// Seconds to wait before the next poll. - seconds: u64, - }, - /// The commitment is spent or unservable; drop the watch. - Drop, -} - -/// A keeper: one conditional source bound to one venue, swept over the -/// keeper stores. -pub struct Keeper { - source: S, - venues: P, - venue: VenueId, -} - -impl Keeper { - /// Bind a source to the venue id its submissions route to. - pub fn new(source: S, venues: P, venue: impl Into) -> Self { - Self { - source, - venues, - venue: venue.into(), - } - } - - /// The venue every submission routes to. - pub fn venue(&self) -> &VenueId { - &self.venue - } -} - -impl Keeper { - /// Sweep the watch set once at `tick`: poll every ready watch, - /// submit [`Sweep::Submit`] bodies through the venue seam, and - /// run every other outcome and every venue refusal through the - /// [`Retrier`]. The [`submission_key`] is checked against the - /// `submitted:` [`Journal`] before every submit and recorded on - /// acceptance - the watch's first-refusal marker is then cleared - /// best-effort - so an accepted body never reaches the venue - /// twice; a `requires-signing` answer journals nothing and is - /// surfaced afresh each sweep. Store faults abort the sweep, bar - /// the post-acceptance marker clear; venue refusals never do - - /// they fold into per-watch retry actions. - pub async fn sweep(&self, host: &H, tick: &Tick) -> Result - where - H: LocalStoreHost, - S: ConditionalSource, - { - let watches = WatchSet::new(host); - let gates = Gates::new(host); - let retrier = Retrier::new(host); - let journal = Journal::submitted(host); - let mut report = SweepReport::default(); - - for key in watches.list()? { - let Some(watch) = WatchRef::parse(&key) else { - report.skipped += 1; - continue; - }; - if !gates.is_ready(watch, tick.block, tick.epoch_s)? { - report.gated += 1; - continue; - } - let Some(params) = watches.get(watch)? else { - report.skipped += 1; - continue; - }; - report.polled += 1; - - let action = match self.source.poll(host, watch, ¶ms, tick) { - Sweep::Submit(body) => { - let key = submission_key(&self.venue, &body); - if journal.contains(&key)? { - report.duplicates += 1; - continue; - } - match self.venues.submit(&self.venue, body).await { - Ok(SubmitOutcome::Accepted(_)) => { - journal.record(&key)?; - // The acceptance is journalled; the marker - // clear is cleanup and must not abort the - // sweep. - if let Err(fault) = retrier.clear_refusal(watch) { - tracing::error!( - %fault, - "refusal-marker clear failed after journalled acceptance", - ); - } - report.submitted += 1; - continue; - } - Ok(SubmitOutcome::RequiresSigning(tx)) => { - report.unsigned.push(tx); - continue; - } - Err(fault) => retry_action(&fault), - } - } - Sweep::WaitBlock => RetryAction::TryNextBlock, - Sweep::Backoff { seconds } => RetryAction::Backoff { seconds }, - Sweep::Drop => RetryAction::Drop, - }; - match action { - RetryAction::Drop => report.dropped += 1, - _ => report.retried += 1, - } - retrier.apply(watch, action, tick)?; - } - Ok(report) - } -} - -/// One sweep's tally, by watch disposition. -#[derive(Clone, Debug, Default, PartialEq)] -#[non_exhaustive] -pub struct SweepReport { - /// Watches polled. - pub polled: usize, - /// Watches skipped by an unexpired gate. - pub gated: usize, - /// Watches skipped unread: a malformed key, or a row that vanished - /// mid-sweep. - pub skipped: usize, - /// Bodies the venue accepted, submission key newly journalled. - pub submitted: usize, - /// Bodies whose key an earlier sweep had journalled, skipped - /// without a venue call. - pub duplicates: usize, - /// Watches left in place for a later tick. - pub retried: usize, - /// Watches dropped. - pub dropped: usize, - /// Transactions the venue answered `requires-signing`; a sweep - /// cannot sign, so the caller owns them. - pub unsigned: Vec, -} - -/// Deterministic pre-submit journal key: the venue id and the -/// keccak-256 of the body. The hash is a fixed-length suffix, so the -/// key is unambiguous whatever the venue id contains. Public so a -/// keeper journalling outside [`Keeper::sweep`] writes the key the -/// sweep checks. -pub fn submission_key(venue: &VenueId, body: &[u8]) -> String { - format!("{venue}:{}", hex::encode_prefixed(keccak256(body))) -} - -/// Fold a venue refusal into the retry action the ledger runs: the -/// throttle hint becomes an epoch gate, transient failures retry next -/// block, and refusals no retry can cure drop the watch. Public so a -/// keeper sweeping outside [`Keeper::sweep`] folds refusals the same -/// way. -pub fn retry_action(fault: &VenueFault) -> RetryAction { - match fault { - VenueFault::RateLimited { - retry_after_ms: Some(ms), - } => RetryAction::Backoff { - seconds: ms.div_ceil(1000), - }, - VenueFault::RateLimited { - retry_after_ms: None, - } - | VenueFault::Timeout - | VenueFault::Unavailable(_) => RetryAction::TryNextBlock, - VenueFault::UnknownVenue - | VenueFault::InvalidBody(_) - | VenueFault::Unsupported - | VenueFault::Denied(_) => RetryAction::Drop, - } -} - -#[cfg(test)] -mod tests { - use std::cell::RefCell; - - use nexum_sdk::host::{Fault, LocalStoreHost as _}; - use nexum_sdk::keeper::{Gates, Journal, Tick, WatchRef, WatchSet}; - use nexum_sdk::prelude::{Address, B256, hex, keccak256}; - use nexum_sdk_test::MockLocalStore; - - use super::{Keeper, Sweep, SweepReport}; - use crate::client::{VenueId, VenueTransport}; - use crate::{IntentStatus, Quotation, SubmitOutcome, UnsignedTx, VenueFault}; - - /// Drive a sweep on the test's synchronous boundary. - fn run(future: F) -> F::Output { - crate::rt::complete(future).expect("sweep futures complete in one poll") - } - - /// Answers every poll with one programmed outcome. - struct StubSource(Sweep); - - impl nexum_sdk::keeper::ConditionalSource for StubSource { - type Outcome = Sweep; - - fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Sweep { - self.0.clone() - } - } - - /// Pops one programmed outcome per poll, from the back. - struct SeqSource(RefCell>); - - impl nexum_sdk::keeper::ConditionalSource for SeqSource { - type Outcome = Sweep; - - fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Sweep { - self.0.borrow_mut().pop().unwrap_or(Sweep::WaitBlock) - } - } - - /// Answers every submit with one programmed outcome, logging bodies. - struct StubVenue { - outcome: Result, - submitted: RefCell>>, - } - - impl StubVenue { - fn new(outcome: Result) -> Self { - Self { - outcome, - submitted: RefCell::new(Vec::new()), - } - } - } - - impl crate::client::sealed::SealedTransport for &StubVenue {} - - impl VenueTransport for &StubVenue { - async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { - unreachable!("quote not exercised") - } - - async fn submit( - &self, - _venue: &VenueId, - body: Vec, - ) -> Result { - self.submitted.borrow_mut().push(body); - self.outcome.clone() - } - - async fn status( - &self, - _venue: &VenueId, - _receipt: &[u8], - ) -> Result { - unreachable!("status not exercised") - } - - async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { - unreachable!("cancel not exercised") - } - } - - const TICK: Tick = Tick { - chain_id: 1, - block: 100, - epoch_s: 1_000, - }; - - fn put_watch(host: &MockLocalStore) -> String { - WatchSet::new(host) - .put(&Address::ZERO, &B256::ZERO, b"params") - .expect("mock store accepts the watch") - } - - fn keeper(outcome: Sweep, venue: &StubVenue) -> Keeper { - Keeper::new(StubSource(outcome), venue, "stub") - } - - #[test] - fn accepted_body_is_journalled_and_never_resubmitted() { - let host = MockLocalStore::default(); - put_watch(&host); - let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![0xA5, 0x5A]))); - let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); - - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); - assert_eq!(report.polled, 1); - assert_eq!(report.submitted, 1); - assert_eq!(venue.submitted.borrow().as_slice(), [b"body".to_vec()]); - - let journal = Journal::submitted(&host); - let key = format!("stub:{}", hex::encode_prefixed(keccak256(b"body"))); - assert!(journal.contains(&key).expect("journal reads")); - assert_eq!(WatchSet::new(&host).list().expect("list reads").len(), 1); - - // A later sweep re-polls the watch but never re-posts the body. - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); - assert_eq!(report.submitted, 0); - assert_eq!(report.duplicates, 1); - assert_eq!(venue.submitted.borrow().len(), 1); - } - - #[test] - fn accepted_body_clears_the_refusal_marker() { - let host = MockLocalStore::default(); - let key = put_watch(&host); - let watch = WatchRef::parse(&key).expect("well-formed key"); - host.set(&watch.refused_key(), &50_u64.to_le_bytes()) - .expect("marker writes"); - let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - - let report = run(keeper(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) - .expect("sweep runs"); - assert_eq!(report.submitted, 1); - assert!( - host.get(&watch.refused_key()) - .expect("marker reads") - .is_none(), - "acceptance must clear the first-refusal marker", - ); - } - - #[test] - fn refusal_marker_clear_fault_does_not_abort_a_journalled_acceptance() { - let host = MockLocalStore::default(); - put_watch(&host); - host.fail_on("refused:", Fault::Unavailable("store down".into())); - let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - - let report = run(keeper(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) - .expect("marker-clear fault must not abort the sweep"); - assert_eq!(report.submitted, 1); - let key = format!("stub:{}", hex::encode_prefixed(keccak256(b"body"))); - assert!( - Journal::submitted(&host) - .contains(&key) - .expect("journal reads"), - "acceptance must be journalled before the marker clear", - ); - } - - #[test] - fn a_changed_body_submits_afresh() { - let host = MockLocalStore::default(); - put_watch(&host); - let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - // Polls pop from the back: `one` first, then `two`. - let source = SeqSource(RefCell::new(vec![ - Sweep::Submit(b"two".to_vec()), - Sweep::Submit(b"one".to_vec()), - ])); - let keeper = Keeper::new(source, &venue, "stub"); - - assert_eq!( - run(keeper.sweep(&host, &TICK)) - .expect("sweep runs") - .submitted, - 1 - ); - assert_eq!( - run(keeper.sweep(&host, &TICK)) - .expect("sweep runs") - .submitted, - 1 - ); - assert_eq!( - venue.submitted.borrow().as_slice(), - [b"one".to_vec(), b"two".to_vec()] - ); - } - - #[test] - fn requires_signing_hands_the_transaction_to_the_caller() { - let host = MockLocalStore::default(); - put_watch(&host); - let tx = UnsignedTx { - chain: 1, - to: vec![0x11; 20], - value: Vec::new(), - data: vec![0xFE], - }; - let venue = StubVenue::new(Ok(SubmitOutcome::RequiresSigning(tx.clone()))); - let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); - - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); - assert_eq!(report.unsigned, vec![tx.clone()]); - assert_eq!(report.submitted, 0); - - // Nothing accepted, nothing journalled: the next sweep - // surfaces the same transaction again. - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); - assert_eq!(report.unsigned, vec![tx]); - } - - #[test] - fn gated_watch_is_not_polled() { - let host = MockLocalStore::default(); - let key = put_watch(&host); - let watch = WatchRef::parse(&key).expect("well-formed key"); - Gates::new(&host) - .set_next_block(watch, TICK.block + 1) - .expect("gate writes"); - let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - - let report = run(keeper(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) - .expect("sweep runs"); - assert_eq!(report.gated, 1); - assert_eq!(report.polled, 0); - assert!(venue.submitted.borrow().is_empty()); - } - - #[test] - fn drop_outcome_removes_the_watch() { - let host = MockLocalStore::default(); - put_watch(&host); - let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - - let report = run(keeper(Sweep::Drop, &venue).sweep(&host, &TICK)).expect("sweep runs"); - assert_eq!(report.dropped, 1); - assert!(WatchSet::new(&host).list().expect("list reads").is_empty()); - } - - #[test] - fn backoff_outcome_gates_the_watch_on_the_epoch_clock() { - let host = MockLocalStore::default(); - put_watch(&host); - let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - let keeper = keeper(Sweep::Backoff { seconds: 30 }, &venue); - - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); - assert_eq!(report.retried, 1); - - // Still inside the backoff window: gated, not polled. - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); - assert_eq!(report.gated, 1); - - // At the threshold the gate opens again. - let later = Tick { - epoch_s: TICK.epoch_s + 30, - ..TICK - }; - let report = run(keeper.sweep(&host, &later)).expect("sweep runs"); - assert_eq!(report.polled, 1); - } - - #[test] - fn rate_limited_refusal_backs_off_by_the_venue_hint() { - let host = MockLocalStore::default(); - put_watch(&host); - let venue = StubVenue::new(Err(VenueFault::RateLimited { - retry_after_ms: Some(2_500), - })); - let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); - - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); - assert_eq!(report.retried, 1); - - // 2500 ms rounds up to a 3 s epoch gate. - let at_2s = Tick { - epoch_s: TICK.epoch_s + 2, - ..TICK - }; - assert_eq!( - run(keeper.sweep(&host, &at_2s)).expect("sweep runs").gated, - 1 - ); - let at_3s = Tick { - epoch_s: TICK.epoch_s + 3, - ..TICK - }; - assert_eq!( - run(keeper.sweep(&host, &at_3s)).expect("sweep runs").polled, - 1 - ); - } - - #[test] - fn non_retryable_refusal_drops_the_watch() { - let host = MockLocalStore::default(); - put_watch(&host); - let venue = StubVenue::new(Err(VenueFault::Denied("blocked".into()))); - - let report = run(keeper(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) - .expect("sweep runs"); - assert_eq!(report.dropped, 1); - assert!(WatchSet::new(&host).list().expect("list reads").is_empty()); - } - - #[test] - fn transient_refusal_leaves_the_watch_for_the_next_tick() { - let host = MockLocalStore::default(); - put_watch(&host); - let venue = StubVenue::new(Err(VenueFault::Unavailable("down".into()))); - let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); - - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); - assert_eq!(report.retried, 1); - assert_eq!( - run(keeper.sweep(&host, &TICK)).expect("sweep runs").polled, - 1 - ); - } - - #[test] - fn empty_watch_set_reports_nothing() { - let host = MockLocalStore::default(); - let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - - let report = run(keeper(Sweep::WaitBlock, &venue).sweep(&host, &TICK)).expect("sweep runs"); - assert_eq!(report, SweepReport::default()); - } -} diff --git a/videre/crates/videre-sdk/src/lib.rs b/videre/crates/videre-sdk/src/lib.rs deleted file mode 100644 index dd96575c..00000000 --- a/videre/crates/videre-sdk/src/lib.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! # videre-sdk -//! -//! Guest-side SDK for the videre personas: the venue author (one -//! venue's protocol speaker exporting the `venue-adapter` world) and -//! the keeper author driving venues through the client seam. Where -//! `nexum-sdk` serves the strategy-module persona, this crate serves -//! both venue sides of it. -//! -//! ## What lives here -//! -//! - [`VenueAdapter`] - the trait mirroring the world's export face -//! (`init` plus the five intent functions). `#[videre_sdk::venue]` -//! on the impl turns it into the component's export glue: the single -//! blessed authoring path. -//! -//! - [`IntentBody`] (trait and derive) with [`BodyError`] - the borsh -//! codec over the outer per-venue version enum. The wire form is a -//! one-byte version tag plus the borsh payload; an unknown tag fails -//! typedly rather than as a stringly decode error. -//! -//! - [`client`] - the typed venue client: a [`Venue`] marker (its -//! [`VenueId`] plus body schema) drives [`VenueClient`], which -//! encodes through [`IntentBody`] before the byte-level, native-AFIT -//! [`VenueTransport`] seam ([`HostVenues`] binds it to the module's -//! own `videre:venue/client` import). Lives here (not in the -//! strategy SDK) so the codec and the client that speaks it version -//! together. `#[videre_sdk::keeper]` on a handler impl wires the -//! import and drives async handlers; [`rt`] completes their futures -//! on the synchronous guest boundary. -//! -//! - [`keeper`](mod@keeper) - the generic sweep assembler: [`Keeper::sweep`] runs -//! the world-neutral `nexum_sdk::keeper` stores over a -//! [`ConditionalSource`](nexum_sdk::keeper::ConditionalSource) -//! producing the shared [`Sweep`] outcome, submitting through the -//! [`VenueTransport`] seam. -//! -//! - [`transport`] - typed wrappers over the world's scoped imports: -//! [`HostChain`](transport::HostChain) behind the SDK [`ChainHost`] -//! seam (plus batch), [`HostMessaging`](transport::HostMessaging) -//! behind [`MessagingHost`](transport::MessagingHost), the -//! wasi:http surface re-exported as [`transport::http`], and -//! [`TimedFetch`](transport::TimedFetch), the per-request timeout -//! middleware every adapter request should ride. -//! -//! - [`faults`] - the conversions that make `?` work across the wire -//! fault, the SDK-neutral fault, and [`VenueError`]; plus -//! [`VenueFault`], the owned client-side mirror. -//! -//! ## Why the bindgen lives in this crate -//! -//! The shared interfaces generate once, in [`bindings`], from an -//! import-only world: the trait, wrappers, and client core are all -//! typed over them. The per-cdylib bindgens (`#[venue]`, `#[keeper]`) -//! remap the shared interfaces onto [`bindings`], so a macro-built -//! component speaks these types while its world stays derived from its -//! own manifest. -//! -//! [`ChainHost`]: nexum_sdk::host::ChainHost -//! [`VenueClient`]: client::VenueClient -//! [`VenueTransport`]: client::VenueTransport - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![warn(missing_docs)] - -#[allow(missing_docs)] -pub mod bindings; - -pub mod adapter; -pub mod body; -pub mod client; -pub mod faults; -pub mod keeper; -pub mod rt; -pub mod transport; - -pub use adapter::VenueAdapter; -pub use body::{BodyError, IntentBody}; -pub use client::{ClientError, HostVenues, Quoted, Venue, VenueClient, VenueId, VenueTransport}; -pub use faults::VenueFault; -pub use keeper::{Keeper, Sweep, SweepReport, retry_action}; -/// Derive [`IntentBody`] on the outer per-venue version enum. See -/// [`videre_macros::IntentBody`]. -pub use videre_macros::IntentBody; -/// The blessed keeper authoring path. Apply to a worker's handler impl: -/// emits the per-cdylib bindgen for a world derived from `module.toml` -/// (asserting the `client` capability), remaps the videre interfaces -/// onto the SDK bindings so the module drives a [`VenueClient`] with -/// shared type identity, dispatches events to the handlers (async ones -/// completed through [`rt::complete`]), and folds [`ClientError`] into -/// the wire fault so `?` works in handlers. See -/// [`videre_macros::keeper`]. -pub use videre_macros::keeper; -/// The single blessed venue authoring path. Apply to the adapter's -/// `impl VenueAdapter for MyVenue` block: emits the per-cdylib bindgen -/// for a world derived from `module.toml` (asserting its -/// `kind = "venue-adapter"`), the `videre:venue/adapter` export glue, -/// and `export!`. The built component imports exactly the manifest's -/// declared scoped transport. See [`videre_macros::venue`]. -pub use videre_macros::venue; - -/// The intent ontology at its plain spellings: the types the -/// [`VenueAdapter`] face and the client core speak. -pub use bindings::videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, - UnsignedTx, VenueError, -}; -/// The value-flow vocabulary intent headers are expressed in. -pub use bindings::videre::value_flow::types as value_flow; - -/// The wire config table (`nexum:host/types.config`) `init` receives. -pub use bindings::nexum::host::types::Config; -/// The wire fault (`nexum:host/types.fault`) `init` returns. Transport -/// seams speak the SDK-neutral [`nexum_sdk::host::Fault`] instead; the -/// [`faults`] conversions bridge the two. -pub use bindings::nexum::host::types::Fault; diff --git a/videre/crates/videre-sdk/src/rt.rs b/videre/crates/videre-sdk/src/rt.rs deleted file mode 100644 index 6a62f4f3..00000000 --- a/videre/crates/videre-sdk/src/rt.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Futures on the synchronous guest boundary. - -use std::future::Future; -use std::pin::pin; -use std::task::{Context, Poll, Waker}; - -/// Complete a future in one poll. Guest host imports are synchronous, -/// so every await in a keeper future resolves immediately and one poll -/// runs it to completion. `None` reports a future that suspended, -/// which nothing built over the host imports does; the keeper macro's -/// emitted glue folds it to a typed fault. -pub fn complete(future: F) -> Option { - let mut future = pin!(future); - let mut cx = Context::from_waker(Waker::noop()); - match future.as_mut().poll(&mut cx) { - Poll::Ready(output) => Some(output), - Poll::Pending => None, - } -} - -#[cfg(test)] -mod tests { - use super::complete; - - #[test] - fn ready_chain_completes_in_one_poll() { - async fn two() -> u8 { - let one = async { 1u8 }.await; - one + async { 1u8 }.await - } - assert_eq!(complete(two()), Some(2)); - } - - #[test] - fn suspending_future_reports_none() { - assert_eq!(complete(std::future::pending::<()>()), None); - } -} diff --git a/videre/crates/videre-sdk/src/transport.rs b/videre/crates/videre-sdk/src/transport.rs deleted file mode 100644 index f1971e92..00000000 --- a/videre/crates/videre-sdk/src/transport.rs +++ /dev/null @@ -1,235 +0,0 @@ -//! Typed wrappers over the adapter world's scoped transport imports: -//! chain RPC, messaging, and outbound wasi:http. -//! -//! Each wrapper adapts this crate's bindgen import shims to the -//! SDK-neutral vocabulary (`nexum_sdk::host`), so adapter logic written -//! against the seams is unit-testable host-free and reuses the -//! `nexum-sdk` chain helpers unchanged. The wrappers only translate; -//! scoping is the host's: chain methods pass through the host's -//! permitted read-only surface, messaging is confined to the adapter's -//! `messaging_topics`, and HTTP to its `http_allow` list, each refusal -//! surfacing as a typed `denied`. - -use core::time::Duration; - -use nexum_sdk::host::{ChainError, ChainHost, Fault, RpcError}; -use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; - -use crate::bindings::nexum::host::{chain, messaging}; -use crate::faults::fault_into_sdk; - -/// Outbound HTTP for adapters: the SDK's wasi:http surface re-exported. -/// [`fetch`](nexum_sdk::http::Fetch::fetch) speaks the standard `http` -/// crate's request/response types; an off-allowlist request fails as -/// [`FetchError::Denied`], which -/// converts into [`VenueError`](crate::VenueError) via `?`. -pub use nexum_sdk::http; - -/// Per-request timeout middleware over any [`Fetch`]: every request -/// through it, including plain [`Fetch::fetch`], carries phase timeouts -/// clamped to the configured bound, so a hung endpoint errors within it -/// rather than stalling the adapter's export call. -#[derive(Clone, Copy, Debug)] -pub struct TimedFetch { - inner: F, - bound: Duration, -} - -impl TimedFetch { - /// Bound every phase (connect, first byte, between bytes) of every - /// request to at most `bound`. - pub const fn new(inner: F, bound: Duration) -> Self { - Self { inner, bound } - } -} - -impl Fetch for TimedFetch { - fn fetch_with( - &self, - request: ::http::Request>, - options: FetchOptions, - ) -> Result<::http::Response>, FetchError> { - self.inner.fetch_with( - request, - FetchOptions { - connect_timeout: options.connect_timeout.min(self.bound), - first_byte_timeout: options.first_byte_timeout.min(self.bound), - between_bytes_timeout: options.between_bytes_timeout.min(self.bound), - }, - ) - } -} - -/// The adapter's `nexum:host/chain` import behind the SDK's -/// [`ChainHost`] seam. Unit-struct handle: hold it where strategy logic -/// takes `&impl ChainHost` and slot a mock in host-side tests. -#[derive(Clone, Copy, Debug, Default)] -pub struct HostChain; - -impl ChainHost for HostChain { - fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { - chain::request(chain_id, method, params).map_err(chain_error_into_sdk) - } -} - -impl HostChain { - /// Execute several JSON-RPC requests against one chain in a single - /// round trip where the host transport supports it. Entries are - /// independent: the outer error is the batch failing to execute at - /// all, the per-entry results carry each call's own outcome, in - /// request order. - pub fn request_batch( - &self, - chain_id: u64, - requests: &[RpcRequest], - ) -> Result>, ChainError> { - let wire: Vec = requests - .iter() - .map(|req| chain::RpcRequest { - method: req.method.clone(), - params: req.params.clone(), - }) - .collect(); - let results = chain::request_batch(chain_id, &wire).map_err(chain_error_into_sdk)?; - Ok(results - .into_iter() - .map(|result| match result { - chain::RpcResult::Ok(value) => Ok(value), - chain::RpcResult::Err(err) => Err(chain_error_into_sdk(err)), - }) - .collect()) - } -} - -/// One JSON-RPC call inside a [`HostChain::request_batch`], mirrored -/// from `nexum:host/chain.rpc-request`. `method` carries its namespace -/// prefix (`eth_call`); `params` is the JSON-encoded positional array. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RpcRequest { - /// JSON-RPC method, namespace prefix included. - pub method: String, - /// JSON-encoded params array. - pub params: String, -} - -/// Lift the wire chain error into the SDK-neutral [`ChainError`]. -/// Exhaustive on both the fault vocabulary and the rpc-error shape. -fn chain_error_into_sdk(err: chain::ChainError) -> ChainError { - match err { - chain::ChainError::Fault(fault) => ChainError::Fault(fault_into_sdk(fault)), - chain::ChainError::Rpc(rpc) => ChainError::Rpc(RpcError { - code: rpc.code, - message: rpc.message, - data: rpc.data.map(Into::into), - }), - } -} - -/// The messaging seam and its message mirror, canonical in the module -/// SDK; [`HostMessaging`] is this crate's bound impl. -pub use nexum_sdk::host::{Message, MessagingHost}; - -/// The adapter's `nexum:host/messaging` import behind the -/// [`MessagingHost`] seam. -#[derive(Clone, Copy, Debug, Default)] -pub struct HostMessaging; - -impl MessagingHost for HostMessaging { - fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { - messaging::publish(content_topic, payload).map_err(fault_into_sdk) - } - - fn query( - &self, - content_topic: &str, - start_time: Option, - end_time: Option, - limit: Option, - ) -> Result, Fault> { - let messages = - messaging::query(content_topic, start_time, end_time, limit).map_err(fault_into_sdk)?; - Ok(messages.into_iter().map(Message::from).collect()) - } -} - -impl From for Message { - fn from(message: crate::bindings::nexum::host::types::Message) -> Self { - Self { - content_topic: message.content_topic, - payload: message.payload, - timestamp: message.timestamp, - sender: message.sender, - } - } -} - -#[cfg(test)] -mod tests { - use core::cell::Cell; - use core::time::Duration; - - use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; - - use super::TimedFetch; - - struct Spy { - seen: Cell>, - } - - impl Fetch for Spy { - fn fetch_with( - &self, - _request: http::Request>, - options: FetchOptions, - ) -> Result>, FetchError> { - self.seen.set(Some(options)); - Ok(http::Response::new(Vec::new())) - } - } - - fn request() -> http::Request> { - http::Request::get("https://api.cow.fi/") - .body(Vec::new()) - .expect("test request builds") - } - - #[test] - fn plain_fetch_is_bounded() { - let bound = Duration::from_secs(5); - let timed = TimedFetch::new( - Spy { - seen: Cell::new(None), - }, - bound, - ); - timed.fetch(request()).expect("spy accepts"); - let seen = timed.inner.seen.get().expect("options recorded"); - assert_eq!(seen.connect_timeout, bound); - assert_eq!(seen.first_byte_timeout, bound); - assert_eq!(seen.between_bytes_timeout, bound); - } - - #[test] - fn caller_options_clamp_to_the_bound_but_tighter_ones_pass() { - let timed = TimedFetch::new( - Spy { - seen: Cell::new(None), - }, - Duration::from_secs(5), - ); - timed - .fetch_with( - request(), - FetchOptions { - connect_timeout: Duration::from_secs(60), - first_byte_timeout: Duration::from_secs(1), - between_bytes_timeout: Duration::from_secs(60), - }, - ) - .expect("spy accepts"); - let seen = timed.inner.seen.get().expect("options recorded"); - assert_eq!(seen.connect_timeout, Duration::from_secs(5)); - assert_eq!(seen.first_byte_timeout, Duration::from_secs(1)); - assert_eq!(seen.between_bytes_timeout, Duration::from_secs(5)); - } -} diff --git a/videre/crates/videre-sdk/tests/adapter.rs b/videre/crates/videre-sdk/tests/adapter.rs deleted file mode 100644 index 0c490331..00000000 --- a/videre/crates/videre-sdk/tests/adapter.rs +++ /dev/null @@ -1,314 +0,0 @@ -//! Acceptance surface for the venue SDK: a hand-written adapter -//! compiles against [`VenueAdapter`] and round-trips a versioned body -//! through `#[derive(IntentBody)]` - including the typed -//! unknown-version failure and the typed [`VenueClient`] driving the -//! adapter through the [`VenueTransport`] seam. The world-export glue -//! is `#[videre_sdk::venue]`'s alone; echo-venue is its worked target. - -use borsh::{BorshDeserialize, BorshSerialize}; -use videre_sdk::value_flow::{Asset, AssetAmount}; -use videre_sdk::{ - AuthScheme, BodyError, ClientError, Config, Fault, IntentBody, IntentHeader, IntentStatus, - Quotation, Settlement, SubmitOutcome, Venue, VenueAdapter, VenueClient, VenueError, VenueFault, - VenueId, VenueTransport, -}; - -/// Drive a client future on the test's synchronous boundary. -fn run(future: F) -> F::Output { - videre_sdk::rt::complete(future).expect("client futures complete in one poll") -} - -/// First published body version: a fixed-price quote. -#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] -struct QuoteV1 { - amount_wei: u64, - memo: String, -} - -/// Second published version: v1 plus an expiry. -#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] -struct QuoteV2 { - amount_wei: u64, - memo: String, - valid_until_ms: Option, -} - -/// The outer per-venue version enum: the schema the demo venue -/// publishes. Tag order is the schema; versions append. -#[derive(IntentBody, Clone, Debug, PartialEq, Eq)] -enum QuoteBody { - V1(QuoteV1), - V2(QuoteV2), -} - -/// The hand-written adapter: enough venue to exercise every trait -/// function without a live transport. -struct DemoAdapter; - -/// The receipt the demo venue issues for every accepted intent. -const RECEIPT: [u8; 4] = [0xA5, 0x5A, 0xC3, 0x3C]; - -impl DemoAdapter { - fn decode(body: &[u8]) -> Result<(u64, Option), VenueError> { - // `BodyError` converts through `?`: malformed and - // unknown-version bodies surface as `invalid-body`. - let body = QuoteBody::from_bytes(body)?; - Ok(match body { - QuoteBody::V1(quote) => (quote.amount_wei, None), - QuoteBody::V2(quote) => (quote.amount_wei, quote.valid_until_ms), - }) - } -} - -impl VenueAdapter for DemoAdapter { - fn init(_config: Config) -> Result<(), Fault> { - Ok(()) - } - - fn derive_header(body: Vec) -> Result { - let (amount_wei, _valid_until_ms) = Self::decode(&body)?; - Ok(IntentHeader { - gives: AssetAmount { - asset: Asset::Native, - amount: amount_wei.to_be_bytes().to_vec(), - }, - wants: AssetAmount { - asset: Asset::Native, - amount: Vec::new(), - }, - settlement: Settlement { chain: 1 }, - authorisation: AuthScheme::Eip712, - }) - } - - fn quote(body: Vec) -> Result { - let (amount_wei, valid_until_ms) = Self::decode(&body)?; - let zero = AssetAmount { - asset: Asset::Native, - amount: Vec::new(), - }; - Ok(Quotation { - gives: AssetAmount { - asset: Asset::Native, - amount: amount_wei.to_be_bytes().to_vec(), - }, - wants: zero.clone(), - fee: zero, - valid_until_ms: valid_until_ms.unwrap_or(u64::MAX), - }) - } - - fn submit(body: Vec) -> Result { - Self::decode(&body)?; - Ok(SubmitOutcome::Accepted(RECEIPT.to_vec())) - } - - fn status(receipt: Vec) -> Result { - if receipt == RECEIPT { - Ok(IntentStatus::Open) - } else { - // A receipt this venue never issued can never succeed, so the - // refusal is the non-retryable case. - Err(VenueError::Denied( - "receipt not issued by this venue".into(), - )) - } - } - - fn cancel(receipt: Vec) -> Result<(), VenueError> { - Self::status(receipt).map(|_| ()) - } -} - -/// The demo venue as a keeper types it. -struct DemoVenue; - -impl Venue for DemoVenue { - const ID: VenueId = VenueId::from_static("demo"); - type Body = QuoteBody; -} - -/// A venue id no adapter answers for, over the same body schema. -struct NowhereVenue; - -impl Venue for NowhereVenue { - const ID: VenueId = VenueId::from_static("nowhere"); - type Body = QuoteBody; -} - -/// In-process transport: routes the demo venue id straight into the -/// adapter, standing in for the host registry the keeper-side seam -/// binds. -struct InProcessClient; - -impl videre_sdk::client::sealed::SealedTransport for InProcessClient {} - -impl VenueTransport for InProcessClient { - async fn quote(&self, venue: &VenueId, body: Vec) -> Result { - if venue.as_str() != "demo" { - return Err(VenueFault::UnknownVenue); - } - DemoAdapter::quote(body).map_err(Into::into) - } - - async fn submit(&self, venue: &VenueId, body: Vec) -> Result { - if venue.as_str() != "demo" { - return Err(VenueFault::UnknownVenue); - } - DemoAdapter::submit(body).map_err(Into::into) - } - - async fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result { - if venue.as_str() != "demo" { - return Err(VenueFault::UnknownVenue); - } - DemoAdapter::status(receipt.to_vec()).map_err(Into::into) - } - - async fn cancel(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { - if venue.as_str() != "demo" { - return Err(VenueFault::UnknownVenue); - } - DemoAdapter::cancel(receipt.to_vec()).map_err(Into::into) - } -} - -fn v2_body() -> QuoteBody { - QuoteBody::V2(QuoteV2 { - amount_wei: 1_000_000, - memo: "two coffees".to_owned(), - valid_until_ms: Some(1_700_000_000_000), - }) -} - -#[test] -fn versioned_body_round_trips_through_the_derive() { - for body in [ - QuoteBody::V1(QuoteV1 { - amount_wei: 42, - memo: "one".to_owned(), - }), - v2_body(), - ] { - let bytes = body.to_bytes().expect("derived payloads encode"); - assert_eq!(QuoteBody::from_bytes(&bytes).unwrap(), body); - } -} - -#[test] -fn wire_tag_is_the_declaration_index() { - let v1 = QuoteBody::V1(QuoteV1 { - amount_wei: 1, - memo: String::new(), - }) - .to_bytes() - .unwrap(); - let v2 = v2_body().to_bytes().unwrap(); - assert_eq!(v1[0], 0); - assert_eq!(v2[0], 1); -} - -#[test] -fn unknown_version_fails_typedly() { - let mut bytes = v2_body().to_bytes().unwrap(); - bytes[0] = 9; - assert_eq!( - QuoteBody::from_bytes(&bytes), - Err(BodyError::UnknownVersion { version: 9 }) - ); -} - -#[test] -fn empty_and_malformed_bodies_fail_typedly() { - assert_eq!(QuoteBody::from_bytes(&[]), Err(BodyError::Empty)); - - // A known tag with a truncated payload. - let mut bytes = v2_body().to_bytes().unwrap(); - bytes.truncate(bytes.len() - 1); - assert!(matches!( - QuoteBody::from_bytes(&bytes), - Err(BodyError::Malformed { version: 1, .. }) - )); - - // A known tag with trailing bytes: borsh requires full consumption. - let mut bytes = v2_body().to_bytes().unwrap(); - bytes.push(0); - assert!(matches!( - QuoteBody::from_bytes(&bytes), - Err(BodyError::Malformed { version: 1, .. }) - )); -} - -#[test] -fn adapter_projects_the_header_from_a_versioned_body() { - let bytes = v2_body().to_bytes().unwrap(); - let header = DemoAdapter::derive_header(bytes).unwrap(); - assert_eq!(header.gives.asset, Asset::Native); - assert_eq!(header.gives.amount, 1_000_000u64.to_be_bytes().to_vec()); - assert_eq!(header.settlement, Settlement { chain: 1 }); - assert_eq!(header.authorisation, AuthScheme::Eip712); -} - -#[test] -fn adapter_reports_an_unknown_version_as_invalid_body() { - let mut bytes = v2_body().to_bytes().unwrap(); - bytes[0] = 7; - let err = DemoAdapter::derive_header(bytes).unwrap_err(); - match err { - VenueError::InvalidBody(detail) => assert!(detail.contains("unknown body version 7")), - other => panic!("expected invalid-body, got {other:?}"), - } -} - -#[test] -fn typed_client_round_trips_through_the_transport_seam() { - let client = VenueClient::::with_transport(InProcessClient); - assert_eq!(client.venue(), DemoVenue::ID); - - let outcome = run(client.submit(&v2_body())).unwrap(); - let SubmitOutcome::Accepted(receipt) = outcome else { - panic!("demo venue always accepts"); - }; - assert_eq!(receipt, RECEIPT.to_vec()); - - assert_eq!(run(client.status(&receipt)).unwrap(), IntentStatus::Open); - run(client.cancel(&receipt)).unwrap(); - - assert!(matches!( - run(client.status(&[0, 1])).unwrap_err(), - ClientError::Venue(VenueFault::Denied(_)) - )); -} - -#[test] -fn quote_typestate_prices_then_submits_the_quoted_body() { - async fn drive( - client: &VenueClient, - ) -> Result { - // The typestate chain under test: a quotation is the only path - // from a priced body to its submission. Static dispatch end to - // end: the transport is native AFIT, nothing boxes. - client.quote(&v2_body()).await?.submit().await - } - - let client = VenueClient::::with_transport(InProcessClient); - - let quoted = run(client.quote(&v2_body())).unwrap(); - assert_eq!( - quoted.quotation().gives.amount, - 1_000_000u64.to_be_bytes().to_vec() - ); - assert_eq!(quoted.quotation().valid_until_ms, 1_700_000_000_000); - - let outcome = run(drive(&client)).unwrap(); - assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == RECEIPT.to_vec())); -} - -#[test] -fn unbound_venue_is_unknown_at_the_client() { - let client = VenueClient::::with_transport(InProcessClient); - assert!(matches!( - run(client.submit(&v2_body())).unwrap_err(), - ClientError::Venue(VenueFault::UnknownVenue) - )); -} diff --git a/videre/crates/videre-test/Cargo.toml b/videre/crates/videre-test/Cargo.toml deleted file mode 100644 index 150846aa..00000000 --- a/videre/crates/videre-test/Cargo.toml +++ /dev/null @@ -1,40 +0,0 @@ -[package] -name = "videre-test" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Conformance kit for venue adapters: file-published borsh codec round-trip vectors, header-derivation golden fixtures, and an in-memory MockTransport for adapter unit tests." - -[lib] -# Plain library, host-only - adapter crates list this under -# [dev-dependencies] so it never ships in the wasm bundle. - -[lints] -workspace = true - -[dependencies] -# The reference schema's payload structs derive the borsh traits, the -# same way a real venue's payload types do. -borsh.workspace = true -# Vector and golden files carry byte fields as lowercase hex so a -# non-Rust author can read them without a borsh decoder. -hex.workspace = true -# `MockFetch` speaks the standard `http` request/response types the -# SDK's `Fetch` seam is expressed in. -http.workspace = true -# Transport seam vocabulary: `ChainHost`, `Fault`, and the `Fetch` seam -# the mocks implement. -nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } -# `MockChain` is composed rather than reimplemented: one chain mock -# serves both personas. -nexum-sdk-test = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } -# The contract under test: `IntentBody`, `BodyError`, the intent -# header types, and the `MessagingHost` seam. -videre-sdk = { path = "../videre-sdk" } -serde = { workspace = true } -serde_json.workspace = true -thiserror.workspace = true - -[dev-dependencies] -tempfile.workspace = true diff --git a/videre/crates/videre-test/goldens/reference-header.json b/videre/crates/videre-test/goldens/reference-header.json deleted file mode 100644 index c56703fd..00000000 --- a/videre/crates/videre-test/goldens/reference-header.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "version": 1, - "venue": "videre-test/reference", - "goldens": [ - { - "name": "v1-small", - "body": "00010000000000000002000000676d", - "header": { - "gives": { - "asset": "native", - "amount": "01" - }, - "wants": { - "asset": "native", - "amount": "" - }, - "settlement": { - "chain": 1 - }, - "authorisation": "eip712" - }, - "notes": "gives chain-1 native token, minimal big-endian amount" - }, - { - "name": "v2-full", - "body": "0140420f00000000000b00000074776f20636f6666656573010068e5cf8b010000140000000102030405060708090a0b0c0d0e0f101112131401", - "header": { - "gives": { - "asset": "native", - "amount": "0f4240" - }, - "wants": { - "asset": { - "erc20": { - "token": "0102030405060708090a0b0c0d0e0f1011121314" - } - }, - "amount": "0f4240" - }, - "settlement": { - "chain": 1 - }, - "authorisation": "eip712" - }, - "notes": "v2 adds an erc20 want at the recipient token address" - } - ] -} diff --git a/videre/crates/videre-test/src/codec.rs b/videre/crates/videre-test/src/codec.rs deleted file mode 100644 index 538db948..00000000 --- a/videre/crates/videre-test/src/codec.rs +++ /dev/null @@ -1,404 +0,0 @@ -//! Codec conformance vectors: the file format that publishes a venue's -//! `IntentBody` wire bytes, and the check that holds a codec to them. -//! -//! A vector file is the venue's codec contract in portable form: JSON, -//! a leading format version (unknown versions fail closed), bytes as -//! lowercase hex, one entry per published body, never zero. A non-Rust -//! adapter author proves byte-exactness by decoding and re-encoding -//! each `round-trip` vector in their own language and comparing bytes; -//! a Rust author runs [`CodecVectors::assert_conforms`] against the -//! derived enum. The failure vectors pin the typed error contract: -//! empty, unknown-version, and malformed bodies must fail exactly as -//! [`BodyError`] names them, not garble into a decoded value. - -use std::path::Path; - -use serde::{Deserialize, Serialize}; -use videre_sdk::{BodyError, IntentBody}; - -use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; -use crate::report::{ConformanceReport, Violation, settle}; - -/// A published set of codec vectors for one venue body schema. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CodecVectors { - /// File-format discriminator; an unknown version fails to parse. - pub version: FormatVersion, - /// Name of the body schema the vectors bind, e.g. - /// `acme-dex/order-body`. Informational: the check never reads it. - pub schema: String, - /// The vectors, in publication order. Never empty in a parsed - /// file: an empty set would conform vacuously. - #[serde(deserialize_with = "fixture::non_empty")] - pub vectors: Vec, -} - -/// One published wire body and the outcome its bytes must produce. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CodecVector { - /// Stable name a violation is reported under. - pub name: String, - /// The wire bytes, lowercase hex in the file. - #[serde(with = "hex_bytes")] - pub bytes: Vec, - /// What a conforming codec does with the bytes. - pub expect: Expectation, - /// Optional prose for readers of the file; the check ignores it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub notes: Option, -} - -/// The outcome a vector demands of a conforming codec. The failure -/// cases mirror [`BodyError`] minus its free-text detail: the detail -/// wording is the Rust implementation's, not part of the contract. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum Expectation { - /// The bytes decode, and re-encoding the decoded value reproduces - /// them exactly. - RoundTrip, - /// Decoding fails: no version tag at all. - Empty, - /// Decoding fails: the tag names no published version. - UnknownVersion { - /// The unknown wire tag. - version: u8, - }, - /// Decoding fails: a known tag whose payload does not parse. - Malformed { - /// The wire tag whose payload is broken. - version: u8, - }, -} - -impl std::fmt::Display for Expectation { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Expectation::RoundTrip => f.write_str("round-trip"), - Expectation::Empty => f.write_str("empty"), - Expectation::UnknownVersion { version } => write!(f, "unknown-version {version}"), - Expectation::Malformed { version } => write!(f, "malformed {version}"), - } - } -} - -impl CodecVectors { - /// An empty vector set for `schema`. Push at least one vector - /// before publishing: a parsed file is never empty. - pub fn new(schema: impl Into) -> Self { - Self { - version: FormatVersion, - schema: schema.into(), - vectors: Vec::new(), - } - } - - /// Append a round-trip vector by encoding `body` through the codec - /// under publication. Returns the pushed vector so the caller can - /// attach [`notes`](CodecVector::notes). - pub fn push_round_trip( - &mut self, - name: impl Into, - body: &B, - ) -> Result<&mut CodecVector, BodyError> { - let bytes = body.to_bytes()?; - self.vectors.push(CodecVector { - name: name.into(), - bytes, - expect: Expectation::RoundTrip, - notes: None, - }); - Ok(self.vectors.last_mut().expect("vector was just pushed")) - } - - /// Append a failure vector: raw bytes plus the typed decode error - /// they must produce. - /// - /// # Panics - /// - /// On [`Expectation::RoundTrip`]; round-trip vectors are encoded - /// from a typed body via [`push_round_trip`](Self::push_round_trip) - /// so their bytes are canonical by construction. - pub fn push_failure( - &mut self, - name: impl Into, - bytes: Vec, - expect: Expectation, - ) -> &mut CodecVector { - assert!( - expect != Expectation::RoundTrip, - "push_failure takes a failure expectation; use push_round_trip", - ); - self.vectors.push(CodecVector { - name: name.into(), - bytes, - expect, - notes: None, - }); - self.vectors.last_mut().expect("vector was just pushed") - } - - /// Parse a vector set from its JSON text. - pub fn from_json(json: &str) -> Result { - fixture::from_json(json) - } - - /// The canonical published form: pretty JSON, trailing newline. - pub fn to_json(&self) -> String { - fixture::to_json(self) - } - - /// Load a vector file from disk. - pub fn load(path: impl AsRef) -> Result { - fixture::load(path.as_ref()) - } - - /// Write the vector file in its canonical published form. - pub fn write(&self, path: impl AsRef) -> Result<(), FixtureError> { - fixture::write(path.as_ref(), self) - } - - /// Check a codec against every vector, collecting all violations - /// rather than stopping at the first. - /// - /// A `round-trip` vector must decode and re-encode to the exact - /// published bytes; a failure vector must produce the matching - /// [`BodyError`] case (the free-text detail is not compared). An - /// empty set is itself a violation: it would conform vacuously. - pub fn check(&self) -> Result<(), ConformanceReport> { - let mut violations = Vec::new(); - if self.vectors.is_empty() { - violations.push(Violation { - vector: "".to_owned(), - detail: "published vector set is empty".to_owned(), - }); - } - for vector in &self.vectors { - if let Err(detail) = vector.check::() { - violations.push(Violation { - vector: vector.name.clone(), - detail, - }); - } - } - settle(violations) - } - - /// [`check`](Self::check), panicking with the full report on any - /// violation. The assertion form for adapter test suites. - pub fn assert_conforms(&self) { - if let Err(report) = self.check::() { - panic!("codec does not conform to {}:\n{report}", self.schema); - } - } -} - -impl CodecVector { - /// Check one vector, returning the violation detail on divergence. - fn check(&self) -> Result<(), String> { - let decoded = B::from_bytes(&self.bytes); - match (&self.expect, decoded) { - (Expectation::RoundTrip, Ok(body)) => { - let reencoded = body - .to_bytes() - .map_err(|err| format!("re-encode failed: {err}"))?; - if reencoded == self.bytes { - Ok(()) - } else { - Err(format!( - "re-encoded bytes diverge from the published vector: published {}, re-encoded {}", - hex::encode(&self.bytes), - hex::encode(&reencoded), - )) - } - } - (Expectation::RoundTrip, Err(err)) => { - Err(format!("expected a round trip, decode failed: {err}")) - } - (expect, Ok(_)) => Err(format!("expected {expect}, decode succeeded")), - (expect, Err(err)) => { - let matches = match (expect, &err) { - (Expectation::Empty, BodyError::Empty) => true, - ( - Expectation::UnknownVersion { version }, - BodyError::UnknownVersion { version: got }, - ) => version == got, - ( - Expectation::Malformed { version }, - BodyError::Malformed { version: got, .. }, - ) => version == got, - _ => false, - }; - if matches { - Ok(()) - } else { - Err(format!("expected {expect}, got: {err}")) - } - } - } - } -} - -#[cfg(test)] -mod tests { - use borsh::{BorshDeserialize, BorshSerialize}; - use videre_sdk::IntentBody; - - use super::*; - - #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] - struct PayloadV1 { - amount: u64, - memo: String, - } - - #[derive(IntentBody, Clone, Debug, PartialEq, Eq)] - enum Body { - V1(PayloadV1), - } - - /// A codec with a diverging payload layout for the same tag. - #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] - struct NarrowPayload { - amount: u32, - memo: String, - } - - #[derive(IntentBody, Clone, Debug, PartialEq, Eq)] - enum NarrowBody { - V1(NarrowPayload), - } - - fn published() -> CodecVectors { - let mut vectors = CodecVectors::new("test/body"); - vectors - .push_round_trip( - "v1", - &Body::V1(PayloadV1 { - amount: 7, - memo: "gm".to_owned(), - }), - ) - .unwrap(); - vectors.push_failure("empty", Vec::new(), Expectation::Empty); - vectors.push_failure( - "unknown-version", - vec![9, 0, 0], - Expectation::UnknownVersion { version: 9 }, - ); - vectors.push_failure( - "truncated", - vec![0, 7], - Expectation::Malformed { version: 0 }, - ); - vectors - } - - #[test] - fn conforming_codec_passes_every_vector() { - published().check::().unwrap(); - } - - #[test] - fn diverging_codec_fails_with_named_vectors() { - let report = published().check::().unwrap_err(); - // The v1 payload no longer parses (u32 vs u64 layout); the - // failure vectors still fail as published, so the report names - // exactly the diverging vector. - assert_eq!(report.violations.len(), 1, "violations: {report}"); - assert_eq!(report.violations[0].vector, "v1"); - assert!(report.violations[0].detail.contains("decode failed")); - } - - #[test] - #[should_panic(expected = "codec does not conform")] - fn assert_conforms_panics_with_the_report() { - published().assert_conforms::(); - } - - #[test] - #[should_panic(expected = "push_failure takes a failure expectation")] - fn push_failure_rejects_round_trip() { - CodecVectors::new("test/body").push_failure("bad", Vec::new(), Expectation::RoundTrip); - } - - #[test] - fn json_form_is_stable_and_round_trips() { - let mut vectors = published(); - vectors.vectors[0].notes = Some("first published body".to_owned()); - let json = vectors.to_json(); - assert_eq!(CodecVectors::from_json(&json).unwrap(), vectors); - // The wire spellings are the contract for non-Rust readers. - assert!(json.contains("\"version\": 1")); - assert!(json.contains("\"round-trip\"")); - assert!(json.contains("\"unknown-version\"")); - assert!(json.contains("\"notes\": \"first published body\"")); - assert!(!json.contains("null"), "absent notes are omitted: {json}"); - } - - #[test] - fn files_round_trip_through_disk() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("vectors.json"); - let vectors = published(); - vectors.write(&path).unwrap(); - assert_eq!(CodecVectors::load(&path).unwrap(), vectors); - } - - #[test] - fn malformed_file_fails_typedly() { - assert!(matches!( - CodecVectors::from_json("{"), - Err(FixtureError::Format(_)), - )); - assert!(matches!( - CodecVectors::load("/nonexistent/vectors.json"), - Err(FixtureError::Read { .. }), - )); - } - - #[test] - fn unknown_format_version_fails_closed() { - let json = published() - .to_json() - .replace("\"version\": 1", "\"version\": 2"); - let Err(FixtureError::Format(detail)) = CodecVectors::from_json(&json) else { - panic!("version 2 must not parse"); - }; - assert!(detail.contains("unknown fixture format version 2")); - } - - #[test] - fn missing_format_version_fails() { - let json = published().to_json().replace(" \"version\": 1,\n", ""); - assert!(matches!( - CodecVectors::from_json(&json), - Err(FixtureError::Format(_)), - )); - } - - #[test] - fn empty_vector_set_fails_the_check() { - let report = CodecVectors::new("test/body").check::().unwrap_err(); - assert_eq!(report.violations.len(), 1, "violations: {report}"); - assert_eq!(report.violations[0].vector, ""); - assert!(report.violations[0].detail.contains("empty")); - } - - #[test] - #[should_panic(expected = "codec does not conform")] - fn assert_conforms_rejects_an_empty_set() { - CodecVectors::new("test/body").assert_conforms::(); - } - - #[test] - fn empty_vector_set_fails_to_parse() { - let json = CodecVectors::new("test/body").to_json(); - let Err(FixtureError::Format(detail)) = CodecVectors::from_json(&json) else { - panic!("an empty set must not parse"); - }; - assert!(detail.contains("never empty")); - } -} diff --git a/videre/crates/videre-test/src/fixture.rs b/videre/crates/videre-test/src/fixture.rs deleted file mode 100644 index 6b2b5118..00000000 --- a/videre/crates/videre-test/src/fixture.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! The shared fixture-file plumbing: JSON on disk, a leading -//! [`FormatVersion`] (unknown versions fail closed), byte fields as -//! lowercase hex, non-empty entry lists, and the typed -//! [`FixtureError`] both file formats load and save through. - -use std::path::Path; - -use serde::de::{DeserializeOwned, Error as _}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -/// The one published fixture file-format version. -const FORMAT_VERSION: u32 = 1; - -/// Fixture file-format discriminator: serializes as the current -/// version, refuses any other on parse (fail-closed), so a reader -/// never guesses at a future layout. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub struct FormatVersion; - -impl Serialize for FormatVersion { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_u32(FORMAT_VERSION) - } -} - -impl<'de> Deserialize<'de> for FormatVersion { - fn deserialize>(deserializer: D) -> Result { - let version = u32::deserialize(deserializer)?; - if version == FORMAT_VERSION { - Ok(Self) - } else { - Err(D::Error::custom(format!( - "unknown fixture format version {version}; this reader speaks {FORMAT_VERSION}", - ))) - } - } -} - -/// Deserialize a fixture's entry list, refusing an empty one: an empty -/// set would conform vacuously. -pub(crate) fn non_empty<'de, D, T>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, - T: Deserialize<'de>, -{ - let entries = Vec::::deserialize(deserializer)?; - if entries.is_empty() { - return Err(D::Error::custom("a published fixture set is never empty")); - } - Ok(entries) -} - -/// Why a fixture file failed to load or save. The JSON case carries -/// serde's rendered detail rather than the error value so the type -/// stays independent of `serde_json`'s feature set. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum FixtureError { - /// The file could not be read. - #[error("failed to read {path}: {source}")] - Read { - /// Path the read targeted. - path: String, - /// The underlying io failure. - source: std::io::Error, - }, - /// The file could not be written. - #[error("failed to write {path}: {source}")] - Write { - /// Path the write targeted. - path: String, - /// The underlying io failure. - source: std::io::Error, - }, - /// The content did not parse as the fixture format. - #[error("malformed fixture json: {0}")] - Format(String), -} - -/// Render a fixture as its canonical published form: pretty-printed -/// JSON with a trailing newline, so a regenerated file diffs cleanly. -pub(crate) fn to_json(value: &T) -> String { - let mut json = serde_json::to_string_pretty(value).expect("fixture types serialize infallibly"); - json.push('\n'); - json -} - -/// Parse a fixture from its JSON text. -pub(crate) fn from_json(json: &str) -> Result { - serde_json::from_str(json).map_err(|err| FixtureError::Format(err.to_string())) -} - -/// Load a fixture file from disk. -pub(crate) fn load(path: &Path) -> Result { - let json = std::fs::read_to_string(path).map_err(|source| FixtureError::Read { - path: path.display().to_string(), - source, - })?; - from_json(&json) -} - -/// Write a fixture file in its canonical published form. -pub(crate) fn write(path: &Path, value: &T) -> Result<(), FixtureError> { - std::fs::write(path, to_json(value)).map_err(|source| FixtureError::Write { - path: path.display().to_string(), - source, - }) -} - -/// Serde codec for byte fields: lowercase hex, no prefix, so the file -/// is legible without a borsh decoder. -pub(crate) mod hex_bytes { - use serde::de::Error as _; - use serde::{Deserialize, Deserializer, Serializer}; - - pub(crate) fn serialize(bytes: &[u8], serializer: S) -> Result { - serializer.serialize_str(&hex::encode(bytes)) - } - - pub(crate) fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result, D::Error> { - let text = String::deserialize(deserializer)?; - hex::decode(&text).map_err(D::Error::custom) - } -} diff --git a/videre/crates/videre-test/src/header.rs b/videre/crates/videre-test/src/header.rs deleted file mode 100644 index 91063dc4..00000000 --- a/videre/crates/videre-test/src/header.rs +++ /dev/null @@ -1,417 +0,0 @@ -//! Header-derivation goldens: the file format that publishes what -//! `derive-header` must project from each published body, and the -//! check that holds an adapter to it. -//! -//! A golden file pairs wire bodies with the intent header a conforming -//! adapter derives from them, spelled in the golden mirror types below -//! (JSON, a leading format version that fails closed on an unknown tag, -//! kebab-case case names matching the WIT, bytes as lowercase hex, -//! never zero goldens). The mirrors exist because wit-bindgen types -//! carry no serde; [`GoldenHeader`] converts from the venue SDK's -//! `IntentHeader`, which macro-built adapters speak too, so an -//! adapter's `derive_header` feeds the check directly. - -use std::fmt; -use std::path::Path; - -use serde::{Deserialize, Serialize}; -use videre_sdk::value_flow::{Asset, AssetAmount}; -use videre_sdk::{AuthScheme, IntentHeader, Settlement}; - -use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; -use crate::report::{ConformanceReport, Violation, settle}; - -/// A published set of header goldens for one venue. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct HeaderGoldens { - /// File-format discriminator; an unknown version fails to parse. - pub version: FormatVersion, - /// The venue the goldens bind. Informational: the check never - /// reads it. - pub venue: String, - /// The goldens, in publication order. Never empty in a parsed - /// file: an empty set would conform vacuously. - #[serde(deserialize_with = "fixture::non_empty")] - pub goldens: Vec, -} - -/// One wire body and the header a conforming adapter derives from it. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct HeaderGolden { - /// Stable name a violation is reported under. - pub name: String, - /// The intent body, lowercase hex in the file. - #[serde(with = "hex_bytes")] - pub body: Vec, - /// The header `derive-header` must produce for the body. - pub header: GoldenHeader, - /// Optional prose for readers of the file; the check ignores it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub notes: Option, -} - -/// Serde mirror of the wire `intent-header`. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", deny_unknown_fields)] -pub struct GoldenHeader { - /// Value leaving the user's control. - pub gives: GoldenAssetAmount, - /// Value expected in return. Display-grade, not host-verified. - pub wants: GoldenAssetAmount, - /// Where the deal settles. - pub settlement: GoldenSettlement, - /// How the venue authorises the intent. - pub authorisation: GoldenAuthScheme, -} - -/// Serde mirror of the wire `asset-amount`. `amount` is big-endian -/// unsigned, minimal-length, hex in the file; an empty string is zero. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct GoldenAssetAmount { - /// The asset moving. - pub asset: GoldenAsset, - /// Big-endian minimal-length unsigned amount bytes. - #[serde(with = "hex_bytes")] - pub amount: Vec, -} - -/// Serde mirror of the wire `settlement`. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct GoldenSettlement { - /// EVM chain id the deal settles on. - pub chain: u64, -} - -/// Serde mirror of the wire `asset`. Token addresses are hex in the file. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde( - rename_all = "kebab-case", - rename_all_fields = "kebab-case", - deny_unknown_fields -)] -pub enum GoldenAsset { - /// The settlement chain's gas token. - Native, - /// An ERC-20 token on the settlement chain. - Erc20 { - /// 20-byte contract address. - #[serde(with = "hex_bytes")] - token: Vec, - }, -} - -/// Serde mirror of the wire `auth-scheme`. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum GoldenAuthScheme { - /// EIP-1271 contract signature. - Eip1271, - /// EIP-712 typed-data signature by host-held keys. - Eip712, -} - -impl From for GoldenHeader { - fn from(header: IntentHeader) -> Self { - Self { - gives: header.gives.into(), - wants: header.wants.into(), - settlement: header.settlement.into(), - authorisation: header.authorisation.into(), - } - } -} - -impl From for GoldenAssetAmount { - fn from(amount: AssetAmount) -> Self { - Self { - asset: amount.asset.into(), - amount: amount.amount, - } - } -} - -impl From for GoldenSettlement { - fn from(settlement: Settlement) -> Self { - Self { - chain: settlement.chain, - } - } -} - -impl From for GoldenAsset { - fn from(asset: Asset) -> Self { - match asset { - Asset::Native => GoldenAsset::Native, - Asset::Erc20(erc20) => GoldenAsset::Erc20 { token: erc20.token }, - } - } -} - -impl From for GoldenAuthScheme { - fn from(scheme: AuthScheme) -> Self { - match scheme { - AuthScheme::Eip1271 => GoldenAuthScheme::Eip1271, - AuthScheme::Eip712 => GoldenAuthScheme::Eip712, - } - } -} - -impl HeaderGoldens { - /// An empty golden set for `venue`. Record at least one golden - /// before publishing: a parsed file is never empty. - pub fn new(venue: impl Into) -> Self { - Self { - version: FormatVersion, - venue: venue.into(), - goldens: Vec::new(), - } - } - - /// Append a golden by running the publishing adapter's own - /// `derive-header` on `body`. Returns the pushed golden so the - /// caller can attach [`notes`](HeaderGolden::notes). - pub fn record( - &mut self, - name: impl Into, - body: Vec, - derive: impl FnOnce(Vec) -> Result, - ) -> Result<&mut HeaderGolden, E> - where - H: Into, - { - let header = derive(body.clone())?.into(); - self.goldens.push(HeaderGolden { - name: name.into(), - body, - header, - notes: None, - }); - Ok(self.goldens.last_mut().expect("golden was just pushed")) - } - - /// Parse a golden set from its JSON text. - pub fn from_json(json: &str) -> Result { - fixture::from_json(json) - } - - /// The canonical published form: pretty JSON, trailing newline. - pub fn to_json(&self) -> String { - fixture::to_json(self) - } - - /// Load a golden file from disk. - pub fn load(path: impl AsRef) -> Result { - fixture::load(path.as_ref()) - } - - /// Write the golden file in its canonical published form. - pub fn write(&self, path: impl AsRef) -> Result<(), FixtureError> { - fixture::write(path.as_ref(), self) - } - - /// Check an adapter's `derive-header` against every golden, - /// collecting all violations rather than stopping at the first. - /// - /// `derive` is the adapter's derivation; a trait-based adapter - /// passes `MyAdapter::derive_header` directly. An empty set is - /// itself a violation: it would conform vacuously. - pub fn check( - &self, - mut derive: impl FnMut(Vec) -> Result, - ) -> Result<(), ConformanceReport> - where - H: Into, - E: fmt::Debug, - { - let mut violations = Vec::new(); - if self.goldens.is_empty() { - violations.push(Violation { - vector: "".to_owned(), - detail: "published golden set is empty".to_owned(), - }); - } - for golden in &self.goldens { - match derive(golden.body.clone()) { - Ok(header) => { - let derived: GoldenHeader = header.into(); - if derived != golden.header { - violations.push(Violation { - vector: golden.name.clone(), - detail: format!( - "derived header diverges from the golden: expected {:?}, derived {derived:?}", - golden.header, - ), - }); - } - } - Err(err) => violations.push(Violation { - vector: golden.name.clone(), - detail: format!("derive-header failed: {err:?}"), - }), - } - } - settle(violations) - } - - /// [`check`](Self::check), panicking with the full report on any - /// violation. The assertion form for adapter test suites. - pub fn assert_conforms(&self, derive: impl FnMut(Vec) -> Result) - where - H: Into, - E: fmt::Debug, - { - if let Err(report) = self.check(derive) { - panic!( - "derive-header does not conform to the {} goldens:\n{report}", - self.venue, - ); - } - } -} - -#[cfg(test)] -mod tests { - use videre_sdk::VenueError; - use videre_sdk::value_flow::Erc20; - - use super::*; - - fn wire_header() -> IntentHeader { - IntentHeader { - gives: AssetAmount { - asset: Asset::Native, - amount: vec![0x0d, 0xe0, 0xb6], - }, - wants: AssetAmount { - asset: Asset::Erc20(Erc20 { - token: vec![0xAA; 20], - }), - amount: vec![1, 0], - }, - settlement: Settlement { chain: 100 }, - authorisation: AuthScheme::Eip1271, - } - } - - #[test] - fn golden_mirror_covers_every_wire_case_and_round_trips_as_json() { - let golden: GoldenHeader = wire_header().into(); - let goldens = HeaderGoldens { - version: FormatVersion, - venue: "acme".to_owned(), - goldens: vec![HeaderGolden { - name: "kitchen-sink".to_owned(), - body: vec![0], - header: golden, - notes: None, - }], - }; - let json = goldens.to_json(); - assert_eq!(HeaderGoldens::from_json(&json).unwrap(), goldens); - // The wire spellings are the contract for non-Rust readers. - assert!(json.contains("\"version\": 1")); - assert!(json.contains("\"native\"")); - assert!(json.contains("\"erc20\"")); - assert!(json.contains("\"token\"")); - assert!(json.contains("\"chain\"")); - assert!(json.contains("\"eip1271\"")); - } - - #[test] - fn conforming_derivation_passes() { - let mut goldens = HeaderGoldens::new("acme"); - goldens - .record("kitchen-sink", vec![1, 2, 3], |_| { - Ok::<_, VenueError>(wire_header()) - }) - .unwrap(); - goldens - .check(|_| Ok::<_, VenueError>(wire_header())) - .unwrap(); - } - - #[test] - fn diverging_derivation_and_failure_are_both_violations() { - let mut goldens = HeaderGoldens::new("acme"); - goldens - .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) - .unwrap(); - goldens - .record("b", vec![2], |_| Ok::<_, VenueError>(wire_header())) - .unwrap(); - - let mut calls = 0; - let report = goldens - .check(|_| { - calls += 1; - if calls == 1 { - let mut header = wire_header(); - header.authorisation = AuthScheme::Eip712; - Ok(header) - } else { - Err(VenueError::InvalidBody("nope".to_owned())) - } - }) - .unwrap_err(); - - assert_eq!(report.violations.len(), 2); - assert_eq!(report.violations[0].vector, "a"); - assert!(report.violations[0].detail.contains("diverges")); - assert_eq!(report.violations[1].vector, "b"); - assert!(report.violations[1].detail.contains("derive-header failed")); - } - - #[test] - #[should_panic(expected = "derive-header does not conform")] - fn assert_conforms_panics_with_the_report() { - let mut goldens = HeaderGoldens::new("acme"); - goldens - .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) - .unwrap(); - goldens.assert_conforms(|_| Err::(VenueError::Timeout)); - } - - #[test] - fn unknown_format_version_fails_closed() { - let mut goldens = HeaderGoldens::new("acme"); - goldens - .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) - .unwrap(); - let json = goldens - .to_json() - .replace("\"version\": 1", "\"version\": 7"); - let Err(FixtureError::Format(detail)) = HeaderGoldens::from_json(&json) else { - panic!("version 7 must not parse"); - }; - assert!(detail.contains("unknown fixture format version 7")); - } - - #[test] - fn empty_golden_set_fails_the_check() { - let report = HeaderGoldens::new("acme") - .check(|_| Ok::<_, VenueError>(wire_header())) - .unwrap_err(); - assert_eq!(report.violations.len(), 1, "violations: {report}"); - assert_eq!(report.violations[0].vector, ""); - assert!(report.violations[0].detail.contains("empty")); - } - - #[test] - #[should_panic(expected = "derive-header does not conform")] - fn assert_conforms_rejects_an_empty_set() { - HeaderGoldens::new("acme").assert_conforms(|_| Ok::<_, VenueError>(wire_header())); - } - - #[test] - fn empty_golden_set_fails_to_parse() { - let json = HeaderGoldens::new("acme").to_json(); - let Err(FixtureError::Format(detail)) = HeaderGoldens::from_json(&json) else { - panic!("an empty set must not parse"); - }; - assert!(detail.contains("never empty")); - } -} diff --git a/videre/crates/videre-test/src/lib.rs b/videre/crates/videre-test/src/lib.rs deleted file mode 100644 index 2485b000..00000000 --- a/videre/crates/videre-test/src/lib.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! # videre-test -//! -//! Conformance kit for venue adapters: file-published codec vectors, -//! header-derivation goldens, and an in-memory transport mock, so an -//! adapter proves its wire behaviour against fixtures any -//! implementation language can read. -//! -//! ## The three pieces -//! -//! - [`CodecVectors`] - the venue's `IntentBody` wire bytes as a JSON -//! file (bytes as lowercase hex). A Rust adapter checks its derived -//! enum with [`CodecVectors::assert_conforms`]; a non-Rust author -//! reads the same file and proves byte-exactness without linking -//! Rust. -//! - [`HeaderGoldens`] - published bodies paired with the header a -//! conforming `derive-header` projects from them, spelled in the -//! [`GoldenHeader`] mirror types. -//! - [`MockTransport`] - the three transports an adapter is granted -//! (chain, messaging, outbound HTTP) as programmable in-memory mocks -//! behind the SDK's own seams. -//! -//! ## Usage -//! -//! Add as a dev-dep on the adapter crate: -//! -//! ```toml -//! [dev-dependencies] -//! videre-test = { path = "../../crates/videre-test" } -//! ``` -//! -//! Hold the adapter to its published fixtures: -//! -//! ```rust -//! use videre_test::reference::{ -//! CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, -//! }; -//! use videre_test::{CodecVectors, HeaderGoldens}; -//! -//! // In a real adapter test these load the venue's own published -//! // files; the kit's reference venue stands in here. -//! let vectors = CodecVectors::from_json(CODEC_VECTORS_JSON).unwrap(); -//! vectors.assert_conforms::(); -//! -//! let goldens = HeaderGoldens::from_json(HEADER_GOLDENS_JSON).unwrap(); -//! goldens.assert_conforms(derive_reference_header); -//! ``` -//! -//! Publishing works through the same types: build the fixtures with -//! [`CodecVectors::push_round_trip`] / [`HeaderGoldens::record`] and -//! [`write`](CodecVectors::write) them next to the venue's schema -//! documentation. -//! -//! ## Macro-built adapters -//! -//! `#[videre_sdk::venue]` adapters speak the SDK's own types (the -//! macro remaps the type interfaces onto `videre_sdk::bindings`), so -//! both checks apply directly: pass `MyAdapter::derive_header` to -//! [`HeaderGoldens::assert_conforms`] and the derived enum to -//! [`CodecVectors::assert_conforms`]. No bridge types. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![warn(missing_docs)] - -pub mod codec; -pub mod fixture; -pub mod header; -pub mod reference; -pub mod report; -pub mod transport; - -pub use codec::{CodecVector, CodecVectors, Expectation}; -pub use fixture::{FixtureError, FormatVersion}; -pub use header::{ - GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, HeaderGolden, - HeaderGoldens, -}; -pub use report::{ConformanceReport, Violation}; -pub use transport::{ - ChainCall, Message, MessagingHost, MockChain, MockFetch, MockMessaging, MockTransport, - PublishRecord, RecordedRequest, -}; diff --git a/videre/crates/videre-test/src/reference.rs b/videre/crates/videre-test/src/reference.rs deleted file mode 100644 index 020cd6f9..00000000 --- a/videre/crates/videre-test/src/reference.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! The kit's reference venue: a published body schema, its codec -//! vector file, and its header golden file. -//! -//! The reference exists so the fixture formats ship with a worked, -//! machine-checked example. Its payloads exercise every borsh -//! primitive a body schema is likely to carry (fixed-width integers, -//! length-prefixed strings and byte vectors, options, bools), so a -//! non-Rust author can prove their borsh implementation byte-exact -//! against [`CODEC_VECTORS_JSON`] before touching their own schema. -//! The published files are pinned by this crate's tests: regeneration -//! must reproduce them byte for byte. - -use borsh::{BorshDeserialize, BorshSerialize}; -use videre_sdk::value_flow::{Asset, AssetAmount, Erc20}; -use videre_sdk::{AuthScheme, IntentBody, IntentHeader, Settlement, VenueError}; - -/// The published codec vector file, verbatim. -pub const CODEC_VECTORS_JSON: &str = include_str!("../vectors/reference-body.json"); - -/// The published header golden file, verbatim. -pub const HEADER_GOLDENS_JSON: &str = include_str!("../goldens/reference-header.json"); - -/// First published version: a fixed-price quote. -#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, Eq, PartialEq)] -pub struct ReferenceV1 { - /// Amount in wei; borsh encodes it as 8 little-endian bytes. - pub amount_wei: u64, - /// Free text; borsh encodes a u32 little-endian byte length then - /// the UTF-8 bytes. - pub memo: String, -} - -/// Second published version: v1 plus an expiry, a recipient, and a -/// priority flag. -#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, Eq, PartialEq)] -pub struct ReferenceV2 { - /// Amount in wei. - pub amount_wei: u64, - /// Free text. - pub memo: String, - /// Expiry in ms since the Unix epoch, UTC; borsh encodes a one-byte - /// presence tag (0 absent, 1 present) then the payload. - pub valid_until_ms: Option, - /// 20-byte recipient address; borsh encodes a u32 little-endian - /// element count then the bytes. - pub recipient: Vec, - /// Priority flag; borsh encodes one byte (0 false, 1 true). - pub urgent: bool, -} - -/// The reference venue's outer version enum. Tag order is the schema: -/// versions append, never reorder. -#[derive(IntentBody, Clone, Debug, Eq, PartialEq)] -pub enum ReferenceBody { - /// Version 1, wire tag 0. - V1(ReferenceV1), - /// Version 2, wire tag 1. - V2(ReferenceV2), -} - -/// The reference venue's pure header derivation, the subject the -/// published goldens pin. Gives the amount as the chain's native token, -/// wants (for v2) the same amount as an ERC-20 at the recipient token -/// address, and authorises via EIP-712. V1 wants nothing, spelled as a -/// zero native amount. -pub fn derive_reference_header(body: Vec) -> Result { - let (amount_wei, wants) = match ReferenceBody::from_bytes(&body)? { - ReferenceBody::V1(quote) => ( - quote.amount_wei, - AssetAmount { - asset: Asset::Native, - amount: Vec::new(), - }, - ), - ReferenceBody::V2(quote) => ( - quote.amount_wei, - AssetAmount { - asset: Asset::Erc20(Erc20 { - token: quote.recipient, - }), - amount: minimal_be(quote.amount_wei), - }, - ), - }; - Ok(IntentHeader { - gives: AssetAmount { - asset: Asset::Native, - amount: minimal_be(amount_wei), - }, - wants, - settlement: Settlement { chain: 1 }, - authorisation: AuthScheme::Eip712, - }) -} - -/// Big-endian bytes with leading zeros trimmed: the minimal spelling -/// of a wire amount, where an empty list is zero. -fn minimal_be(value: u64) -> Vec { - let bytes = value.to_be_bytes(); - let first = bytes.iter().position(|byte| *byte != 0); - first.map_or(Vec::new(), |index| bytes[index..].to_vec()) -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use crate::codec::{CodecVectors, Expectation}; - use crate::header::HeaderGoldens; - - use super::*; - - fn v1_small() -> ReferenceBody { - ReferenceBody::V1(ReferenceV1 { - amount_wei: 1, - memo: "gm".to_owned(), - }) - } - - fn v2_full() -> ReferenceBody { - ReferenceBody::V2(ReferenceV2 { - amount_wei: 1_000_000, - memo: "two coffees".to_owned(), - valid_until_ms: Some(1_700_000_000_000), - recipient: (1..=20).collect(), - urgent: true, - }) - } - - /// Rebuild the published codec vectors from the reference schema. - fn build_codec_vectors() -> CodecVectors { - let mut vectors = CodecVectors::new("videre-test/reference-body"); - - vectors - .push_round_trip("v1-small", &v1_small()) - .unwrap() - .notes = Some( - "tag 0x00, amount_wei 1 as 8 little-endian bytes, memo as u32 \ - little-endian length then utf-8 bytes" - .to_owned(), - ); - vectors - .push_round_trip( - "v1-zero-and-empty", - &ReferenceBody::V1(ReferenceV1 { - amount_wei: 0, - memo: String::new(), - }), - ) - .unwrap() - .notes = Some("zero integer and zero-length string".to_owned()); - vectors - .push_round_trip( - "v1-max-amount", - &ReferenceBody::V1(ReferenceV1 { - amount_wei: u64::MAX, - memo: "max".to_owned(), - }), - ) - .unwrap() - .notes = Some("endianness proof: u64::MAX is eight 0xff bytes".to_owned()); - vectors - .push_round_trip("v2-full", &v2_full()) - .unwrap() - .notes = Some( - "tag 0x01; option present is 0x01 then the payload, vec is u32 \ - little-endian element count then bytes, bool true is 0x01" - .to_owned(), - ); - vectors - .push_round_trip( - "v2-no-expiry", - &ReferenceBody::V2(ReferenceV2 { - amount_wei: 5, - memo: "later".to_owned(), - valid_until_ms: None, - recipient: vec![0xAA; 20], - urgent: false, - }), - ) - .unwrap() - .notes = Some("option absent is a bare 0x00, bool false is 0x00".to_owned()); - - vectors - .push_failure("empty-body", Vec::new(), Expectation::Empty) - .notes = Some("no version tag at all".to_owned()); - let mut unknown = v1_small().to_bytes().unwrap(); - unknown[0] = 9; - vectors - .push_failure( - "unknown-version", - unknown, - Expectation::UnknownVersion { version: 9 }, - ) - .notes = Some("tag 0x09 names no published version".to_owned()); - let mut truncated = v2_full().to_bytes().unwrap(); - truncated.truncate(truncated.len() - 1); - vectors - .push_failure( - "truncated-payload", - truncated, - Expectation::Malformed { version: 1 }, - ) - .notes = Some("known tag, payload cut one byte short".to_owned()); - let mut trailing = v1_small().to_bytes().unwrap(); - trailing.push(0); - vectors - .push_failure( - "trailing-bytes", - trailing, - Expectation::Malformed { version: 0 }, - ) - .notes = Some("decoding must consume the payload exactly".to_owned()); - - vectors - } - - /// Rebuild the published header goldens from the reference - /// derivation. - fn build_header_goldens() -> HeaderGoldens { - let mut goldens = HeaderGoldens::new("videre-test/reference"); - goldens - .record( - "v1-small", - v1_small().to_bytes().unwrap(), - derive_reference_header, - ) - .unwrap() - .notes = Some("gives chain-1 native token, minimal big-endian amount".to_owned()); - goldens - .record( - "v2-full", - v2_full().to_bytes().unwrap(), - derive_reference_header, - ) - .unwrap() - .notes = Some("v2 adds an erc20 want at the recipient token address".to_owned()); - goldens - } - - #[test] - fn published_codec_vectors_match_regeneration() { - assert_eq!( - CODEC_VECTORS_JSON, - build_codec_vectors().to_json(), - "vectors/reference-body.json has drifted; run the ignored \ - regenerate_reference_fixtures test and commit the result", - ); - } - - #[test] - fn published_header_goldens_match_regeneration() { - assert_eq!( - HEADER_GOLDENS_JSON, - build_header_goldens().to_json(), - "goldens/reference-header.json has drifted; run the ignored \ - regenerate_reference_fixtures test and commit the result", - ); - } - - /// Rewrite the published files from the reference schema. Run with - /// `cargo test -p videre-test -- --ignored regenerate` after a - /// deliberate schema change, then commit the diff; the tests above - /// compare against the compiled-in copy, so they go green on the - /// next build. - #[test] - #[ignore = "writes the published fixture files in place"] - fn regenerate_reference_fixtures() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")); - build_codec_vectors() - .write(root.join("vectors/reference-body.json")) - .unwrap(); - build_header_goldens() - .write(root.join("goldens/reference-header.json")) - .unwrap(); - } -} diff --git a/videre/crates/videre-test/src/report.rs b/videre/crates/videre-test/src/report.rs deleted file mode 100644 index 97f56b97..00000000 --- a/videre/crates/videre-test/src/report.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! The conformance verdict: every check in this crate either passes or -//! returns a [`ConformanceReport`] naming each vector that failed. - -use std::error::Error; -use std::fmt; - -/// One vector or golden the subject under test failed, with enough -/// detail to fix the divergence without re-running under a debugger. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Violation { - /// The `name` of the failing vector or golden. - pub vector: String, - /// What diverged: the expected and observed outcome. - pub detail: String, -} - -impl fmt::Display for Violation { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}: {}", self.vector, self.detail) - } -} - -/// Every violation a conformance check found, one entry per failing -/// vector. A check never stops at the first failure: the report is the -/// whole distance between the subject and the published fixtures. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ConformanceReport { - /// The violations, in vector order. - pub violations: Vec, -} - -impl fmt::Display for ConformanceReport { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "{} conformance violation(s):", self.violations.len())?; - for violation in &self.violations { - writeln!(f, " {violation}")?; - } - Ok(()) - } -} - -impl Error for ConformanceReport {} - -/// Fold collected violations into the check's verdict. -pub(crate) fn settle(violations: Vec) -> Result<(), ConformanceReport> { - if violations.is_empty() { - Ok(()) - } else { - Err(ConformanceReport { violations }) - } -} diff --git a/videre/crates/videre-test/src/transport.rs b/videre/crates/videre-test/src/transport.rs deleted file mode 100644 index a7295c97..00000000 --- a/videre/crates/videre-test/src/transport.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! In-memory mocks for the three transports a venue adapter is -//! granted: chain RPC, messaging, and outbound HTTP. -//! -//! [`MockTransport`] composes the three behind the same seams the SDK -//! wrappers implement ([`ChainHost`], [`MessagingHost`], [`Fetch`]), so -//! adapter logic written against `&impl Seam` runs unchanged in unit -//! tests. Grant play mirrors the host's: [`MockMessaging::scope_topics`] -//! plays the adapter's `messaging_topics` grant with the host's -//! `/`-bounded prefix matching, and [`MockFetch::scope_hosts`] plays the -//! `[capabilities.http].allow` list with the host's exact-or-`*.suffix` -//! matching; both refuse off-grant calls as a typed `denied`, exactly as -//! the host would. - -use std::cell::RefCell; -use std::collections::HashMap; - -use nexum_sdk::host::{ChainError, ChainHost, Fault}; -use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; -pub use nexum_sdk_test::{ChainCall, MockChain, MockMessaging, PublishRecord}; -pub use videre_sdk::transport::{Message, MessagingHost}; - -/// Composed in-memory transport. Each field exposes the per-seam mock -/// so tests can program responses and assert on calls. -#[derive(Default)] -pub struct MockTransport { - /// `nexum:host/chain` mock. - pub chain: MockChain, - /// `nexum:host/messaging` mock. - pub messaging: MockMessaging, - /// Outbound wasi:http mock. - pub http: MockFetch, -} - -impl MockTransport { - /// Fresh empty transport. Equivalent to `Default::default`. - pub fn new() -> Self { - Self::default() - } -} - -impl ChainHost for MockTransport { - fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { - self.chain.request(chain_id, method, params) - } -} - -impl MessagingHost for MockTransport { - fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { - self.messaging.publish(content_topic, payload) - } - - fn query( - &self, - content_topic: &str, - start_time: Option, - end_time: Option, - limit: Option, - ) -> Result, Fault> { - self.messaging - .query(content_topic, start_time, end_time, limit) - } -} - -impl Fetch for MockTransport { - fn fetch_with( - &self, - request: http::Request>, - options: FetchOptions, - ) -> Result>, FetchError> { - self.http.fetch_with(request, options) - } -} - -// ------------------------------------------------------------ http - -/// One recorded [`Fetch::fetch_with`] invocation. -#[derive(Clone, Debug)] -pub struct RecordedRequest { - /// HTTP method. - pub method: http::Method, - /// Full request URI, verbatim. - pub uri: String, - /// Request body bytes. - pub body: Vec, - /// The per-phase timeouts the caller applied. - pub options: FetchOptions, -} - -/// A programmed response, rebuilt into an `http::Response` per call -/// because the standard response type is not `Clone`. -#[derive(Clone, Debug)] -struct StoredResponse { - status: http::StatusCode, - body: Vec, -} - -/// In-memory [`Fetch`] backed by a `(method, uri)` -> response map. -/// Records every request so tests can assert dispatch shape. An -/// optional host scope plays the adapter's `[capabilities.http].allow` -/// grant ([`scope_hosts`](Self::scope_hosts)); one-off refusals can -/// still be programmed via [`fail_with`](Self::fail_with). -#[derive(Default)] -pub struct MockFetch { - responses: RefCell>>, - requests: RefCell>, - scope: RefCell>>, -} - -impl MockFetch { - /// Confine the mock to `hosts`, playing the adapter's - /// `[capabilities.http].allow` grant with the host's matching: - /// case-insensitive, an entry is an exact hostname or a `*.suffix` - /// wildcard, and an off-grant request fails as - /// [`FetchError::Denied`]. An empty grant denies every host, the - /// host's posture for an absent allow list; an untouched mock is - /// unscoped. - pub fn scope_hosts(&self, hosts: impl IntoIterator>) { - *self.scope.borrow_mut() = Some(hosts.into_iter().map(Into::into).collect()); - } - - /// Program a response for the `(method, uri)` pair. Overwrites any - /// prior entry. - /// - /// # Panics - /// - /// On a `status` outside the valid HTTP range. - pub fn respond_to( - &self, - method: http::Method, - uri: impl Into, - status: u16, - body: impl Into>, - ) { - let status = - http::StatusCode::from_u16(status).expect("MockFetch: status must be a valid code"); - self.responses.borrow_mut().insert( - (method, uri.into()), - Ok(StoredResponse { - status, - body: body.into(), - }), - ); - } - - /// Program a failure for the `(method, uri)` pair. Overwrites any - /// prior entry. - pub fn fail_with(&self, method: http::Method, uri: impl Into, error: FetchError) { - self.responses - .borrow_mut() - .insert((method, uri.into()), Err(error)); - } - - /// All requests received, in arrival order. - pub fn requests(&self) -> Vec { - self.requests.borrow().clone() - } - - /// Last request received, if any. - pub fn last_request(&self) -> Option { - self.requests.borrow().last().cloned() - } - - /// Total request count. - pub fn request_count(&self) -> usize { - self.requests.borrow().len() - } -} - -impl Fetch for MockFetch { - fn fetch_with( - &self, - request: http::Request>, - options: FetchOptions, - ) -> Result>, FetchError> { - let method = request.method().clone(); - let uri = request.uri().to_string(); - self.requests.borrow_mut().push(RecordedRequest { - method: method.clone(), - uri: uri.clone(), - body: request.body().clone(), - options, - }); - if let Some(scope) = self.scope.borrow().as_ref() - && !request - .uri() - .host() - .is_some_and(|host| host_allowed(host, scope)) - { - return Err(FetchError::Denied); - } - match self.responses.borrow().get(&(method.clone(), uri.clone())) { - Some(Ok(stored)) => Ok(http::Response::builder() - .status(stored.status) - .body(stored.body.clone()) - .expect("a stored response always rebuilds")), - Some(Err(err)) => Err(err.clone()), - None => Err(FetchError::Transport(format!( - "MockFetch: no response configured for {method} {uri}" - ))), - } - } -} - -/// The host's `[capabilities.http].allow` matching: host-only and -/// case-insensitive, an entry admits its exact hostname or, as -/// `*.suffix`, any strict subdomain of the suffix. -fn host_allowed(host: &str, allowlist: &[String]) -> bool { - let host = host.to_ascii_lowercase(); - allowlist.iter().any(|pat| { - let pat = pat.to_ascii_lowercase(); - if let Some(suffix) = pat.strip_prefix("*.") { - host.ends_with(&format!(".{suffix}")) - } else { - host == pat - } - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn fetch_returns_programmed_response_and_records_the_request() { - let fetch = MockFetch::default(); - fetch.respond_to( - http::Method::GET, - "https://venue.example/api/v1/quote", - 200, - br#"{"price":"1"}"#.to_vec(), - ); - - let request = http::Request::builder() - .method(http::Method::GET) - .uri("https://venue.example/api/v1/quote") - .body(Vec::new()) - .unwrap(); - let response = fetch.fetch(request).unwrap(); - assert_eq!(response.status(), http::StatusCode::OK); - assert_eq!(response.body(), br#"{"price":"1"}"#); - - assert_eq!(fetch.request_count(), 1); - let recorded = fetch.last_request().unwrap(); - assert_eq!(recorded.method, http::Method::GET); - assert_eq!(recorded.uri, "https://venue.example/api/v1/quote"); - assert_eq!(recorded.options, FetchOptions::default()); - } - - #[test] - fn fetch_unconfigured_and_programmed_failures() { - let fetch = MockFetch::default(); - fetch.fail_with( - http::Method::POST, - "https://venue.example/api/v1/orders", - FetchError::Denied, - ); - - let denied = http::Request::builder() - .method(http::Method::POST) - .uri("https://venue.example/api/v1/orders") - .body(b"order".to_vec()) - .unwrap(); - assert_eq!(fetch.fetch(denied).unwrap_err(), FetchError::Denied); - - let stray = http::Request::builder() - .uri("https://nowhere.example/") - .body(Vec::new()) - .unwrap(); - let err = fetch.fetch(stray).unwrap_err(); - assert!(matches!(err, FetchError::Transport(msg) if msg.contains("MockFetch"))); - // Refused and unconfigured requests are still recorded. - assert_eq!(fetch.request_count(), 2); - } - - #[test] - fn fetch_scope_matches_the_host_grant() { - let fetch = MockFetch::default(); - fetch.scope_hosts(["api.acme.example", "*.discord.com"]); - fetch.respond_to(http::Method::GET, "https://api.acme.example/v1", 200, "ok"); - fetch.respond_to(http::Method::GET, "https://API.ACME.EXAMPLE/v1", 200, "ok"); - fetch.respond_to(http::Method::GET, "https://a.b.discord.com/", 200, "ok"); - - // Exact entry, case-insensitively; a wildcard admits strict - // subdomains only. - let get = |uri: &str| { - fetch.fetch( - http::Request::builder() - .uri(uri) - .body(Vec::new()) - .expect("test request builds"), - ) - }; - assert!(get("https://api.acme.example/v1").is_ok()); - assert!(get("https://API.ACME.EXAMPLE/v1").is_ok()); - assert!(get("https://a.b.discord.com/").is_ok()); - assert_eq!( - get("https://evil.api.acme.example/").unwrap_err(), - FetchError::Denied, - ); - assert_eq!(get("https://discord.com/").unwrap_err(), FetchError::Denied); - - // Refused requests are still recorded. - assert_eq!(fetch.request_count(), 5); - - // An empty grant denies every host, the host's posture for an - // absent allow list. - let sealed = MockFetch::default(); - sealed.scope_hosts(Vec::::new()); - sealed.respond_to(http::Method::GET, "https://anywhere.example/", 200, ""); - let denied = sealed.fetch( - http::Request::builder() - .uri("https://anywhere.example/") - .body(Vec::new()) - .expect("test request builds"), - ); - assert_eq!(denied.unwrap_err(), FetchError::Denied); - } - - #[test] - fn transport_dispatches_through_every_seam() { - let transport = MockTransport::new(); - transport - .chain - .respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".to_owned())); - transport.messaging.seed_payload("/t", b"m".to_vec(), 1); - transport - .http - .respond_to(http::Method::GET, "https://venue.example/", 204, Vec::new()); - - // Through the seams an adapter's logic is written against. - let chain: &dyn ChainHost = &transport; - assert_eq!( - chain.request(1, "eth_blockNumber", "[]").unwrap(), - "\"0x1\"" - ); - - let messaging: &dyn MessagingHost = &transport; - messaging.publish("/t", b"out").unwrap(); - assert_eq!(messaging.query("/t", None, None, None).unwrap().len(), 1); - - let request = http::Request::builder() - .uri("https://venue.example/") - .body(Vec::new()) - .unwrap(); - let response = transport.fetch(request).unwrap(); - assert_eq!(response.status(), http::StatusCode::NO_CONTENT); - - assert_eq!(transport.chain.call_count(), 1); - assert_eq!(transport.messaging.publish_count(), 1); - assert_eq!(transport.http.request_count(), 1); - } -} diff --git a/videre/crates/videre-test/tests/conformance.rs b/videre/crates/videre-test/tests/conformance.rs deleted file mode 100644 index da0bc538..00000000 --- a/videre/crates/videre-test/tests/conformance.rs +++ /dev/null @@ -1,142 +0,0 @@ -//! Acceptance surface for the conformance kit: an adapter written -//! against `videre-sdk` is held to the published vector and -//! golden files, and a deliberately divergent adapter is caught by -//! them. - -use videre_sdk::value_flow::{Asset, AssetAmount}; -use videre_sdk::{ - AuthScheme, Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, - VenueError, -}; -use videre_test::reference::{ - CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, -}; -use videre_test::{CodecVectors, HeaderGoldens, MessagingHost, MockTransport}; - -/// An adapter under test: the reference venue implemented through the -/// SDK trait, transport injected through the seams so the kit's mocks -/// drive it. -struct ReferenceAdapter; - -impl VenueAdapter for ReferenceAdapter { - fn init(_config: Config) -> Result<(), Fault> { - Ok(()) - } - - fn derive_header(body: Vec) -> Result { - derive_reference_header(body) - } - - fn quote(body: Vec) -> Result { - let header = derive_reference_header(body)?; - Ok(Quotation { - gives: header.gives, - wants: header.wants, - fee: AssetAmount { - asset: Asset::Native, - amount: Vec::new(), - }, - valid_until_ms: u64::MAX, - }) - } - - fn submit(body: Vec) -> Result { - Ok(SubmitOutcome::Accepted(body)) - } - - fn status(_receipt: Vec) -> Result { - Ok(IntentStatus::Open) - } - - fn cancel(_receipt: Vec) -> Result<(), VenueError> { - Ok(()) - } -} - -#[test] -fn adapter_codec_conforms_to_the_published_vectors() { - CodecVectors::from_json(CODEC_VECTORS_JSON) - .expect("the published vector file parses") - .assert_conforms::(); -} - -#[test] -fn adapter_derive_header_conforms_to_the_published_goldens() { - HeaderGoldens::from_json(HEADER_GOLDENS_JSON) - .expect("the published golden file parses") - .assert_conforms(ReferenceAdapter::derive_header); -} - -#[test] -fn divergent_derivation_is_caught_by_the_published_goldens() { - // The classic byte-order bug: little-endian amounts. - let derive = |body: Vec| -> Result { - let mut header = derive_reference_header(body)?; - header.gives.amount.reverse(); - Ok(header) - }; - let report = HeaderGoldens::from_json(HEADER_GOLDENS_JSON) - .unwrap() - .check(derive) - .unwrap_err(); - assert!(!report.violations.is_empty()); - assert!(report.violations[0].detail.contains("diverges")); -} - -#[test] -fn mock_transport_drives_seam_shaped_adapter_logic() { - // A slice of adapter logic written against the seams: announce a - // submission over messaging, confirm via the venue's HTTP API. - fn announce(messaging: &M, receipt: &[u8]) -> Result<(), VenueError> { - messaging - .publish("/reference/1/receipts/proto", receipt) - .map_err(VenueError::from) - } - - let transport = MockTransport::new(); - transport - .messaging - .scope_topics(["/reference/1/receipts/proto"]); - - let SubmitOutcome::Accepted(receipt) = ReferenceAdapter::submit(vec![1, 2, 3]).unwrap() else { - panic!("the reference venue accepts directly"); - }; - announce(&transport, &receipt).unwrap(); - assert_eq!( - transport.messaging.last_published().unwrap().payload, - receipt, - ); - - // An off-scope topic surfaces as the typed policy refusal. - let denied = transport - .messaging - .publish("/elsewhere", &receipt) - .map_err(VenueError::from) - .unwrap_err(); - assert!(matches!(denied, VenueError::Denied(_))); -} - -#[test] -fn published_files_document_the_wire_format_in_hex() { - // Non-Rust authors consume the files directly: every byte field is - // lowercase hex, and the first round-trip vector carries prose. - let vectors = CodecVectors::from_json(CODEC_VECTORS_JSON).unwrap(); - assert!(vectors.vectors.iter().any(|vector| vector.notes.is_some())); - - let goldens = HeaderGoldens::from_json(HEADER_GOLDENS_JSON).unwrap(); - let golden = &goldens.goldens[0]; - // The golden's body is a codec vector's bytes: the two files pin - // the same wire form from both sides. - assert!( - vectors - .vectors - .iter() - .any(|vector| vector.bytes == golden.body), - "header goldens reuse published codec bodies", - ); - // And the expected header speaks the value-flow vocabulary. - let derived = derive_reference_header(golden.body.clone()).unwrap(); - assert_eq!(derived.gives.asset, Asset::Native); - assert_eq!(derived.authorisation, AuthScheme::Eip712); - let _: &AssetAmount = &derived.gives; -} diff --git a/videre/crates/videre-test/vectors/reference-body.json b/videre/crates/videre-test/vectors/reference-body.json deleted file mode 100644 index 4bc46024..00000000 --- a/videre/crates/videre-test/vectors/reference-body.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "version": 1, - "schema": "videre-test/reference-body", - "vectors": [ - { - "name": "v1-small", - "bytes": "00010000000000000002000000676d", - "expect": "round-trip", - "notes": "tag 0x00, amount_wei 1 as 8 little-endian bytes, memo as u32 little-endian length then utf-8 bytes" - }, - { - "name": "v1-zero-and-empty", - "bytes": "00000000000000000000000000", - "expect": "round-trip", - "notes": "zero integer and zero-length string" - }, - { - "name": "v1-max-amount", - "bytes": "00ffffffffffffffff030000006d6178", - "expect": "round-trip", - "notes": "endianness proof: u64::MAX is eight 0xff bytes" - }, - { - "name": "v2-full", - "bytes": "0140420f00000000000b00000074776f20636f6666656573010068e5cf8b010000140000000102030405060708090a0b0c0d0e0f101112131401", - "expect": "round-trip", - "notes": "tag 0x01; option present is 0x01 then the payload, vec is u32 little-endian element count then bytes, bool true is 0x01" - }, - { - "name": "v2-no-expiry", - "bytes": "010500000000000000050000006c617465720014000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa00", - "expect": "round-trip", - "notes": "option absent is a bare 0x00, bool false is 0x00" - }, - { - "name": "empty-body", - "bytes": "", - "expect": "empty", - "notes": "no version tag at all" - }, - { - "name": "unknown-version", - "bytes": "09010000000000000002000000676d", - "expect": { - "unknown-version": { - "version": 9 - } - }, - "notes": "tag 0x09 names no published version" - }, - { - "name": "truncated-payload", - "bytes": "0140420f00000000000b00000074776f20636f6666656573010068e5cf8b010000140000000102030405060708090a0b0c0d0e0f1011121314", - "expect": { - "malformed": { - "version": 1 - } - }, - "notes": "known tag, payload cut one byte short" - }, - { - "name": "trailing-bytes", - "bytes": "00010000000000000002000000676d00", - "expect": { - "malformed": { - "version": 0 - } - }, - "notes": "decoding must consume the payload exactly" - } - ] -} diff --git a/videre/modules/examples/echo-client/Cargo.toml b/videre/modules/examples/echo-client/Cargo.toml deleted file mode 100644 index 4eada01b..00000000 --- a/videre/modules/examples/echo-client/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "echo-client" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Shepherd example module paired with the echo-venue adapter: submits an opaque body through videre:venue/client on every block and logs the intent-status transitions the registry fans back." - -[lints] -workspace = true - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } -wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } diff --git a/videre/modules/examples/echo-client/module.toml b/videre/modules/examples/echo-client/module.toml deleted file mode 100644 index 9dee6673..00000000 --- a/videre/modules/examples/echo-client/module.toml +++ /dev/null @@ -1,38 +0,0 @@ -# echo-client module manifest - the keeper half of the echo pair. It -# submits through videre:venue/client and observes intent-status, so it -# declares the `client` capability alongside `logging`; the per-module world -# the macro derives imports exactly videre:venue/client and -# nexum:host/logging. - -[module] -name = "echo-client" -version = "0.1.0" -# Placeholder content hash; parsed but not verified in 0.2. -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -# `client` grants the videre:venue/client import; `logging` the log sink. -required = ["client", "logging"] -optional = [] - -[capabilities.http] -allow = [] - -# Submit on every chain-1 block. -[[subscription]] -kind = "block" -chain_id = 1 - -# Observe the status transitions the registry polls from the echo-venue adapter. -[[subscription]] -kind = "intent-status" -venue = "echo-venue" - -[config] -name = "echo-client" - -# The one body-schema version this keeper encodes; install refuses the -# keeper unless every installed adapter's [venue] body_versions -# contains it. -[venue] -body_version = 1 diff --git a/videre/modules/examples/echo-client/src/lib.rs b/videre/modules/examples/echo-client/src/lib.rs deleted file mode 100644 index 185e22da..00000000 --- a/videre/modules/examples/echo-client/src/lib.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! # echo-client (reference Shepherd intent module) -//! -//! The keeper half of the echo pair. On every chain-1 block it quotes and -//! submits an opaque body through `videre:venue/client` to the `echo-venue` -//! adapter and logs the receipt, and it logs each `intent-status` transition the -//! registry fans back from that venue. Paired with the echo-venue adapter it -//! is the smallest end-to-end demonstration of the intent core: module -> -//! host registry -> venue adapter, and the status event back. -//! -//! It declares two capabilities (`client`, `logging`), so the built -//! component imports `videre:venue/client` and `nexum:host/logging` and -//! nothing else: the per-module world matches the manifest by construction. - -// wit_bindgen::generate! expands to host-import shims whose arity matches -// the WIT signatures, which can exceed clippy's too-many-arguments threshold. -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -use nexum::host::{logging, types}; -use videre::types::types::SubmitOutcome; -use videre::venue::client; - -/// Venue id the paired echo-venue adapter answers for; the module submits -/// to and observes exactly this venue. -const ECHO_VENUE: &str = "echo-venue"; - -struct EchoClient; - -#[nexum_sdk::module] -impl EchoClient { - fn on_block(block: types::Block) -> Result<(), Fault> { - // The echo venue accepts any bytes and hands them back as the - // receipt, so the body content is immaterial; the block number keeps - // it non-empty and legible in the logs. - let body = block.number.to_be_bytes().to_vec(); - match client::quote(ECHO_VENUE, &body) { - Ok(quotation) => logging::log( - logging::Level::Info, - &format!( - "quoted {} bytes at {ECHO_VENUE}: gives {} amount bytes", - body.len(), - quotation.gives.amount.len(), - ), - ), - Err(_) => logging::log( - logging::Level::Warn, - &format!("quote at {ECHO_VENUE} was refused"), - ), - } - match client::submit(ECHO_VENUE, &body) { - Ok(SubmitOutcome::Accepted(receipt)) => logging::log( - logging::Level::Info, - &format!( - "submitted {} bytes to {ECHO_VENUE}, receipt {} bytes", - body.len(), - receipt.len(), - ), - ), - Ok(SubmitOutcome::RequiresSigning(_)) => logging::log( - logging::Level::Warn, - &format!("{ECHO_VENUE} unexpectedly asked for a signature"), - ), - Err(_) => logging::log( - logging::Level::Warn, - &format!("submit to {ECHO_VENUE} was refused"), - ), - } - Ok(()) - } - - fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { - let body = nexum_sdk::status_body::StatusBody::decode(&update.status) - .map_err(|err| Fault::InvalidInput(err.to_string()))?; - logging::log( - logging::Level::Info, - &format!( - "intent status from venue {}: {:?} ({} receipt bytes)", - update.venue, - body.status, - update.receipt.len(), - ), - ); - Ok(()) - } -} diff --git a/videre/modules/examples/echo-keeper/Cargo.toml b/videre/modules/examples/echo-keeper/Cargo.toml deleted file mode 100644 index d9ba15a5..00000000 --- a/videre/modules/examples/echo-keeper/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "echo-keeper" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Shepherd example keeper paired with the echo-venue adapter: drives it through the typed VenueClient emitted by #[videre_sdk::keeper] - quote, submit, status, cancel - and logs the intent-status transitions the registry fans back." - -[lints] -workspace = true - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", tag = "v0.1.0" } -videre-sdk = { path = "../../../crates/videre-sdk" } -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/videre/modules/examples/echo-keeper/module.toml b/videre/modules/examples/echo-keeper/module.toml deleted file mode 100644 index 0620286c..00000000 --- a/videre/modules/examples/echo-keeper/module.toml +++ /dev/null @@ -1,40 +0,0 @@ -# echo-keeper module manifest - the blessed keeper half of the echo -# pair. It drives the echo-venue adapter through the typed client, so it -# declares the `client` capability alongside `logging`; the per-module -# world the macro derives imports exactly videre:venue/client and -# nexum:host/logging. - -[module] -name = "echo-keeper" -version = "0.1.0" -# Placeholder content hash; parsed but not verified in 0.2. -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -# `client` grants the videre:venue/client import (required by -# #[videre_sdk::keeper]); `logging` the log sink. -required = ["client", "logging"] -optional = [] - -[capabilities.http] -allow = [] - -# Drive the venue on every chain-1 block. -[[subscription]] -kind = "block" -chain_id = 1 - -# Observe the status transitions the registry polls from the echo-venue -# adapter. -[[subscription]] -kind = "intent-status" -venue = "echo-venue" - -[config] -name = "echo-keeper" - -# The one body-schema version this keeper encodes; install refuses the -# keeper unless every installed adapter's [venue] body_versions -# contains it. -[venue] -body_version = 1 diff --git a/videre/modules/examples/echo-keeper/src/lib.rs b/videre/modules/examples/echo-keeper/src/lib.rs deleted file mode 100644 index 05e41619..00000000 --- a/videre/modules/examples/echo-keeper/src/lib.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! # echo-keeper (reference videre keeper module) -//! -//! The blessed keeper half of the echo pair: on every chain-1 block it -//! drives the echo-venue adapter through the typed -//! `VenueClient` - quote, submit, status, cancel, all with a -//! typed body - and logs each `intent-status` transition the registry -//! fans back. Where echo-client hand-writes byte marshalling over the -//! raw `videre:venue/client` import, this module is -//! `#[videre_sdk::keeper]`: the macro wires the world and the client -//! import, and the author never sees wire bytes. -//! -//! It declares two capabilities (`client`, `logging`), so the built -//! component imports `videre:venue/client` and `nexum:host/logging` and -//! nothing else: the per-module world matches the manifest by -//! construction. - -// wit_bindgen::generate! expands to host-import shims whose arity matches -// the WIT signatures, which can exceed clippy's too-many-arguments threshold. -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -use nexum::host::{logging, types}; -use videre_sdk::{SubmitOutcome, Venue, VenueClient, VenueId}; - -/// The echo venue as this keeper types it: the id the paired adapter -/// answers for and the body schema below. -struct EchoVenue; - -impl Venue for EchoVenue { - const ID: VenueId = VenueId::from_static("echo-venue"); - type Body = EchoBody; -} - -/// The keeper's published body schema. The echo venue accepts any -/// bytes, so v1 is just the block number: enough to exercise the typed -/// codec end to end. -#[derive(videre_sdk::IntentBody)] -enum EchoBody { - V1(u64), -} - -struct EchoKeeper; - -#[videre_sdk::keeper] -impl EchoKeeper { - async fn on_block(block: types::Block) -> Result<(), Fault> { - let venue = VenueClient::::new(); - let body = EchoBody::V1(block.number); - - // Quote-then-submit through the typestate: the venue prices - // exactly the bytes it is later handed. ClientError folds into - // the wire fault, so `?` applies throughout. - let quoted = venue.quote(&body).await?; - logging::log( - logging::Level::Info, - &format!( - "quoted at {}: gives {} amount bytes", - EchoVenue::ID, - quoted.quotation().gives.amount.len(), - ), - ); - let receipt = match quoted.submit().await? { - SubmitOutcome::Accepted(receipt) => receipt, - SubmitOutcome::RequiresSigning(_) => { - logging::log( - logging::Level::Warn, - &format!("{} unexpectedly asked for a signature", EchoVenue::ID), - ); - return Ok(()); - } - }; - logging::log( - logging::Level::Info, - &format!( - "submitted to {}: receipt {} bytes", - EchoVenue::ID, - receipt.len(), - ), - ); - - let status = venue.status(&receipt).await?; - logging::log( - logging::Level::Info, - &format!("status at {}: {status:?}", EchoVenue::ID), - ); - - venue.cancel(&receipt).await?; - logging::log( - logging::Level::Info, - &format!("cancelled at {}", EchoVenue::ID), - ); - Ok(()) - } - - fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { - let body = nexum_sdk::status_body::StatusBody::decode(&update.status) - .map_err(|err| Fault::InvalidInput(err.to_string()))?; - logging::log( - logging::Level::Info, - &format!( - "intent status from venue {}: {:?} ({} receipt bytes)", - update.venue, - body.status, - update.receipt.len(), - ), - ); - Ok(()) - } -} diff --git a/videre/modules/examples/echo-venue/Cargo.toml b/videre/modules/examples/echo-venue/Cargo.toml deleted file mode 100644 index a1d818f1..00000000 --- a/videre/modules/examples/echo-venue/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "echo-venue" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true - -[lints] -workspace = true - -[lib] -crate-type = ["cdylib"] - -[dependencies] -videre-sdk = { path = "../../../crates/videre-sdk" } -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } - -[dev-dependencies] -# The conformance kit: holds this adapter's header derivation to the kit's -# golden mirror types, so echo-venue is both the tutorial artefact and the -# kit's worked test target. -videre-test = { path = "../../../crates/videre-test" } diff --git a/videre/modules/examples/echo-venue/module.toml b/videre/modules/examples/echo-venue/module.toml deleted file mode 100644 index bd17af64..00000000 --- a/videre/modules/examples/echo-venue/module.toml +++ /dev/null @@ -1,29 +0,0 @@ -# echo-venue adapter manifest - the reference #[videre_sdk::venue] -# component. Declares a single scoped-transport capability (chain), so the -# per-component world the macro derives imports nexum:host/chain and -# nothing else. - -[module] -name = "echo-venue" -version = "0.1.0" -kind = "venue-adapter" -# Placeholder content hash; parsed but not verified in 0.2. -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -# A venue adapter may declare only scoped transport (chain, messaging) -# plus the HTTP allowlist. echo-venue reads chain state to derive its -# header and needs nothing else. -required = ["chain"] -optional = [] - -[capabilities.http] -allow = [] - -[config] -name = "echo-venue" - -# Body-schema versions this adapter decodes: the handshake authority. -# Install asserts the adapter's body-versions export equals it. -[venue] -body_versions = [1] diff --git a/videre/modules/examples/echo-venue/src/lib.rs b/videre/modules/examples/echo-venue/src/lib.rs deleted file mode 100644 index 2a3d8d50..00000000 --- a/videre/modules/examples/echo-venue/src/lib.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! # echo-venue (reference Shepherd venue adapter) -//! -//! The minimal reference venue adapter: it accepts any body, echoes it back -//! as the receipt, and settles instantly (every receipt it issued reports -//! `fulfilled`). It carries no real venue protocol, so it doubles as the -//! smallest end-to-end demonstration of `#[videre_sdk::venue]` - the -//! attribute takes the `impl VenueAdapter` block and supplies the -//! per-cdylib wit-bindgen for a world derived from `module.toml` plus the -//! export glue - and as the `videre-test` conformance target (see the -//! tests below). -//! -//! It declares one capability (`chain`), so the built component imports -//! `nexum:host/chain` and nothing else: the per-component world matches -//! the manifest by construction. - -// wit_bindgen::generate! expands to host-import shims whose arity matches -// the WIT signatures, which can exceed clippy's too-many-arguments threshold. -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -// `Config` and `Fault` come from the macro's world bindgen at the crate -// root: aliases of the SDK types, so the trait impl lines up. -use nexum::host::chain; -use videre_sdk::value_flow::{Asset, AssetAmount}; -use videre_sdk::{ - AuthScheme, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueAdapter, - VenueError, -}; - -struct EchoVenue; - -#[videre_sdk::venue] -impl VenueAdapter for EchoVenue { - fn init(_config: Config) -> Result<(), Fault> { - Ok(()) - } - - fn body_versions() -> Vec { - // Must equal the manifest `[venue] body_versions`; install - // asserts it. - vec![1] - } - - fn derive_header(body: Vec) -> Result { - // The echo venue gives back exactly the bytes handed to it, so the - // header's `gives` amount is the body length: enough to exercise - // the value-flow vocabulary without a real schema. Wants nothing, - // spelled as a zero native amount. - Ok(IntentHeader { - gives: AssetAmount { - asset: Asset::Native, - amount: minimal_be(body.len() as u64), - }, - wants: zero_native(), - settlement: Settlement { chain: 1 }, - authorisation: AuthScheme::Eip1271, - }) - } - - fn quote(body: Vec) -> Result { - // Echo pricing mirrors the header: gives the body length, wants - // nothing, charges no fee, and the quote never expires. - Ok(Quotation { - gives: AssetAmount { - asset: Asset::Native, - amount: minimal_be(body.len() as u64), - }, - wants: zero_native(), - fee: zero_native(), - valid_until_ms: u64::MAX, - }) - } - - fn submit(body: Vec) -> Result { - // Reading chain state on submit is what justifies the declared - // `chain` capability; the block height is discarded, the point is - // the scoped transport import the manifest declares. - let _ = chain::request(1, "eth_blockNumber", "[]") - .map_err(|_| VenueError::Unavailable("chain read failed".into()))?; - Ok(SubmitOutcome::Accepted(body)) - } - - fn status(_receipt: Vec) -> Result { - // Settles instantly: the intent reaches a terminal state on the - // first status poll. - Ok(IntentStatus::Fulfilled) - } - - fn cancel(_receipt: Vec) -> Result<(), VenueError> { - Ok(()) - } -} - -/// A zero native amount: the venue's spelling of "nothing". -fn zero_native() -> AssetAmount { - AssetAmount { - asset: Asset::Native, - amount: Vec::new(), - } -} - -/// Big-endian bytes with leading zeros trimmed: the minimal `uint` -/// spelling, where an empty list is zero. -fn minimal_be(value: u64) -> Vec { - let bytes = value.to_be_bytes(); - let first = bytes.iter().position(|byte| *byte != 0); - first.map_or(Vec::new(), |index| bytes[index..].to_vec()) -} - -/// echo-venue as the `videre-test` conformance target: the adapter's -/// pure header derivation is held to a hand-written golden. The macro -/// remaps the type interfaces onto the SDK bindings, so the derivation -/// feeds the kit directly through its `From` mirror. -#[cfg(test)] -mod conformance { - use super::*; - use videre_test::{ - FormatVersion, GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, - GoldenSettlement, HeaderGolden, HeaderGoldens, - }; - - fn zero_native() -> GoldenAssetAmount { - GoldenAssetAmount { - asset: GoldenAsset::Native, - amount: Vec::new(), - } - } - - #[test] - fn derive_header_conforms_to_the_published_golden() { - // The echo contract: gives chain-1 native token whose amount is the - // body length in minimal big-endian bytes, wants zero native, and - // authorises via EIP-1271. A conforming adapter reproduces this - // exactly. - let golden = HeaderGolden { - name: "four-byte-body".to_owned(), - body: vec![1, 2, 3, 4], - header: GoldenHeader { - gives: GoldenAssetAmount { - asset: GoldenAsset::Native, - amount: vec![4], - }, - wants: zero_native(), - settlement: GoldenSettlement { chain: 1 }, - authorisation: GoldenAuthScheme::Eip1271, - }, - notes: Some("amount is the minimal big-endian body length".to_owned()), - }; - let goldens = HeaderGoldens { - version: FormatVersion, - venue: "echo-venue".to_owned(), - goldens: vec![golden], - }; - goldens.assert_conforms(EchoVenue::derive_header); - } - - #[test] - fn divergent_derivation_is_caught_by_the_golden() { - // A non-minimal amount is the classic uint bug; the golden must - // reject it, proving the check has teeth on echo-venue. - let goldens = HeaderGoldens { - version: FormatVersion, - venue: "echo-venue".to_owned(), - goldens: vec![HeaderGolden { - name: "four-byte-body".to_owned(), - body: vec![1, 2, 3, 4], - header: GoldenHeader { - gives: GoldenAssetAmount { - asset: GoldenAsset::Native, - amount: 4u64.to_be_bytes().to_vec(), - }, - wants: zero_native(), - settlement: GoldenSettlement { chain: 1 }, - authorisation: GoldenAuthScheme::Eip1271, - }, - notes: None, - }], - }; - assert!(goldens.check(EchoVenue::derive_header).is_err()); - } -} diff --git a/videre/modules/fixtures/flaky-venue/Cargo.toml b/videre/modules/fixtures/flaky-venue/Cargo.toml deleted file mode 100644 index 4987897e..00000000 --- a/videre/modules/fixtures/flaky-venue/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "flaky-venue" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Evil-by-design venue adapter fixture: submit panics while the chain head reads as the poison sentinel and accepts once it stops. Drives the supervisor's provider trap-to-recovery and poison sweeps." - -[lints] -workspace = true - -[lib] -crate-type = ["cdylib"] - -[dependencies] -videre-sdk = { path = "../../../crates/videre-sdk" } -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/videre/modules/fixtures/flaky-venue/module.toml b/videre/modules/fixtures/flaky-venue/module.toml deleted file mode 100644 index c7667b9d..00000000 --- a/videre/modules/fixtures/flaky-venue/module.toml +++ /dev/null @@ -1,19 +0,0 @@ -# flaky-venue adapter manifest - the trap-to-recovery test fixture. -# Declares the chain capability its poison-sentinel read requires. - -[module] -name = "flaky-venue" -version = "0.1.0" -kind = "venue-adapter" -# Placeholder content hash; parsed but not verified in 0.2. -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -required = ["chain"] -optional = [] - -[capabilities.http] -allow = [] - -[config] -name = "flaky-venue" diff --git a/videre/modules/fixtures/flaky-venue/src/lib.rs b/videre/modules/fixtures/flaky-venue/src/lib.rs deleted file mode 100644 index 10716274..00000000 --- a/videre/modules/fixtures/flaky-venue/src/lib.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! # flaky-venue (test fixture) -//! -//! A venue adapter whose `submit` panics (traps the store) while the chain -//! head reads as the poison sentinel `0xdead`, and accepts once the head -//! moves on. The controlling test programs the mock chain backend, so the -//! trap is deterministic and the recovery is host-driven: the supervisor's -//! sweep must reinstantiate the adapter before a submit succeeds again. -//! -//! Not a production adapter. Lives under `modules/fixtures/` so it is -//! obviously test-only. - -// wit_bindgen::generate! expands to host-import shims whose arity matches -// the WIT signatures, which can exceed clippy's too-many-arguments threshold. -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -// `Config` and `Fault` come from the macro's world bindgen at the crate -// root: aliases of the SDK types, so the trait impl lines up. -use nexum::host::chain; -use videre_sdk::value_flow::{Asset, AssetAmount}; -use videre_sdk::{ - AuthScheme, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueAdapter, - VenueError, -}; - -/// The chain-head response that detonates `submit`. -const POISON_HEAD: &str = "0xdead"; - -struct FlakyVenue; - -#[videre_sdk::venue] -impl VenueAdapter for FlakyVenue { - fn init(_config: Config) -> Result<(), Fault> { - Ok(()) - } - - fn derive_header(_body: Vec) -> Result { - Ok(IntentHeader { - gives: zero_native(), - wants: zero_native(), - settlement: Settlement { chain: 1 }, - authorisation: AuthScheme::Eip1271, - }) - } - - fn quote(_body: Vec) -> Result { - Ok(Quotation { - gives: zero_native(), - wants: zero_native(), - fee: zero_native(), - valid_until_ms: u64::MAX, - }) - } - - fn submit(body: Vec) -> Result { - let head = chain::request(1, "eth_blockNumber", "[]") - .map_err(|_| VenueError::Unavailable("chain read failed".into()))?; - // The sentinel detonates the fixture: a guest panic traps the - // store, which is what the sweep under test must recover from. - assert!(!head.contains(POISON_HEAD), "flaky-venue poison head"); - Ok(SubmitOutcome::Accepted(body)) - } - - fn status(_receipt: Vec) -> Result { - Ok(IntentStatus::Open) - } - - fn cancel(_receipt: Vec) -> Result<(), VenueError> { - Ok(()) - } -} - -/// A zero native amount. -fn zero_native() -> AssetAmount { - AssetAmount { - asset: Asset::Native, - amount: Vec::new(), - } -} diff --git a/videre/wit/deps.lock b/videre/wit/deps.lock deleted file mode 100644 index 76e2f363..00000000 --- a/videre/wit/deps.lock +++ /dev/null @@ -1,5 +0,0 @@ -[nexum-host] -url = "https://github.com/nullislabs/nexum-runtime/archive/refs/tags/v0.1.0.tar.gz" -prefix = "nexum-runtime-0.1.0/wit/nexum-host" -sha256 = "52211dea6cf662c1b00b4fa705e5f9e05a44fb3d114b7cc9c0f4cdb77b2fc716" -sha512 = "9ed9bff008c7bb0620250c26b59fc0fdec5ac7326d1d5bc4e81dfdaad18c357444cb0786b8a53e2ff4090f5cfcab956a56efabe0718985c7871b82e30ce0df8d" diff --git a/videre/wit/deps.toml b/videre/wit/deps.toml deleted file mode 100644 index 053a4751..00000000 --- a/videre/wit/deps.toml +++ /dev/null @@ -1,8 +0,0 @@ -# Cross-repo WIT pins, vendored under wit/deps and verified against -# deps.lock by `wit-deps`. Pins are exact git tags of the owning repo; -# they converge to wkg/OCI (ghcr.io/nullislabs) with per-package semver -# once the packages are published. - -[nexum-host] -url = "https://github.com/nullislabs/nexum-runtime/archive/refs/tags/v0.1.0.tar.gz" -prefix = "nexum-runtime-0.1.0/wit/nexum-host" diff --git a/videre/wit/deps/nexum-host/chain.wit b/videre/wit/deps/nexum-host/chain.wit deleted file mode 100644 index 1d812dc6..00000000 --- a/videre/wit/deps/nexum-host/chain.wit +++ /dev/null @@ -1,60 +0,0 @@ -package nexum:host@0.1.0; - -interface chain { - use types.{chain-id, fault}; - - /// A JSON-RPC error response from the upstream node. `code` is the - /// node-reported numeric (typically `-32000` for an `eth_call` - /// revert). `data` is the decoded `error.data` payload: the host - /// hex-decodes the upstream JSON string once, so a guest receives - /// the raw abi-encoded revert bytes without any string handling. - record rpc-error { - code: s32, - message: string, - data: option>, - } - - /// Failure of a chain call: either a shared host `fault` (transport - /// down, timed out, denied, ...) or a structured JSON-RPC error - /// carrying the node code and any decoded revert payload. - variant chain-error { - fault(fault), - rpc(rpc-error), - } - - /// A single JSON-RPC request to be executed as part of a batch. - record rpc-request { - method: string, - params: string, - } - - /// Result of a single request inside a batch. Each entry is independent; - /// one failing call does not abort the others. - variant rpc-result { - ok(string), - err(chain-error), - } - - /// Execute a JSON-RPC request against the specified chain. - /// - /// The host routes to its configured provider for the given chain, - /// applying whatever middleware is appropriate for the platform - /// (timeout, retry, rate-limit, fallback on server; simple HTTP - /// on mobile; window.ethereum or injected provider in WebView). - /// - /// `method` includes the namespace prefix (e.g. "eth_call"). Each - /// host enforces its own permitted method surface; the reference - /// server host forwards only a read-only set and refuses signing - /// or mutating methods with a `denied` fault before they reach the - /// provider. `params` and the success value are JSON-encoded - /// strings. - request: func(chain-id: chain-id, method: string, params: string) - -> result; - - /// Execute several JSON-RPC requests against the same chain in a single - /// round trip where the host transport supports it. Hosts that cannot - /// batch natively MUST fall back to sequential `request` calls. The - /// returned list is the same length as `requests` and in the same order. - request-batch: func(chain-id: chain-id, requests: list) - -> result, chain-error>; -} diff --git a/videre/wit/deps/nexum-host/event-module.wit b/videre/wit/deps/nexum-host/event-module.wit deleted file mode 100644 index 277134a9..00000000 --- a/videre/wit/deps/nexum-host/event-module.wit +++ /dev/null @@ -1,24 +0,0 @@ -package nexum:host@0.1.0; - -/// Event-driven module — automation, background processing. -/// No UI capabilities. Runs on any conforming host. -world event-module { - use types.{config, event, fault}; - - // Six core primitives (always provided by a conforming host). - import chain; - import identity; - import local-store; - import remote-store; - import messaging; - import logging; - - // Time, randomness, and outbound HTTP are WASI concerns, not - // nexum:host interfaces: wasi:clocks and wasi:random are linked - // into every module store, and hosts link - // wasi:http/outgoing-handler, gated per-module by the - // `[capabilities.http].allow` allowlist in module.toml. - - export init: func(config: config) -> result<_, fault>; - export on-event: func(event: event) -> result<_, fault>; -} diff --git a/videre/wit/deps/nexum-host/identity.wit b/videre/wit/deps/nexum-host/identity.wit deleted file mode 100644 index 2e82c9fe..00000000 --- a/videre/wit/deps/nexum-host/identity.wit +++ /dev/null @@ -1,24 +0,0 @@ -package nexum:host@0.1.0; - -/// Identity / signing capability. -/// -/// 0.2 ships a single, minimal interface. A future release (0.4+) is -/// expected to split this into `identity-read` and `identity-sign` and to -/// introduce a richer `signing-result` variant; for 0.2 the simple shape is -/// sufficient because user rejection can already be expressed via -/// `fault.denied`. -interface identity { - use types.{fault}; - - /// Return the list of account addresses (20-byte EVM addresses) the host - /// is willing to sign for. Empty list means no signing capability. - accounts: func() -> result>, fault>; - - /// Sign an arbitrary message with personal_sign semantics (prepends the - /// "\x19Ethereum Signed Message:\n" prefix). Returns a 65-byte signature. - sign: func(account: list, message: list) -> result, fault>; - - /// Sign EIP-712 typed data. `typed-data` is a JSON-encoded EIP-712 payload. - /// Returns a 65-byte signature. - sign-typed-data: func(account: list, typed-data: string) -> result, fault>; -} diff --git a/videre/wit/deps/nexum-host/local-store.wit b/videre/wit/deps/nexum-host/local-store.wit deleted file mode 100644 index 8521b37a..00000000 --- a/videre/wit/deps/nexum-host/local-store.wit +++ /dev/null @@ -1,29 +0,0 @@ -package nexum:host@0.1.0; - -interface local-store { - use types.{fault}; - - /// Get a value by key. Returns none if the key does not exist. - get: func(key: string) -> result>, fault>; - - /// Set a key-value pair. Overwrites any existing value. - /// The host may enforce a size quota; if exceeded, returns err. - set: func(key: string, value: list) -> result<_, fault>; - - /// Delete a key. No-op if the key does not exist. - delete: func(key: string) -> result<_, fault>; - - /// List all keys matching a prefix. Empty prefix returns all keys. - list-keys: func(prefix: string) -> result, fault>; - - /// Whether the key exists, without transferring the value. - contains: func(key: string) -> result; - - /// Value byte length, none if the key is absent, without - /// transferring the value. On some backends this may be a scan. - len: func(key: string) -> result, fault>; - - /// Number of keys matching a prefix, without materialising the key - /// list. On some backends this may be a scan. - count: func(prefix: string) -> result; -} diff --git a/videre/wit/deps/nexum-host/logging.wit b/videre/wit/deps/nexum-host/logging.wit deleted file mode 100644 index 8cd98140..00000000 --- a/videre/wit/deps/nexum-host/logging.wit +++ /dev/null @@ -1,15 +0,0 @@ -package nexum:host@0.1.0; - -interface logging { - enum level { - trace, - debug, - info, - warn, - error, - } - - /// Emit a structured log message. - /// The host decides how to handle it (stdout, file, discard). - log: func(level: level, message: string); -} diff --git a/videre/wit/deps/nexum-host/messaging.wit b/videre/wit/deps/nexum-host/messaging.wit deleted file mode 100644 index 30777ca7..00000000 --- a/videre/wit/deps/nexum-host/messaging.wit +++ /dev/null @@ -1,19 +0,0 @@ -package nexum:host@0.1.0; - -interface messaging { - use types.{fault, message}; - - /// Publish a message to a content topic. - /// - /// Content topics follow the format: //// - /// e.g. "/nexum/1/twap-updates/proto" - publish: func(content-topic: string, payload: list) -> result<_, fault>; - - /// Query historical messages from the Waku store protocol. - query: func( - content-topic: string, - start-time: option, - end-time: option, - limit: option, - ) -> result, fault>; -} diff --git a/videre/wit/deps/nexum-host/query-module.wit b/videre/wit/deps/nexum-host/query-module.wit deleted file mode 100644 index ffb29b87..00000000 --- a/videre/wit/deps/nexum-host/query-module.wit +++ /dev/null @@ -1,25 +0,0 @@ -package nexum:host@0.1.0; - -/// Query module — synchronous, side-effect-free evaluation. -/// -/// EXPERIMENTAL (0.2): the shape of this world is provisional and may -/// change in a future minor release without a major bump. Hosts and SDKs -/// should expect breakage here until the world is stabilised. -/// -/// A query module exposes a single pure `evaluate` entry point. It is given -/// read-only access to the local store (for cached/derived state) and to -/// logging; everything else (chain access, network, messaging, signing) is -/// deliberately excluded so the host can run queries inside a tight -/// deterministic sandbox. -world query-module { - use types.{config, fault}; - - import local-store; - import logging; - - export init: func(config: config) -> result<_, fault>; - - /// Evaluate the query. `input` and the returned bytes are opaque to the - /// host; the module and its caller agree on the encoding. - export evaluate: func(input: list) -> result, fault>; -} diff --git a/videre/wit/deps/nexum-host/remote-store.wit b/videre/wit/deps/nexum-host/remote-store.wit deleted file mode 100644 index 39b36bae..00000000 --- a/videre/wit/deps/nexum-host/remote-store.wit +++ /dev/null @@ -1,33 +0,0 @@ -package nexum:host@0.1.0; - -interface remote-store { - use types.{fault}; - - /// Upload raw data to the decentralised store. - /// Returns the 32-byte content reference (Swarm address). - upload: func(data: list) -> result, fault>; - - /// Download raw data by 32-byte content reference. - download: func(reference: list) -> result, fault>; - - /// Read the latest value from a mutable feed. - /// - /// Feeds are mutable pointers: (owner, topic) -> latest chunk. - /// `owner`: 20-byte Ethereum address of the feed owner. - /// `topic`: 32-byte topic hash. - read-feed: func( - owner: list, - topic: list, - ) -> result>, fault>; - - /// Update a mutable feed with new data. - /// - /// The host signs the feed update with its configured identity. - /// `topic`: 32-byte topic hash. - /// `data`: the payload to publish. - /// Returns the 32-byte reference of the new chunk. - write-feed: func( - topic: list, - data: list, - ) -> result, fault>; -} diff --git a/videre/wit/deps/nexum-host/types.wit b/videre/wit/deps/nexum-host/types.wit deleted file mode 100644 index 8e834263..00000000 --- a/videre/wit/deps/nexum-host/types.wit +++ /dev/null @@ -1,106 +0,0 @@ -package nexum:host@0.1.0; - -/// Common types shared across all runtime interfaces. -/// -/// All `u64` timestamps in this package are milliseconds since the Unix -/// epoch, UTC. -interface types { - type chain-id = u64; - - record block { - chain-id: chain-id, - number: u64, - hash: list, - timestamp: u64, - } - - /// One decoded log, mirroring the RPC `eth_getLogs` shape field for - /// field so the guest can rebuild the native alloy log losslessly. - /// Fixed-width byte fields are carried raw: `address` is 20 bytes, - /// each topic and hash is 32. The block-scoped fields are absent on a - /// pending log (mined logs carry them all). - record chain-log { - address: list, - topics: list>, - data: list, - block-hash: option>, - block-number: option, - block-timestamp: option, - transaction-hash: option>, - transaction-index: option, - log-index: option, - removed: bool, - } - - /// A batch of logs delivered from one subscription. The alloy log type - /// carries no chain id, so it sits here once: every log in a delivery - /// shares the chain of the subscription that produced it. - record chain-logs { - chain-id: chain-id, - logs: list, - } - - /// A message delivered over the messaging interface. Defined here (rather - /// than only in `messaging.wit`) so the `event` variant can reference it - /// without a cross-interface use clause. - record message { - content-topic: string, - payload: list, - timestamp: u64, - /// Optional sender identity (protocol-dependent). - sender: option>, - } - - /// Fired by the host on a configured cadence. `fired-at` is the host's - /// wall-clock time (ms since Unix epoch, UTC) at which the tick was - /// generated. - record tick { - fired-at: u64, - } - - /// A host-observed status transition for a previously submitted - /// intent. Transport-blind: the subscriber sees only where the - /// intent is in its life, never how the host learnt it. - record intent-status-update { - /// Venue id the receipt was issued by. - venue: string, - /// The venue-scoped intent identifier, opaque to the host. - receipt: list, - /// Opaque versioned status body: a leading u8 version tag, - /// then that version's borsh payload (v1: lifecycle status, - /// optional settlement proof, optional failure reason). An - /// unknown tag is a decode error and the body is never empty. - /// The host never inspects it. - status: list, - } - - variant event { - block(block), - chain-logs(chain-logs), - tick(tick), - message(message), - intent-status(intent-status-update), - } - - /// Opaque config from module.toml [config] section. - type config = list>; - - /// The cross-domain failure vocabulary richer interfaces embed as a - /// case. Each payload-bearing case carries a human-readable detail; - /// `rate-limited` carries structured backoff guidance instead. - variant fault { - unsupported(string), - unavailable(string), - denied(string), - rate-limited(rate-limit), - timeout, - invalid-input(string), - internal(string), - } - - /// Backoff guidance for a `fault.rate-limited`. `retry-after-ms` is - /// the host's suggested wait before retrying, when known. - record rate-limit { - retry-after-ms: option, - } -} diff --git a/videre/wit/videre-types/types.wit b/videre/wit/videre-types/types.wit deleted file mode 100644 index e8b69c81..00000000 --- a/videre/wit/videre-types/types.wit +++ /dev/null @@ -1,83 +0,0 @@ -package videre:types@0.1.0; - -/// The venue-neutral intent ontology. Depends only on value-flow; never on -/// nexum:host, so the venue-error transport cases are its own. -interface types { - use videre:value-flow/types@0.1.0.{asset-amount}; - - /// How an intent is authorised at its venue. Non-EVM schemes are 0.2+. - variant auth-scheme { - eip1271, - eip712, - } - - /// Where a deal settles. EVM-only in 0.1. - record settlement { - chain: u64, - } - - /// Adapter-derived description of an intent body: the ontology guard policy - /// runs on. Policy has teeth on `gives`; `wants` is display-grade. - record intent-header { - gives: asset-amount, - wants: asset-amount, - settlement: settlement, - authorisation: auth-scheme, - } - - /// Venue-scoped stable id for a submitted intent. Opaque to host and policy. - type receipt = list; - - /// An EVM call the host must sign and send. The adapter only describes it; - /// the host fills gas/fee and signs, so adapters cannot move value. Always - /// a call to existing code. - record unsigned-tx { - chain: u64, - /// 20-byte contract address. - to: list, - /// Native value, big-endian minimal; empty is zero. - value: list, - /// ABI-encoded calldata. - data: list, - } - - /// What a successful submit produced. - variant submit-outcome { - accepted(receipt), - requires-signing(unsigned-tx), - } - - /// Lifecycle state. Coarse and portable; proof and failure reason ride the - /// opaque status body (docs/design/videre-wit-pinned-0.1.0.md). - enum intent-status { - pending, - open, - fulfilled, - cancelled, - expired, - } - - /// Failure of a client or adapter call. `denied` and `rate-limited` are the - /// only guard/transport shapes; `denied` MUST NOT be retried. - variant venue-error { - unknown-venue, - invalid-body(string), - unsupported, - denied(string), - rate-limited(rate-limit), - unavailable(string), - timeout, - } - - record rate-limit { - retry-after-ms: option, - } - - /// An indicative quotation for a body. Firm/RFQ maker-side offers are 0.2+. - record quotation { - gives: asset-amount, - wants: asset-amount, - fee: asset-amount, - valid-until-ms: u64, - } -} diff --git a/videre/wit/videre-value-flow/types.wit b/videre/wit/videre-value-flow/types.wit deleted file mode 100644 index c3aa9de9..00000000 --- a/videre/wit/videre-value-flow/types.wit +++ /dev/null @@ -1,32 +0,0 @@ -package videre:value-flow@0.1.0; - -/// Egress-neutral vocabulary for value in motion. Carries no dependency so it -/// outlives any contract built on it. EVM-only in 0.1. -interface types { - /// 20-byte EVM address, big-endian. - type address = list; - - /// Unsigned integer, big-endian, minimal-length: no leading zero bytes, - /// zero is the empty list. Decoders MUST compare by integer value, not by - /// byte equality. - type uint = list; - - /// An ERC-20 token on the intent's settlement chain. - record erc20 { - token: address, - } - - /// A kind of value that can move. erc721/erc1155/service/offchain are 0.2+. - variant asset { - /// The settlement chain's gas token. - native, - erc20(erc20), - } - - /// An amount of one asset. Never negative; direction lives in the field - /// that holds the pair (`gives` vs `wants`). - record asset-amount { - asset: asset, - amount: uint, - } -} diff --git a/videre/wit/videre-venue/venue.wit b/videre/wit/videre-venue/venue.wit deleted file mode 100644 index 8a9685f2..00000000 --- a/videre/wit/videre-venue/venue.wit +++ /dev/null @@ -1,49 +0,0 @@ -package videre:venue@0.1.0; - -/// Worker (keeper) face. The host holds the venue registry; the keeper names -/// a venue by string. -interface client { - use videre:types/types@0.1.0.{quotation, receipt, intent-status, submit-outcome, venue-error}; - - quote: func(venue: string, body: list) -> result; - submit: func(venue: string, body: list) -> result; - /// Put an externally-obtained receipt (e.g. an on-chain placement) - /// under the host's status watch; an accepted submit is watched - /// implicitly. Idempotent. - observe: func(venue: string, receipt: receipt) -> result<_, venue-error>; - status: func(venue: string, receipt: receipt) -> result; - cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; -} - -/// Provider (venue) face. Mirrors `client` without the venue selector: one -/// installed adapter answers for exactly one venue, so the registry resolves -/// a venue id to its adapter and calls it directly. -interface adapter { - use videre:types/types@0.1.0.{intent-header, quotation, receipt, intent-status, submit-outcome, venue-error}; - - /// Body-schema versions this adapter decodes. Must equal the - /// manifest `[venue] body_versions` set; install asserts it. - body-versions: func() -> list; - - /// Pure: derive the guard-facing header from a body. No I/O. - derive-header: func(body: list) -> result; - quote: func(body: list) -> result; - submit: func(body: list) -> result; - status: func(receipt: receipt) -> result; - cancel: func(receipt: receipt) -> result<_, venue-error>; -} - -/// A venue adapter component: the provider face over scoped transport only. -/// No local-store, remote-store, identity, or logging import, so an adapter -/// structurally cannot touch host key material or persistent state. Outbound -/// HTTP is wasi:http, linked separately and allowlisted per adapter. -world venue-adapter { - use nexum:host/types@0.1.0.{config, fault}; - - import nexum:host/chain@0.1.0; - import nexum:host/messaging@0.1.0; - - /// Configure the adapter from its `[config]` before any submission. - export init: func(config: config) -> result<_, fault>; - export adapter; -} diff --git a/shepherd/wit/deps.lock b/wit/deps.lock similarity index 100% rename from shepherd/wit/deps.lock rename to wit/deps.lock diff --git a/wit/deps.toml b/wit/deps.toml new file mode 100644 index 00000000..12ac0ee5 --- /dev/null +++ b/wit/deps.toml @@ -0,0 +1,21 @@ +# Cross-repo WIT pins, vendored under wit/deps and verified against +# deps.lock by `wit-deps`. Pins are exact git tags of the owning repo, +# one tag per upstream, agreeing with the Rust pins; they converge to +# wkg/OCI (ghcr.io/nullislabs) with per-package semver once the +# packages are published. + +[nexum-host] +url = "https://github.com/nullislabs/nexum-runtime/archive/17005ba50466e18a64b2f145544c75198549d8a1.tar.gz" +prefix = "nexum-runtime-17005ba50466e18a64b2f145544c75198549d8a1/wit/nexum-host" + +[videre-types] +url = "https://github.com/nullislabs/videre-nexum-module/archive/112868fb9dc3d9021a0fd17739d1443383b06612.tar.gz" +prefix = "videre-nexum-module-112868fb9dc3d9021a0fd17739d1443383b06612/wit/videre-types" + +[videre-value-flow] +url = "https://github.com/nullislabs/videre-nexum-module/archive/112868fb9dc3d9021a0fd17739d1443383b06612.tar.gz" +prefix = "videre-nexum-module-112868fb9dc3d9021a0fd17739d1443383b06612/wit/videre-value-flow" + +[videre-venue] +url = "https://github.com/nullislabs/videre-nexum-module/archive/112868fb9dc3d9021a0fd17739d1443383b06612.tar.gz" +prefix = "videre-nexum-module-112868fb9dc3d9021a0fd17739d1443383b06612/wit/videre-venue" diff --git a/nexum/wit/nexum-host/chain.wit b/wit/deps/nexum-host/chain.wit similarity index 100% rename from nexum/wit/nexum-host/chain.wit rename to wit/deps/nexum-host/chain.wit diff --git a/nexum/wit/nexum-host/event-module.wit b/wit/deps/nexum-host/event-module.wit similarity index 100% rename from nexum/wit/nexum-host/event-module.wit rename to wit/deps/nexum-host/event-module.wit diff --git a/nexum/wit/nexum-host/identity.wit b/wit/deps/nexum-host/identity.wit similarity index 100% rename from nexum/wit/nexum-host/identity.wit rename to wit/deps/nexum-host/identity.wit diff --git a/nexum/wit/nexum-host/local-store.wit b/wit/deps/nexum-host/local-store.wit similarity index 100% rename from nexum/wit/nexum-host/local-store.wit rename to wit/deps/nexum-host/local-store.wit diff --git a/nexum/wit/nexum-host/logging.wit b/wit/deps/nexum-host/logging.wit similarity index 100% rename from nexum/wit/nexum-host/logging.wit rename to wit/deps/nexum-host/logging.wit diff --git a/nexum/wit/nexum-host/messaging.wit b/wit/deps/nexum-host/messaging.wit similarity index 100% rename from nexum/wit/nexum-host/messaging.wit rename to wit/deps/nexum-host/messaging.wit diff --git a/nexum/wit/nexum-host/query-module.wit b/wit/deps/nexum-host/query-module.wit similarity index 100% rename from nexum/wit/nexum-host/query-module.wit rename to wit/deps/nexum-host/query-module.wit diff --git a/nexum/wit/nexum-host/remote-store.wit b/wit/deps/nexum-host/remote-store.wit similarity index 100% rename from nexum/wit/nexum-host/remote-store.wit rename to wit/deps/nexum-host/remote-store.wit diff --git a/nexum/wit/nexum-host/types.wit b/wit/deps/nexum-host/types.wit similarity index 100% rename from nexum/wit/nexum-host/types.wit rename to wit/deps/nexum-host/types.wit diff --git a/shepherd/wit/deps/videre-types/types.wit b/wit/deps/videre-types/types.wit similarity index 100% rename from shepherd/wit/deps/videre-types/types.wit rename to wit/deps/videre-types/types.wit diff --git a/shepherd/wit/deps/videre-value-flow/types.wit b/wit/deps/videre-value-flow/types.wit similarity index 100% rename from shepherd/wit/deps/videre-value-flow/types.wit rename to wit/deps/videre-value-flow/types.wit diff --git a/shepherd/wit/deps/videre-venue/venue.wit b/wit/deps/videre-venue/venue.wit similarity index 100% rename from shepherd/wit/deps/videre-venue/venue.wit rename to wit/deps/videre-venue/venue.wit diff --git a/shepherd/wit/shepherd-cow/cow-events.wit b/wit/shepherd-cow/cow-events.wit similarity index 100% rename from shepherd/wit/shepherd-cow/cow-events.wit rename to wit/shepherd-cow/cow-events.wit From 1726d3dd1e1a7c396e15657bf40bd46258e629e0 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 18 Jul 2026 09:54:56 +0000 Subject: [PATCH 2/2] docs: update cross-repo-deps for the executed split Rev (commit) pins not tags; the umbrella and dep-sync gate are historical. --- docs/design/cross-repo-deps.md | 95 ++++++++++++++-------------------- 1 file changed, 39 insertions(+), 56 deletions(-) diff --git a/docs/design/cross-repo-deps.md b/docs/design/cross-repo-deps.md index 64cf3429..d2b49ed4 100644 --- a/docs/design/cross-repo-deps.md +++ b/docs/design/cross-repo-deps.md @@ -1,59 +1,42 @@ # Cross-repo dependencies -How the three repos source each other after the carve, and how the -transitional umbrella keeps the monorepo building as one workspace until -then. The repos: `nullislabs/nexum-runtime` (the `nexum/` grouping, -including `nexum-world`), `nullislabs/videre-nexum-module` (`videre/`), -and `nullislabs/shepherd` staying as the app-level bundle (`shepherd/`). -Dependencies flow strictly up: shepherd on videre and nexum-runtime, -videre on nexum-runtime, nexum-runtime on neither. Plan: -`docs/design/videre-split-plan.md` (design-notes branch), section 6.2 -decisions D9 and D10. - -## Transitional umbrella (pre-carve) - -- One workspace, one hoisted dependency table, one `Cargo.lock`, atomic - folds across the groupings. -- Member manifests write cross-group Rust dependencies in their final - post-carve form: exact git-tag pins on the owning repo, one tag per - repo. -- The umbrella root `[patch]` table redirects each pinned repo URL to - the in-tree crate, so the monorepo builds the working tree, not the - tag, and stays green through the whole train. -- Each grouping carries `Cargo.repo.toml`, the workspace root it ships - with at carve time; its member list mirrors the umbrella's members - for that grouping. -- Each grouping resolves WIT crate-locally; cross-group WIT is sourced - through `wit-deps` git tags with checked-in locks - (`/wit/deps.toml` plus `deps.lock`). - -## Convergence path (decision D9) - -1. Git-tag pins, at carve time: cross-repo Rust deps are exact `tag` - pins, cross-repo WIT rides `wit-deps` git tags, locks are committed. - No semver resolution; a bump is an explicit repin downstream. -2. crates.io, once the surfaces stabilise: `nexum-runtime` and the - videre crates publish; downstreams move to caret ranges; the - `[patch]` table and the git pins disappear. +How shepherd sources the two upstream repos after the carve. The repos: +`nullislabs/nexum-runtime` (L1, the generic runtime, including +`nexum-world`), `nullislabs/videre-nexum-module` (L2, the videre venue +platform), and this repo, `nullislabs/shepherd` (L3, the app-level CoW +bundle). Dependencies flow strictly up: shepherd on videre and +nexum-runtime, videre on nexum-runtime, nexum-runtime on neither. + +## Pinning + +- Cross-repo Rust deps are exact git `rev` (commit) pins on the owning + repo: `nexum-*` on nexum-runtime, `videre-*` on videre-nexum-module. + No semver resolution; a bump is an explicit repin. +- Cross-repo WIT rides `wit-deps` commit-tarball sources with a + checked-in lock (`wit/deps.toml` plus `deps.lock`), one commit per + upstream repo. +- The videre `client` capability the keepers declare is registered in the + root `extensions.toml`; the `#[nexum_sdk::module]` macro resolves it + from the nearest ancestor registry. + +## History (pre-carve umbrella) + +Before the carve the three groupings lived in one workspace under a +transitional umbrella `Cargo.toml`: member manifests wrote the final +cross-repo pins, and a root `[patch]` table redirected each pinned repo +URL to the in-tree grouping so the monorepo built the working tree and +stayed green through the split train. The carve removed the umbrella, the +`dep-sync` gate and the groupings; each upstream grouping shipped with its +`Cargo.repo.toml` renamed to `Cargo.toml`. + +## Convergence path + +1. Git-rev pins, today: exact commit pins, WIT via `wit-deps` tarballs, + locks committed. A bump is an explicit downstream repin. +2. crates.io, once the surfaces stabilise: `nexum-runtime` and the videre + crates publish; downstreams move to caret ranges and the git pins + disappear. 3. wkg/OCI, alongside crates.io: the WIT packages publish to - `ghcr.io/nullislabs`; `wit-deps` git sources become registry - fetches. -4. shepherd stays app-level: it pins upstream tags and never publishes. - -## Enforcement - -`scripts/check-dep-sync.sh`, blocking as the `dep-sync` CI job and -locally via `just check-dep-sync`: - -- the crate DAG points strictly up across the groupings for every - workspace member, including path deps and `wit/` symlinks; -- every umbrella `[patch]` entry targets the owning grouping's in-tree - crate and neutralises a written pin, with no orphans; -- git-tag pins agree on one tag per upstream repo everywhere they are - written; -- each `Cargo.repo.toml` member list mirrors the umbrella's; -- `wit-deps` manifests and locks exist together, stay key-synced, and - their tags agree with the Rust pins and across groups. - -A check whose artefact is not yet written skips visibly and starts -enforcing the moment the artefact lands. + `ghcr.io/nullislabs`; `wit-deps` git sources become registry fetches. + +shepherd stays app-level: it pins upstream commits and never publishes.