diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 2dea7fa..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[target.wasm32-wasip2] -rustflags = ["-Cpanic=abort"] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..840618e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default owner — required reviewer for any path not matched below. +* @mfw78 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..d520d5a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,51 @@ +name: Bug report +description: Report a defect or unexpected behavior in Shepherd. +labels: [bug] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: One- or two-sentence description of the bug. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Reproduction steps + description: Minimal steps to reproduce. Include commands, module manifest, and host config if relevant. + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: Include logs, stack traces, or `RUST_LOG=debug` output if available. + validations: + required: true + - type: input + id: version + attributes: + label: Shepherd version / commit + placeholder: "e.g. 0.1.0 or a2f5b8c" + validations: + required: true + - type: input + id: env + attributes: + label: Environment + placeholder: "OS, Rust toolchain, wasmtime version if non-default" + - type: textarea + id: extra + attributes: + label: Additional context diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..98ec03f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,31 @@ +name: Feature request +description: Propose a new capability, WIT interface, or behavior. +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What can't you do today, or what's painful about how it works now? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Sketch of the API, behavior, or workflow you'd like. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: textarea + id: scope + attributes: + label: Scope + description: Universal `nexum:host` change, domain extension (e.g. `shepherd:cow`), runtime-only, or SDK-only? + - type: textarea + id: extra + attributes: + label: Additional context diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1a42137 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +## Summary + + + +## Changes + + + +- + +## Test plan + + + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets -- -D warnings` +- [ ] `cargo test --workspace` +- [ ] + +## Notes for reviewer + + diff --git a/.github/actions/rust-setup/action.yml b/.github/actions/rust-setup/action.yml index 8343707..b5a5c12 100644 --- a/.github/actions/rust-setup/action.yml +++ b/.github/actions/rust-setup/action.yml @@ -1,5 +1,5 @@ name: rust-setup -description: Pinned Rust 1.94 + sccache (GitHub Actions cache backend, SCCACHE_GHA_ENABLED) + registry-only Swatinem cache shared across jobs. +description: Pinned Rust 1.94 + sccache (R2 backend, configured at the workflow env level) + registry-only Swatinem cache shared across jobs. inputs: targets: @@ -11,7 +11,7 @@ inputs: save-if: description: > May this run WRITE the registry cache? Defaults to always: Swatinem now caches - only the small ~/.cargo registry (target/ moved to sccache over the GitHub Actions cache), and the GHA + only the small ~/.cargo registry (target/ moved to sccache/R2), and the GHA cache is branch-scoped, so a PR saving its own registry warms its re-runs without touching the develop baseline. Set false to make a job restore-only. default: "true" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5e7cc10 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + wasmtime: + patterns: + - "wasmtime*" + - "wit-bindgen*" + - "wasmtime-wasi*" + patch-updates: + update-types: + - patch + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 327820f..4109083 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,21 +19,27 @@ env: # jobs: any per-job RUSTFLAGS delta re-fingerprints the whole graph and every # sccache object key, defeating reuse. RUSTFLAGS: "-D warnings" - # sccache caches individual rustc invocations across jobs and runs, including the - # workspace crates (which Swatinem drops). Requires incremental off. Backed by the - # GitHub Actions cache (SCCACHE_GHA_ENABLED): free, no secrets, and available on - # fork pull_request runs, so forks build cached too. The per-repo cache evicts LRU - # on its own; a miss degrades to a cold compile, never a build failure. - RUSTC_WRAPPER: sccache + # sccache caches individual rustc invocations across jobs AND runs, including the + # workspace crates (which Swatinem drops). Requires incremental off. Backed by a + # Cloudflare R2 bucket (S3 API) when its secrets are present; absent (e.g. a fresh + # fork or a repo without the R2 secrets) it fails open: an empty RUSTC_WRAPPER + # disables the wrapper and an empty SCCACHE_BUCKET drops sccache to its local-disk + # default. Builds run uncached but green. + RUSTC_WRAPPER: ${{ secrets.R2_ACCOUNT_ID != '' && 'sccache' || '' }} CARGO_INCREMENTAL: "0" - SCCACHE_GHA_ENABLED: "true" + SCCACHE_BUCKET: ${{ secrets.R2_ACCOUNT_ID != '' && 'nexum-sccache' || '' }} + SCCACHE_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com + SCCACHE_REGION: auto + SCCACHE_S3_KEY_PREFIX: ci/rust-1.94 + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} jobs: fmt: name: rustfmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/rust-setup with: components: rustfmt @@ -43,7 +49,7 @@ jobs: name: clippy runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/rust-setup with: components: clippy @@ -57,7 +63,7 @@ jobs: name: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # mold is symlinked as `ld` by setup-mold, NOT put in RUSTFLAGS: it keeps # linking fast (which sccache cannot cache anyway) without a linker flag in the # cache key. wasm targets link with rust-lld, untouched. @@ -101,6 +107,48 @@ jobs: # it may differ from RUSTFLAGS without splitting the compile cache. RUSTDOCFLAGS: "-D warnings" steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/rust-setup - run: cargo doc --workspace --no-deps --locked + + # Lenient guard that the workspace still type-checks for arm64 (the library is + # consumed on Android/iOS via arm64). The native x86_64 leg is dropped: on + # ubuntu-latest the host target IS x86_64-unknown-linux-gnu, which the clippy and + # test jobs already compile with more coverage. `continue-on-error` keeps this a + # signal, not a gate; it runs `cargo check` (no final link) so no cross linker is + # required. Push/dispatch only: it never gates, so a per-PR run would only spend a + # cold cross cache for nothing. + cross-check: + name: cross-check aarch64 + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/rust-setup + with: + targets: aarch64-unknown-linux-gnu + - name: install arm64 cross toolchain + run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu + - name: cargo check + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc + CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++ + AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar + run: cargo check --workspace --all-features --locked --target aarch64-unknown-linux-gnu + + # Blocking zero-leak gate: host-layer crate graphs stay venue-free, the + # runtime Rust sources carry no charter symbol and no privileged router + # field, and nexum:host names no foreign WIT package and resolves as a + # leaf (scripts/check-venue-agnostic.sh). + venue-agnostic: + name: venue-agnostic + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/rust-setup + - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: wasm-tools,ripgrep + - run: ./scripts/check-venue-agnostic.sh diff --git a/.gitignore b/.gitignore index 35532e0..8ebd986 100644 --- a/.gitignore +++ b/.gitignore @@ -33,19 +33,20 @@ skills-lock.json # Engine runtime state (default state_dir from engine.toml). data/ # Shipped crate data slices are source, not runtime state: keep them. -!crates/*/data/ -!crates/*/data/** +!*/crates/*/data/ +!*/crates/*/data/** # E2E automation: rendered configs with embedded RPC keys + script state # never get committed. *.local.toml -scripts/.state -scripts/.env +shepherd/scripts/.state +shepherd/scripts/.env # Operator-supplied engine config (carries paid RPC URLs / API keys). # The committed siblings `engine.example.toml`, `engine.docker.toml`, # and `engine.{m2,m3,e2e,load}.toml` are placeholder templates. /engine.toml +/shepherd/engine.toml # Generated reports under e2e-reports/ (operator commits the filled-in ones # manually via `git add -f`). diff --git a/Cargo.lock b/Cargo.lock index 39c386a..d8046c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -245,7 +245,7 @@ dependencies = [ "rustc-hash", "secp256k1 0.31.1", "serde", - "sha3", + "sha3 0.11.0", ] [[package]] @@ -280,7 +280,7 @@ dependencies = [ "lru", "parking_lot", "pin-project", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "thiserror 2.0.18", @@ -349,7 +349,7 @@ dependencies = [ "alloy-transport-ws", "futures", "pin-project", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "tokio", @@ -449,7 +449,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "sha3", + "sha3 0.11.0", "syn 2.0.118", "syn-solidity", ] @@ -524,7 +524,7 @@ dependencies = [ "alloy-json-rpc", "alloy-transport", "itertools 0.14.0", - "reqwest", + "reqwest 0.13.4", "serde_json", "tower", "tracing", @@ -564,7 +564,7 @@ dependencies = [ "rustls", "serde_json", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tracing", "url", "ws_stream_wasm", @@ -1003,6 +1003,34 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bee-rs" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "549d4dd83a83a5ccbb180606cc7206d887ba1ea064a53d186018202cdd2b5cf2" +dependencies = [ + "base64", + "bytes", + "futures-util", + "hex", + "num-bigint", + "num-traits", + "rand_core 0.6.4", + "reqwest 0.12.28", + "secp256k1 0.30.0", + "serde", + "serde_json", + "sha3 0.10.9", + "subtle", + "tar", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.24.0", + "tracing", + "url", + "zeroize", +] + [[package]] name = "bimap" version = "0.6.3" @@ -1346,6 +1374,8 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" name = "clock-reader" version = "0.1.0" dependencies = [ + "nexum-sdk", + "tracing", "wit-bindgen 0.59.0", ] @@ -2031,6 +2061,7 @@ name = "example" version = "0.1.0" dependencies = [ "nexum-sdk", + "tracing", "wit-bindgen 0.59.0", ] @@ -2081,6 +2112,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2109,6 +2150,8 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" name = "flaky-bomb" version = "0.1.0" dependencies = [ + "nexum-sdk", + "tracing", "wit-bindgen 0.59.0", ] @@ -2154,6 +2197,8 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" name = "fuel-bomb" version = "0.1.0" dependencies = [ + "nexum-sdk", + "tracing", "wit-bindgen 0.59.0", ] @@ -2319,8 +2364,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -2330,9 +2377,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -2567,6 +2616,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.8", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2912,6 +2977,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "keccak" version = "0.2.0" @@ -3009,24 +3083,6 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "load-gen" -version = "0.1.0" -dependencies = [ - "alloy-primitives", - "alloy-provider", - "alloy-rpc-types-eth", - "alloy-sol-types", - "alloy-transport-ws", - "anyhow", - "clap", - "futures", - "serde_json", - "tokio", - "tracing", - "tracing-subscriber", -] - [[package]] name = "lock_api" version = "0.4.14" @@ -3064,6 +3120,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mach2" version = "0.6.0" @@ -3115,6 +3177,8 @@ dependencies = [ name = "memory-bomb" version = "0.1.0" dependencies = [ + "nexum-sdk", + "tracing", "wit-bindgen 0.59.0", ] @@ -3166,6 +3230,22 @@ dependencies = [ "sketches-ddsketch", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.1" @@ -3179,7 +3259,7 @@ dependencies = [ [[package]] name = "nexum-cli" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "nexum-launch", @@ -3189,7 +3269,7 @@ dependencies = [ [[package]] name = "nexum-launch" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "clap", @@ -3210,7 +3290,7 @@ dependencies = [ [[package]] name = "nexum-runtime" -version = "0.1.0" +version = "0.2.0" dependencies = [ "alloy-chains", "alloy-primitives", @@ -3221,6 +3301,7 @@ dependencies = [ "alloy-transport-ws", "anyhow", "async-trait", + "bee-rs", "bytes", "futures", "http", @@ -3253,7 +3334,6 @@ dependencies = [ name = "nexum-sdk" version = "0.1.0" dependencies = [ - "alloy-chains", "alloy-json-rpc", "alloy-primitives", "alloy-provider", @@ -3261,10 +3341,10 @@ dependencies = [ "alloy-rpc-types-eth", "alloy-sol-types", "alloy-transport", - "borsh", "http", "nexum-module-macros", "nexum-sdk-test", + "nexum-status-body", "nexum-world", "proptest", "serde_json", @@ -3284,6 +3364,14 @@ dependencies = [ "tracing", ] +[[package]] +name = "nexum-status-body" +version = "0.1.0" +dependencies = [ + "borsh", + "thiserror 2.0.18", +] + [[package]] name = "nexum-tasks" version = "0.1.0" @@ -3297,8 +3385,6 @@ dependencies = [ name = "nexum-world" version = "0.1.0" dependencies = [ - "strum", - "syn 2.0.118", "tempfile", "toml 1.1.2+spec-1.1.0", ] @@ -3320,6 +3406,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "serde", ] [[package]] @@ -3768,6 +3855,61 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.46" @@ -4029,6 +4171,48 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 1.0.8", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -4209,6 +4393,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -4242,6 +4427,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "schemars" version = "0.9.0" @@ -4420,6 +4611,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -4484,6 +4687,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + [[package]] name = "sha3" version = "0.11.0" @@ -4491,7 +4704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ "digest 0.11.3", - "keccak", + "keccak 0.2.0", ] [[package]] @@ -4561,6 +4774,8 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" name = "slow-host" version = "0.1.0" dependencies = [ + "nexum-sdk", + "tracing", "wit-bindgen 0.59.0", ] @@ -4698,6 +4913,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -4889,6 +5115,22 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.24.0", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -4901,7 +5143,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.28.0", "webpki-roots 0.26.11", ] @@ -5118,6 +5360,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.28.0" @@ -5167,6 +5429,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -5403,6 +5671,19 @@ dependencies = [ "wasmparser 0.253.0", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.251.0" @@ -5810,6 +6091,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -6296,6 +6587,16 @@ dependencies = [ "tap", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 77be067..feeedfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,7 @@ +# 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", @@ -6,6 +10,7 @@ members = [ "crates/nexum-runtime", "crates/nexum-sdk", "crates/nexum-sdk-test", + "crates/nexum-status-body", "crates/nexum-tasks", "crates/nexum-world", "modules/example", @@ -18,175 +23,93 @@ members = [ "modules/fixtures/memory-bomb", "modules/fixtures/panic-bomb", "modules/fixtures/slow-host", - "tools/load-gen", ] resolver = "2" [workspace.package] -version = "0.1.0" edition = "2024" license = "AGPL-3.0" repository = "https://github.com/nullislabs/nexum-runtime" -# 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 is 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"] } +metrics = "0.24" +metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] } -# `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) 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 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"] } -# 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 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"] } +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 transport for the SDK helpers and test surfaces. -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 surface. 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" -# Proc-macro toolkit backing `nexum-module-macros`. Host-side only: a -# proc-macro crate always builds for the host, even when the module -# consuming it targets wasm. +# 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"] } -# `wit-bindgen` is consumed by every guest module crate (example + -# 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). +# WASM Component Model runtime. 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 } - -# Embedded key-value store (engine local-store backend). +# Embedded key-value store (local-store backend). redb = "4" # Misc engine host deps. url = "2" -# HTTP server for test tooling. -axum = "0.8" - -# Randomness for tooling. -rand = "0.10" - -# Hex codec for test fixtures and receipt rendering. -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" diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 0c97efd..0000000 --- a/LICENSE +++ /dev/null @@ -1,235 +0,0 @@ -GNU AFFERO GENERAL PUBLIC LICENSE -Version 3, 19 November 2007 - -Copyright (C) 2007 Free Software Foundation, Inc. - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - - Preamble - -The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. - -The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. - -When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. - -Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. - -A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. - -The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. - -An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. - -The precise terms and conditions for copying, distribution and modification follow. - - TERMS AND CONDITIONS - -0. Definitions. - -"This License" refers to version 3 of the GNU Affero General Public License. - -"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. - -"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. - -To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. - -A "covered work" means either the unmodified Program or a work based on the Program. - -To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. - -To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. - -An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. - -1. Source Code. -The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. - -A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. - -The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. - -The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those -subprograms and other parts of the work. - -The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. - -The Corresponding Source for a work in source code form is that same work. - -2. Basic Permissions. -All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. - -You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. - -Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. - -3. Protecting Users' Legal Rights From Anti-Circumvention Law. -No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. - -When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. - -4. Conveying Verbatim Copies. -You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. - -You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. - -5. Conveying Modified Source Versions. -You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". - - c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. - -A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. - -6. Conveying Non-Source Forms. -You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: - - a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. - - d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. - -A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. - -A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. - -"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. - -If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). - -The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. - -Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. - -7. Additional Terms. -"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. - -When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. - -Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or authors of the material; or - - e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. - -All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. - -If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. - -Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. - -8. Termination. - -You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). - -However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. - -Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. - -Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. - -9. Acceptance Not Required for Having Copies. - -You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. - -10. Automatic Licensing of Downstream Recipients. - -Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. - -An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. - -You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. - -11. Patents. - -A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". - -A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. - -Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. - -In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. - -If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. - -If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. - -A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. - -Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. - -12. No Surrender of Others' Freedom. - -If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. - -13. Remote Network Interaction; Use with the GNU General Public License. - -Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. - -Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. - -14. Revised Versions of this License. - -The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. - -If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. - -Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. - -15. Disclaimer of Warranty. - -THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. Limitation of Liability. - -IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -17. Interpretation of Sections 15 and 16. - -If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. - -END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - -If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. - -To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - - This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - -If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. - -You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . diff --git a/README.md b/README.md deleted file mode 100644 index 30e8ed3..0000000 --- a/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# nexum-runtime - -Nexum is a WASM Component Model host runtime for web3 modules. It supervises guest components built against the `nexum:host` WIT world, giving each module a capability-gated view of the host: chain access over JSON-RPC, an allowlisted `wasi:http` outbound gate, a local key-value store, clocks, and structured logging — with fuel, memory, and epoch limits enforced per module. - -This repository is the leaf of the Nullis runtime stack: it carries no cross-repo dependencies. Downstream repositories (videre, shepherd) build on the SDK and runtime published here. - -## Layout - -- `crates/nexum-runtime` — the engine host: wasmtime embedding, supervisor, capability providers, metrics. -- `crates/nexum-cli` — the bare `nexum` engine binary. -- `crates/nexum-launch` — shared launch surface (config loading, logging, presets). -- `crates/nexum-sdk` — the guest-side SDK modules build against. -- `crates/nexum-sdk-test` — SDK acceptance-test harness. -- `crates/nexum-module-macros` — proc-macros for module entrypoints. -- `crates/nexum-tasks` — task lifecycle and graceful shutdown. -- `crates/nexum-world` — single-source capability and fault-label vocabularies. -- `modules/example` — minimal reference module. -- `modules/examples/` — example modules (balance-tracker, http-probe, price-alert). -- `modules/fixtures/` — adversarial test fixtures (clock-reader, flaky-bomb, fuel-bomb, memory-bomb, panic-bomb, slow-host). -- `tools/load-gen` — load generator for soak runs. -- `wit/nexum-host` — the `nexum:host` WIT package. - -## Development - -The repository pins its toolchain via a Nix flake (Rust 1.94.0, matching CI): - -```sh -nix develop # or `direnv allow` once -just build # engine + all guest wasms -just test # host engine unit tests -just ci # full CI series locally (fmt, clippy, doc, wasms, nextest, doctests) -``` - -Without Nix, any Rust 1.94+ toolchain with the `wasm32-wasip2` target, `cargo-nextest`, and `just` works. - -## Running a module - -```sh -just run # builds the example module and runs the engine with it -``` - -The engine takes a component wasm and its `module.toml` (capabilities + config): - -```sh -cargo run -p nexum-cli -- target/wasm32-wasip2/release/example.wasm modules/example/module.toml -``` - -## Licence - -AGPL-3.0. See [LICENSE](LICENSE). diff --git a/crates/nexum-cli/Cargo.toml b/crates/nexum-cli/Cargo.toml index 4591ae7..05bab22 100644 --- a/crates/nexum-cli/Cargo.toml +++ b/crates/nexum-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nexum-cli" -version.workspace = true +version = "0.2.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/nexum-launch/Cargo.toml b/crates/nexum-launch/Cargo.toml index 2797184..aff18ad 100644 --- a/crates/nexum-launch/Cargo.toml +++ b/crates/nexum-launch/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nexum-launch" -version.workspace = true +version = "0.2.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/nexum-launch/src/cli.rs b/crates/nexum-launch/src/cli.rs index c2de94b..b9af9ba 100644 --- a/crates/nexum-launch/src/cli.rs +++ b/crates/nexum-launch/src/cli.rs @@ -40,8 +40,10 @@ pub struct Cli { #[arg(long = "pretty-logs")] pub pretty_logs: bool, - /// Override `[engine] log_backfill_concurrency`, the chain-log - /// poller's per-block `eth_getLogs` concurrency during backfill. + /// Override the chain-log poller's per-block `eth_getLogs` + /// concurrency during backfill. Higher catches up faster at more + /// node load. Overrides `[engine] log_backfill_concurrency` when + /// set. #[arg(long = "log-backfill-concurrency")] pub log_backfill_concurrency: Option, } diff --git a/crates/nexum-launch/src/lib.rs b/crates/nexum-launch/src/lib.rs index 0e47237..6994ca2 100644 --- a/crates/nexum-launch/src/lib.rs +++ b/crates/nexum-launch/src/lib.rs @@ -42,9 +42,9 @@ pub async fn launch(name: &str, preset: R, cli: Cli) -> anyhow::Resu .await } -/// Install the global tracing subscriber: JSON by default, the -/// human-readable formatter behind `--pretty-logs`. The same -/// [`EnvFilter`] (`RUST_LOG`, else the config level) applies to both. +/// 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)) diff --git a/crates/nexum-module-macros/Cargo.toml b/crates/nexum-module-macros/Cargo.toml index 547b47b..4bef46f 100644 --- a/crates/nexum-module-macros/Cargo.toml +++ b/crates/nexum-module-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nexum-module-macros" -version.workspace = true +version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true @@ -13,7 +13,7 @@ proc-macro = true workspace = true [dependencies] -nexum-world = { path = "../nexum-world", features = ["macros"] } +nexum-world = { path = "../nexum-world" } proc-macro2.workspace = true quote.workspace = true syn = { workspace = true, features = ["full"] } diff --git a/crates/nexum-module-macros/src/lib.rs b/crates/nexum-module-macros/src/lib.rs index d021d6b..bb90eb7 100644 --- a/crates/nexum-module-macros/src/lib.rs +++ b/crates/nexum-module-macros/src/lib.rs @@ -1,43 +1,68 @@ //! Proc-macro glue for nexum runtime modules. //! //! [`module`] turns an `impl` block of named handlers into a complete -//! per-cdylib module. Reach it through `nexum_sdk::module`, not this -//! crate directly; the venue-side macros live in `videre-macros`. +//! 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}; +use syn::{ImplItem, ItemImpl, Type}; -/// The handler names recognised on a `#[module]` impl. An `on_`-prefixed -/// method outside this set is a compile error; an absent handler -/// dispatches as a no-op. +/// The handler names recognised on a `#[module]` impl. Any method not in +/// this set is left untouched on the type, except that names starting +/// with `on_` are rejected at compile time (a typo'd handler would +/// otherwise silently never fire); any handler in the set that is absent +/// is treated as a no-op in the generated `on-event` dispatch. const HANDLERS: [&str; 6] = [ "init", "on_block", "on_chain_logs", "on_tick", "on_message", - "on_custom", + "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_custom`); each takes its event's wit-bindgen -/// payload and returns `Result<(), Fault>`, and `init` takes the config -/// table. Undefined handlers dispatch as no-ops. Emits -/// `wit_bindgen::generate!`, the host adapter, the `Guest` impl, and -/// `export!` around the untouched impl. +/// `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 world is per module: the macro reads the crate's `module.toml` -/// and synthesizes a world importing exactly the -/// `[capabilities].required` and `optional` declarations, so the -/// load-time capability check passes by construction. An undeclared -/// capability's bindings do not exist. Requirements: the manifest sits -/// at the crate root with a `[capabilities]` section; the crate depends -/// on `wit-bindgen` directly; and the crate root must not shadow the -/// std prelude names `Result`, `Vec`, or `Ok` (the generated `Guest` +/// 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 { @@ -53,7 +78,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(item as ItemImpl); let self_ty = &input.self_ty; - if !nexum_world::is_plain_type(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", @@ -113,7 +138,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { return syn::Error::new_spanned( self_ty, "#[nexum_sdk::module] found no recognised handlers on this impl; define at least one \ - of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_custom`", + of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_intent_status`", ) .to_compile_error() .into(); @@ -128,7 +153,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { .into(); } }; - let wit_paths = match nexum_world::manifest_wit_packages(&module_world.packages) { + 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) @@ -176,7 +201,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { let logs_arm = arm("on_chain_logs", "ChainLogs"); let tick_arm = arm("on_tick", "Tick"); let message_arm = arm("on_message", "Message"); - let custom_arm = arm("on_custom", "Custom"); + let intent_status_arm = arm("on_intent_status", "IntentStatus"); quote! { // Anchor a rebuild on the manifest and the extension registry: @@ -207,7 +232,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { #logs_arm #tick_arm #message_arm - #custom_arm + #intent_status_arm } } } @@ -217,12 +242,27 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } -/// Synthesize the per-module world from the crate's `module.toml` -/// `[capabilities]` plus the nearest ancestor `extensions.toml`. -/// Returns the rebuild anchor paths alongside the world. +/// 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 crate_dir = nexum_world::manifest_dir()?; - let manifest_path = crate_dir.join("module.toml"); + 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 \ @@ -236,7 +276,7 @@ fn derive_module_world() -> Result<(Vec, nexum_world::ModuleWorld), Stri let manifest_path = manifest_path.to_string_lossy().into_owned(); let mut anchors = vec![manifest_path.clone()]; - let extensions = match nexum_world::find_extensions_manifest(&crate_dir) { + let extensions = match nexum_world::find_extensions_manifest(&manifest_dir()?) { None => Vec::new(), Some(registry) => { let text = std::fs::read_to_string(®istry) @@ -251,3 +291,15 @@ fn derive_module_world() -> Result<(Vec, nexum_world::ModuleWorld), Stri .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/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index ca511dc..affc1a0 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nexum-runtime" -version.workspace = true +version = "0.2.0" edition.workspace = true license.workspace = true repository.workspace = true @@ -85,6 +85,9 @@ futures.workspace = true # 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 diff --git a/crates/nexum-runtime/examples/embed.rs b/crates/nexum-runtime/examples/embed.rs index 75c26df..129f989 100644 --- a/crates/nexum-runtime/examples/embed.rs +++ b/crates/nexum-runtime/examples/embed.rs @@ -1,10 +1,20 @@ //! 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}; @@ -19,7 +29,7 @@ async fn main() -> anyhow::Result<()> { let cfg = EngineConfig { modules: vec![ModuleEntry { path: "target/wasm32-wasip2/release/example.wasm".into(), - manifest: Some("modules/example/module.toml".into()), + manifest: Some("nexum/modules/example/module.toml".into()), }], ..EngineConfig::default() }; diff --git a/crates/nexum-runtime/src/addons.rs b/crates/nexum-runtime/src/addons.rs index 165ae01..114d235 100644 --- a/crates/nexum-runtime/src/addons.rs +++ b/crates/nexum-runtime/src/addons.rs @@ -1,19 +1,30 @@ -//! Cross-cutting runtime add-ons: process-wide facilities that attach to the -//! launch path without the core knowing their concrete type. An add-on -//! installs a facility from the resolved config and returns a handle the -//! launcher keeps alive for the run. +//! 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. +/// 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 run. +/// 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, @@ -26,18 +37,23 @@ impl AddOnHandle { } } -/// A process-wide facility attached to the launch path. +/// 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. +/// 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 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 { @@ -70,7 +86,8 @@ mod tests { use super::*; use crate::engine_config::MetricsSection; - /// An enabled exporter with an unparseable bind address fails at install. + /// 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 { diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index 5789985..b3debec 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -1,12 +1,21 @@ //! WIT bindings generated by `wasmtime::component::bindgen!`. //! -//! Binds the `nexum:host/event-module` world (the six core primitives). -//! Outbound HTTP is wasi:http, linked separately; clocks are ambient -//! wasi:clocks. `nexum:host` is a leaf package: the host `event` variant -//! carries a status transition as opaque bytes. An extension remaps onto these -//! shared interfaces with `with`, so the `Host` impls and `fault` type its -//! components see are the ones the core host constructs. `PartialEq` is derived -//! so extension services can compare event payloads. +//! 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"], diff --git a/crates/nexum-runtime/src/bootstrap.rs b/crates/nexum-runtime/src/bootstrap.rs index 88d7078..9ae57a9 100644 --- a/crates/nexum-runtime/src/bootstrap.rs +++ b/crates/nexum-runtime/src/bootstrap.rs @@ -1,8 +1,11 @@ -//! Generic launch entry point: assemble an [`AssembledRuntime`] from pre-built -//! backends and run it until shutdown. +//! Generic launch entry point: assemble the [`AssembledRuntime`] from +//! pre-built backends and run it until shutdown. //! -//! Forwards to the [`builder`](crate::builder) launcher and blocks until the -//! event loop returns. A launcher wanting the +//! 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. @@ -19,9 +22,14 @@ use crate::host::extension::Extension; /// Launch the runtime from a loaded config and run until shutdown. /// -/// `components` and `extensions` must agree: a module importing an extension -/// interface boots only if that extension is present in both. `add_ons` are the -/// cross-cutting facilities installed before the engine boots. +/// `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>, diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 195cefd..5b98e2d 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -1,12 +1,21 @@ //! Type-state runtime builder and the imperative launcher it drives. //! -//! [`RuntimeBuilder`] accumulates the assembly (config, lattice, extensions, -//! component builders, add-ons) through a type-state chain; -//! [`ReadyBuilder::launch`] opens the backends and hands off to -//! [`LaunchRuntime::launch`], which installs add-ons, builds the engine and -//! linker, boots the supervisor, opens subscriptions, spawns the event loop, -//! and returns a [`RuntimeHandle`]. [`RuntimeBuilder::runtime`] binds a -//! [`Runtime`] preset for the common case. +//! [`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; @@ -18,7 +27,7 @@ use nexum_tasks::{DrainOutcome, TaskExit, TaskHandle, TaskManager, TaskSet}; use tracing::{error, info, warn}; use wasmtime::Engine; -use crate::addons::{AddOnHandle, AddOns, AddOnsContext, RuntimeAddOn}; +use crate::addons::{AddOnHandle, AddOnsContext, RuntimeAddOn}; use crate::engine_config::EngineConfig; use crate::host::component::{ BuilderContext, ComponentBuilder, Components, ComponentsBuilder, RuntimeTypes, @@ -30,7 +39,8 @@ use crate::runtime::event_loop; pub use crate::supervisor::WasiClockOverride; use crate::supervisor::{self, Supervisor}; -/// Ambient inputs the launcher reads. +/// 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, @@ -38,10 +48,12 @@ pub struct LaunchContext<'a> { pub config: &'a EngineConfig, } -/// Upper bound on the final durable-flush drain before shutdown forces exit. +/// 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. [`shutdown`](Self::shutdown) or dropping fires shutdown; +/// 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, @@ -64,8 +76,9 @@ impl RuntimeHandle { } /// Block until the loop stops (on its own, on shutdown, or on a critical - /// task ending), bounding the final flush; a drain past the timeout forces - /// exit. + /// 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, @@ -361,14 +374,15 @@ impl<'a> RuntimeBuilder<'a> { } } - /// Bind a `Default` [`Runtime`] preset by marker; sugar over - /// [`with_runtime`](Self::with_runtime). + /// 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, carrying pre-built backends into - /// the launch. + /// 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, @@ -381,8 +395,10 @@ impl<'a> RuntimeBuilder<'a> { } } -/// Terminal stage of the preset shortcut, leaving only optional extension -/// hooks and the module source before [`launch`](Self::launch). +/// 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, @@ -410,41 +426,18 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { self } - /// Override the per-store WASI wall and monotonic clocks, including stores - /// rebuilt on restart. Omitting it leaves the ambient host clocks. + /// 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 } - /// Override the preset's component builders before launch; `map` swaps one - /// seam while the preset's extensions and add-ons carry through. Mirror of - /// [`TypedBuilder::with_components`]. - pub fn with_components( - self, - map: impl FnOnce( - ComponentsBuilder, - ) -> ComponentsBuilder, - ) -> PresetComponentsBuilder<'a, R::Types, C, S, E, L> { - // Gather the preset's extensions and add-ons before `components` - // consumes the preset by value. - let mut extensions = self.preset.extensions(self.config); - extensions.extend(self.extensions); - let add_ons = self.preset.add_ons(); - let components = map(self.preset.components()); - PresetComponentsBuilder { - config: self.config, - extensions, - add_ons, - wasm: self.wasm, - manifest: self.manifest, - clocks: self.clocks, - components, - } - } - - /// Open the preset's backends and launch, driving [`LaunchRuntime::launch`] - /// with a fresh [`TaskManager`]. + /// 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(); @@ -482,58 +475,6 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { } } -/// A preset with its component builders overridden through -/// [`PresetBuilder::with_components`], leaving only [`launch`](Self::launch). -pub struct PresetComponentsBuilder<'a, T: RuntimeTypes, C, S, E, L> { - config: &'a EngineConfig, - extensions: Vec>>, - add_ons: AddOns, - wasm: Option, - manifest: Option, - clocks: Option, - components: ComponentsBuilder, -} - -impl PresetComponentsBuilder<'_, T, C, S, E, L> -where - T: RuntimeTypes, - C: ComponentBuilder, - S: ComponentBuilder, - E: ComponentBuilder, - L: ComponentBuilder, -{ - /// Open the overridden backends and launch, otherwise as - /// [`PresetBuilder::launch`]. - pub async fn launch(self) -> anyhow::Result { - let tasks = TaskManager::new(); - let executor = tasks.executor(); - let data_dir = self.config.engine.state_dir.clone(); - let build_ctx = BuilderContext { - config: self.config, - data_dir: &data_dir, - executor: &executor, - }; - // `add_ons` owns the boxed add-ons; `add_on_refs` borrows into it and is - // consumed by the launch call, so both must stay in scope for that call. - let add_on_refs: Vec<&dyn RuntimeAddOn> = self.add_ons.iter().map(|a| &**a).collect(); - let components = self.components.build::(&build_ctx).await?; - - let runtime = AssembledRuntime { - components, - extensions: self.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> { @@ -563,8 +504,9 @@ impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { self } - /// Override the per-store WASI wall and monotonic clocks, including stores - /// rebuilt on restart. Omitting it leaves the ambient host clocks. + /// 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 @@ -636,8 +578,9 @@ where E: ComponentBuilder, L: ComponentBuilder, { - /// Open the backends and launch, driving [`LaunchRuntime::launch`] with a - /// fresh [`TaskManager`]. + /// 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(); @@ -680,19 +623,11 @@ mod tests { use crate::test_utils::Prebuilt; use wasmtime::component::Linker; - /// Workspace root: the topmost ancestor with a `Cargo.toml`. - fn workspace_root() -> std::path::PathBuf { - let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - manifest - .ancestors() - .filter(|d| d.join("Cargo.toml").is_file()) - .last() - .unwrap_or(manifest) - .to_path_buf() - } - - /// The preset shortcut reaches the supervisor boot, which bails on the - /// default config's empty module set. + /// 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"); @@ -710,7 +645,8 @@ mod tests { assert!(err.to_string().contains("no modules to run"), "{err}"); } - /// Counts linker hook runs. + /// Counts linker hook runs, so a test observes an extension reaching the + /// launch's linker build. struct CountingExt { namespace: &'static str, prefix: &'static str, @@ -764,7 +700,9 @@ mod tests { } } - /// Preset extensions and appended extensions each link exactly once. + /// 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"); @@ -826,7 +764,8 @@ mod tests { } } - /// A preset hands a pre-built pipeline through into the built bundle. + /// `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"); @@ -854,107 +793,19 @@ mod tests { ); } - /// A core-lattice preset with no add-ons, avoiding the process-global - /// Prometheus recorder (only one install succeeds per process). - struct NoAddOnCore; - - impl crate::sealed::SealedRuntime for NoAddOnCore {} - - impl RuntimePreset for NoAddOnCore { - type Types = CoreRuntime; - type ChainBuilder = ProviderPoolBuilder; - type StoreBuilder = LocalStoreBuilder; - type ExtBuilder = (); - type LogsBuilder = LogPipelineBuilder; - - fn components(self) -> ComponentsBuilder { - ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) - } - - fn add_ons(&self) -> AddOns { - Vec::new() - } - } - - /// Counts builds. - struct CountingLogsBuilder(Arc); - - impl ComponentBuilder for CountingLogsBuilder { - type Output = LogPipeline; - async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result { - self.0.fetch_add(1, Ordering::SeqCst); - Ok(LogPipeline::in_memory(ctx.config.limits.logs())) - } - } - - /// `with_components` overrides a seam: the substituted logs builder runs - /// once, then the launch bails on the empty module set. - #[tokio::test] - async fn preset_with_components_overrides_a_seam() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut config = EngineConfig::default(); - config.engine.state_dir = dir.path().join("state"); - - let built = Arc::new(AtomicUsize::new(0)); - let seen = built.clone(); - let err = match RuntimeBuilder::new(&config) - .with_runtime(NoAddOnCore) - .with_components(move |c| c.with_logs(CountingLogsBuilder(seen))) - .launch() - .await - { - Ok(_) => panic!("default config declares no modules; launch must bail"), - Err(err) => err, - }; - assert!(err.to_string().contains("no modules to run"), "{err}"); - assert_eq!( - built.load(Ordering::SeqCst), - 1, - "overridden logs builder ran once", - ); - } - - /// Full preset-path launch with an overridden logs seam; skips when the - /// module fixture is not built (`just build-module`). - #[tokio::test] - async fn e2e_preset_with_components_launches_through_overridden_logs() { - let repo_root = workspace_root(); - let wasm = repo_root.join("target/wasm32-wasip2/release/example.wasm"); - if !wasm.exists() { - eprintln!( - "SKIP: {} not found - run `just build-module` to enable E2E tests", - wasm.display() - ); - return; - } - let manifest = repo_root.join("nexum/modules/example/module.toml"); - - let dir = tempfile::tempdir().expect("tempdir"); - let mut config = EngineConfig::default(); - config.engine.state_dir = dir.path().join("state"); - - let custom = LogPipeline::in_memory(config.limits.logs()); - let mut handle = RuntimeBuilder::new(&config) - .with_runtime(NoAddOnCore) - .with_module_source(Some(wasm), Some(manifest)) - .with_components(|c| c.with_logs(Prebuilt(custom.clone()))) - .launch() - .await - .expect("launch through the overridden logs seam"); - - assert!( - Arc::ptr_eq(&handle.logs().router(), &custom.router()), - "run reads the overridden pipeline", - ); - - handle.shutdown(); - handle.wait().await.expect("clean shutdown"); - } - - /// Every module failing `init` aborts launch instead of idling. + /// 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 = workspace_root().join("target/wasm32-wasip2/release/price_alert.wasm"); + 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`", @@ -1010,7 +861,9 @@ every_n_blocks = "1" assert!(err.to_string().contains("failed initialisation"), "{err}"); } - /// Add-ons install before the supervisor boots, exactly once. + /// 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); @@ -1066,11 +919,20 @@ every_n_blocks = "1" ); } - /// Full builder-path launch against the example module; skips when the - /// fixture is not built (`just build-module`). + /// 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 = workspace_root().join("target/wasm32-wasip2/release/example.wasm"); + 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", @@ -1078,7 +940,11 @@ every_n_blocks = "1" ); return; } - let manifest = workspace_root().join("nexum/modules/example/module.toml"); + 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(); @@ -1129,8 +995,8 @@ every_n_blocks = "1" .expect("clean completion resolves Ok"); } - /// Firing the shutdown trigger drives the loop to completion and `wait` - /// returns. + /// 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(); @@ -1143,7 +1009,9 @@ every_n_blocks = "1" handle.wait().await.expect("wait returns after the trigger"); } - /// An abnormally-stopped event loop surfaces an error from `wait`. + /// 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(); @@ -1159,7 +1027,8 @@ every_n_blocks = "1" assert!(err.to_string().contains("terminated abnormally"), "{err}"); } - /// Dropping the handle without `wait` still drains the event loop. + /// 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(); diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index 956f8f7..a9ac368 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -1,9 +1,20 @@ -//! Engine-side runtime configuration (`engine.toml`): chain RPC -//! endpoints, local-store location, and per-module resource limits. -//! Distinct from a module's `module.toml` manifest. +//! Engine-side runtime configuration. //! -//! Load order: `--engine-config` path, else `engine.toml` in the cwd, -//! else defaults (no chains, `state_dir = ./data`). +//! 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}; @@ -26,28 +37,14 @@ pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); /// Default cap on receipts under status watch at once. pub const DEFAULT_WATCH_MAX_ENTRIES: usize = 1024; -/// Default base window a healthy provider refreshes within; the give-up -/// deadline is the derived `grace`, not this directly. +/// Default lifetime of one status watch before it is evicted unreported. pub const DEFAULT_WATCH_EXPIRY: Duration = Duration::from_secs(86_400); -/// Derived grace defaults to this many `expiry` windows. -pub const WATCH_GRACE_MULTIPLIER: u64 = 2; -/// Ceiling on the derived grace window. -pub const WATCH_GRACE_MAX: Duration = Duration::from_secs(86_400); - -/// Give-up window derived from `expiry`: `min(MULTIPLIER * expiry, MAX)`. -const fn derive_grace(expiry: Duration) -> Duration { - let scaled = expiry.as_secs().saturating_mul(WATCH_GRACE_MULTIPLIER); - let capped = if scaled < WATCH_GRACE_MAX.as_secs() { - scaled - } else { - WATCH_GRACE_MAX.as_secs() - }; - Duration::from_secs(capped) -} -/// Per-caller submission quota toward providers. A submission and a -/// charged decode failure each consume one unit; the window slides. -/// Resolved from `[limits.quota]`. +/// 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`. @@ -57,7 +54,7 @@ pub struct SubmitQuota { } impl SubmitQuota { - /// Budget paired with the window it is counted over. + /// Pair a budget with the window it is counted over. pub const fn new(max_charges: u32, window: Duration) -> Self { Self { max_charges, @@ -72,33 +69,24 @@ impl Default for SubmitQuota { } } -/// Bounds on a provider status-watch set: `max_entries` caps the -/// per-cadence poll fan-out, `grace` is the give-up deadline, `expiry` -/// the base window it derives from. Resolved from `[limits.watch]`. +/// 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, - /// Base window a healthy provider refreshes the deadline within. + /// How long a watch survives without a successful poll before it is + /// evicted unreported. pub expiry: Duration, - /// Give-up deadline: how long a watch survives an unreachable provider - /// before unreported eviction. A reachable poll resets it; a resolve - /// failure or errored poll rides out against it. Derived unless set. - pub grace: Duration, } impl WatchLimit { - /// Pair a cap with the base expiry; `grace` derives from `expiry`. + /// Pair a cap with the per-entry expiry. pub const fn new(max_entries: usize, expiry: Duration) -> Self { - Self::with_grace(max_entries, expiry, derive_grace(expiry)) - } - - /// As [`new`](Self::new) but with an explicit `grace`. - pub const fn with_grace(max_entries: usize, expiry: Duration, grace: Duration) -> Self { Self { max_entries, expiry, - grace, } } } @@ -110,6 +98,15 @@ impl Default for WatchLimit { } /// 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] @@ -130,30 +127,50 @@ pub enum EngineConfigError { pub struct EngineConfig { #[serde(default)] pub engine: EngineSection, - /// Per-module wasmtime resource limits, applied uniformly. + /// 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 config keyed by EIP-155 chain id. Numeric - /// (`[chains.11155111]`) and named (`[chains.sepolia]`) keys both - /// parse via `Chain`'s `FromStr`. + /// 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 at the composition root. + /// Opaque `[extensions.]` tables. The engine never + /// interprets these; each extension parses its own table at the + /// composition root. #[serde(default)] pub extensions: HashMap, - /// Modules the supervisor boots; each resolves a - /// `(component.wasm, module.toml)` pair. + /// `[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 boots alongside modules. Like a - /// module, but the operator, not the author, scopes its transport here. + /// 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. `manifest` defaults to a sibling -/// `module.toml`. +/// 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. @@ -163,10 +180,16 @@ pub struct ModuleEntry { pub manifest: Option, } -/// One `[[adapters]]` table. `path`/`manifest` mirror [`ModuleEntry`]. -/// `http_allow` and `messaging_topics` are the operator's transport -/// grant: an empty `http_allow` denies all outbound HTTP, an empty -/// `messaging_topics` leaves messaging unscoped. +/// 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. @@ -174,7 +197,9 @@ pub struct AdapterEntry { /// Path to the adapter's `module.toml`. Defaults to `/module.toml`. #[serde(default)] pub manifest: Option, - /// Outbound HTTP host allowlist: exact hostname or `*.suffix` wildcard. + /// 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. @@ -186,16 +211,18 @@ pub struct AdapterEntry { pub struct EngineSection { #[serde(default = "default_state_dir")] pub state_dir: PathBuf, - /// `EnvFilter` directive; defaults to `info`, `RUST_LOG` overrides at - /// process start. + /// `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 exporter wiring. Absent = disabled (the recorder is still - /// installed so call sites stay live, but no HTTP listener binds). + /// 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, - /// Per-block `eth_getLogs` concurrency during chain-log backfill. `0` is - /// treated as `1`. + /// 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, } @@ -215,8 +242,11 @@ fn default_log_backfill_concurrency() -> usize { 16 } -/// `[engine.metrics]`. When `enabled`, serves `/metrics` on `bind_addr` -/// via a Prometheus HTTP exporter. Default disabled. +/// `[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)] @@ -241,12 +271,14 @@ fn default_metrics_bind() -> String { #[derive(Debug, Deserialize)] pub struct ChainConfig { - /// JSON-RPC endpoint. `ws(s)://` engages pubsub (needed for - /// `eth_subscribe`); `http(s)://` is request/response only. + /// 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` calls, seconds. Excludes - /// `eth_subscribe` streams and the log poller. Default 30. `0` is - /// rejected at boot. + /// 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, } @@ -255,10 +287,39 @@ fn default_chain_request_timeout_secs() -> u64 { 30 } -/// Default fuel budget per `on_event` invocation (~1e9 WASM instructions). +/// 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. +/// 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. @@ -270,45 +331,100 @@ 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. +/// 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. +/// 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. +/// 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). +/// 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). +/// 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). +/// 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 past runs retained per module (16). +/// 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 provider status polling cadence (5 s). +/// 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 a millisecond knob into [1 ms, 24 h]. +/// 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. Sections are documented on their -/// own types. +/// 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. @@ -317,7 +433,8 @@ pub struct ModuleLimits { pub event_deadline_secs: Option, /// Linear-memory cap in bytes per module store. pub memory_bytes: Option, - /// Local-store on-disk byte quota per module. + /// Local-store on-disk byte quota (prefix + key + value + per-entry + /// overhead) per module. pub state_bytes: Option, /// Outbound wasi:http limits. #[serde(default)] @@ -356,7 +473,10 @@ impl ModuleLimits { self.memory_bytes.unwrap_or(DEFAULT_MEMORY_LIMIT) } - /// Resolved chain response size cap; a degenerate `0` saturates to 1 byte. + /// 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 @@ -369,14 +489,15 @@ impl ModuleLimits { self.state_bytes.unwrap_or(DEFAULT_STATE_BYTES) } - /// Resolved per-dispatch deadline; an override saturates up to a 1 s floor. + /// 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. + /// Resolved outbound HTTP limits (overrides or defaults). pub fn http(&self) -> OutboundHttpLimits { OutboundHttpLimits { connect_timeout_max: self @@ -406,7 +527,9 @@ impl ModuleLimits { } } - /// Resolved log retention limits; degenerate zeroes saturate up to 1. + /// 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 @@ -422,7 +545,10 @@ impl ModuleLimits { } } - /// Resolved poison-pill thresholds; degenerate zeroes saturate up to 1. + /// 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 @@ -451,7 +577,9 @@ impl ModuleLimits { ) } - /// Resolved status-poll cadence; a zero interval saturates up to 1 ms. + /// 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 @@ -459,8 +587,10 @@ impl ModuleLimits { .unwrap_or(DEFAULT_STATUS_POLL_INTERVAL) } - /// Resolved per-caller submission quota; a zero `max_charges` is - /// saturated up to 1 by the consuming service. + /// 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), @@ -471,33 +601,32 @@ impl ModuleLimits { ) } - /// Resolved status-watch bounds; zero `max_entries`/`expiry_secs` - /// saturate up to a usable minimum. `grace_secs` overrides the give-up - /// deadline, else it derives from `expiry` via [`WatchLimit::new`]. + /// 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 { - let max_entries = self - .watch - .max_entries - .map(|n| n.max(1)) - .unwrap_or(DEFAULT_WATCH_MAX_ENTRIES); - let expiry = self - .watch - .expiry_secs - .map(|s| Duration::from_secs(s.max(1))) - .unwrap_or(DEFAULT_WATCH_EXPIRY); - match self.watch.grace_secs { - Some(secs) => { - WatchLimit::with_grace(max_entries, expiry, Duration::from_secs(secs.max(1))) - } - None => WatchLimit::new(max_entries, expiry), - } + 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 limits. All optional; millisecond values -/// saturate into [1 ms, 24 h]. The `*_timeout_max_ms` fields are ceilings -/// on the matching guest-settable `request-options` timeouts: a higher -/// guest value is clamped down, an unset one inherits the ceiling. +/// `[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. @@ -513,15 +642,22 @@ pub struct HttpLimitsSection { pub response_body_max_bytes: Option, } -/// `[limits.chain]` chain JSON-RPC response size limit. Optional; defaults -/// to 1 MiB. +/// `[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. + /// 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. +/// 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. @@ -536,8 +672,14 @@ pub struct OutboundHttpLimits { pub response_body_max_bytes: u64, } -/// `[limits.logs]` per-run retention knobs. Both optional; degenerate -/// zeroes saturate up to 1. +/// `[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. @@ -546,10 +688,14 @@ pub struct LogLimitsSection { pub runs_retained: Option, } -/// `[limits.poison]` quarantine thresholds. Both optional; degenerate -/// zeroes saturate up to 1. A module reaching `max_failures` traps within -/// a sliding `window_secs` is quarantined and no longer dispatched until -/// an operator-driven engine restart. +/// `[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. @@ -558,9 +704,13 @@ pub struct PoisonLimitsSection { pub window_secs: Option, } -/// `[limits.quota]` per-caller submission budget. Both optional. A caller -/// (keyed by its namespace) may accrue at most `max_charges` within a -/// sliding `window_secs`; a charged decode failure counts the same. +/// `[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 @@ -570,28 +720,33 @@ pub struct QuotaLimitsSection { pub window_secs: Option, } -/// `[limits.status_poll]` provider status polling cadence: how often the -/// consuming service polls each provider's `status` export for the -/// receipts it watches. Optional; a zero saturates up to 1 ms. +/// `[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. All optional; degenerate -/// zeroes saturate up to a usable minimum. The cap bounds the per-cadence -/// poll fan-out; at the cap a new watch is refused and logged, live -/// watches are never dropped. +/// `[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, - /// Base window seconds a healthy venue refreshes the deadline within. + /// Seconds one watch stays live before it is evicted unreported. pub expiry_secs: Option, - /// Give-up deadline seconds: how long a watch rides out an unreachable - /// venue before eviction. Omitted, it derives from `expiry_secs`. - pub grace_secs: Option, } /// `[limits.dispatch]` per-module dispatch rate-limit knobs. Both @@ -605,11 +760,12 @@ pub struct DispatchLimitsSection { pub refill_per_sec: Option, } -/// Resolved log retention limits the in-memory store enforces. +/// 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 newest record is never evicted - /// to nothing. + /// 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, @@ -658,9 +814,18 @@ pub fn load_or_default(path: Option<&Path>) -> Result Result { let mut out = String::with_capacity(raw.len()); let bytes = raw.as_bytes(); @@ -712,7 +877,10 @@ fn is_valid_env_name(s: &str) -> bool { chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') } -/// Errors from `${VAR}` substitution in `engine.toml`. +/// `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] @@ -734,9 +902,11 @@ pub enum EnvVarError { Unclosed { offset: usize }, } -/// Blank the credential-bearing parts of a URL (userinfo, query, fragment, -/// long API-key path segments) so it is safe to log. Unparseable input -/// yields a placeholder. +/// 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(); @@ -805,6 +975,29 @@ 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 @@ -1096,19 +1289,6 @@ expiry_secs = 900 let watch = cfg.limits.watch(); assert_eq!(watch.max_entries, 32); assert_eq!(watch.expiry, Duration::from_secs(900)); - // Omitted grace_secs derives from expiry (min(2 * expiry, 24h)). - assert_eq!(watch.grace, Duration::from_secs(1800)); - - // An explicit grace_secs overrides the derivation. - let cfg: EngineConfig = toml::from_str( - r#" -[limits.watch] -expiry_secs = 900 -grace_secs = 120 -"#, - ) - .expect("limits.watch parses"); - assert_eq!(cfg.limits.watch().grace, Duration::from_secs(120)); } #[test] @@ -1236,6 +1416,7 @@ key = "value" assert_eq!(redact_url("not a url"), ""); } + // ----------------- env var substitution ----------------------- // // These tests stash + restore process env vars under unique names // so parallel `cargo test` runs don't trip on each other. diff --git a/crates/nexum-runtime/src/host/actor.rs b/crates/nexum-runtime/src/host/actor.rs index 62cb956..c10f8e0 100644 --- a/crates/nexum-runtime/src/host/actor.rs +++ b/crates/nexum-runtime/src/host/actor.rs @@ -1,7 +1,8 @@ -//! Supervised host-actor primitive: one component instance the host holds and -//! others call. The store is refuelled before each guest call, traps project -//! onto a typed fault, and an [`ActorSlot`] mutex serialises calls so one -//! store never runs two at once. +//! 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; @@ -12,13 +13,14 @@ use wasmtime::Store; use super::component::RuntimeTypes; use super::state::HostState; -/// One supervised actor behind its serialising mutex; concurrent callers -/// queue. +/// 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; a trap marks it dead and -/// records when, for backoff from the death instant. Clone shares the flag, -/// starts alive. +/// 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>>); @@ -33,7 +35,8 @@ impl Liveness { *self.lock() } - /// Mark dead, keeping the first death instant if already dead. + /// 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() { @@ -46,7 +49,8 @@ impl Liveness { *self.lock() = None; } - /// The flag, recovered from a poisoned lock. + /// 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() @@ -61,13 +65,15 @@ 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 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, with traps -/// projected onto [`ActorFault`] and recorded on [`Liveness`]. +/// 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, @@ -85,8 +91,8 @@ impl SupervisedStore { } } - /// Refuel, then run one guest call; a trap marks liveness dead until - /// reinstantiated. + /// 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, diff --git a/crates/nexum-runtime/src/host/component/builder.rs b/crates/nexum-runtime/src/host/component/builder.rs index 02222c9..84b4f32 100644 --- a/crates/nexum-runtime/src/host/component/builder.rs +++ b/crates/nexum-runtime/src/host/component/builder.rs @@ -1,6 +1,11 @@ -//! Per-component builders. Each core backend is a [`ComponentBuilder`]; -//! [`ComponentsBuilder`] assembles the core seams, the lattice `Ext` payload, -//! and the log pipeline into a [`Components`] bundle. +//! 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; @@ -11,8 +16,11 @@ 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. +/// 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, @@ -22,7 +30,9 @@ pub struct BuilderContext<'a> { pub executor: &'a TaskExecutor, } -/// Builds one runtime backend from the shared [`BuilderContext`]. +/// 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; @@ -73,6 +83,18 @@ impl ComponentBuilder for LocalStoreBuilder { } } +/// 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; @@ -85,7 +107,9 @@ impl ComponentBuilder for LogPipelineBuilder { } } -/// Names the component slot whose build failed. +/// 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 { @@ -101,6 +125,9 @@ pub enum BuildError { /// 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 @@ -113,9 +140,12 @@ impl ComponentBuilder for () { } } -/// Assembles the core, `Ext`, and log-pipeline builders into a [`Components`] -/// bundle; the logs slot defaults to [`LogPipelineBuilder`]. -pub struct ComponentsBuilder { +/// Assembles the core backend builders, the lattice `Ext` builder, and the +/// log pipeline builder into a [`Components`] bundle. The logs slot defaults +/// to [`LogPipelineBuilder`] 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`]). @@ -124,33 +154,53 @@ pub struct ComponentsBuilder { 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. + /// 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 { +impl ComponentsBuilder { /// Replace the log pipeline builder. - pub fn with_logs(self, logs: L2) -> ComponentsBuilder { + 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; a failing - /// sub-build returns the [`BuildError`] naming that slot. + /// Drive each builder against `ctx` and bundle the backends. The + /// builder outputs must match the lattice seams: chain to + /// [`RuntimeTypes::Chain`], store to [`RuntimeTypes::Store`], ext to + /// [`RuntimeTypes::Ext`]; logs always yields a [`LogPipeline`] 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, @@ -158,16 +208,19 @@ impl ComponentsBuilder { 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, }) } } @@ -178,7 +231,11 @@ mod tests { use crate::engine_config::EngineConfig; use crate::preset::CoreRuntime; - /// Opens the core backends end-to-end against a fresh data directory. + /// 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"); diff --git a/crates/nexum-runtime/src/host/component/chain.rs b/crates/nexum-runtime/src/host/component/chain.rs index 490736d..687abf3 100644 --- a/crates/nexum-runtime/src/host/component/chain.rs +++ b/crates/nexum-runtime/src/host/component/chain.rs @@ -5,13 +5,71 @@ 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}; -/// Permitted read surface, re-exported from `nexum-world`. -pub use nexum_world::ChainMethod; +/// 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`]. +/// 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( @@ -19,7 +77,8 @@ pub trait ChainProvider { chain: Chain, ) -> impl Future> + Send; - /// Current head block number (`eth_blockNumber`). + /// Current head block number (`eth_blockNumber`), used as the + /// canonical log poller's start block. fn block_number(&self, chain: Chain) -> impl Future> + Send; @@ -32,7 +91,8 @@ pub trait ChainProvider { start_block: u64, ) -> Result; - /// Raw JSON-RPC dispatch; `params_json` is the JSON params array. + /// Raw JSON-RPC dispatch. `method` is a permitted read-surface + /// method; `params_json` is the JSON params array. fn request( &self, chain: Chain, @@ -74,3 +134,51 @@ impl ChainProvider for ProviderPool { ProviderPool::request(self, chain, method, params_json) } } + +#[cfg(test)] +mod tests { + use super::ChainMethod; + + #[test] + fn read_surface_methods_parse() { + for m in [ + "eth_call", + "eth_blockNumber", + "eth_getBalance", + "eth_getLogs", + "eth_getTransactionReceipt", + "net_version", + ] { + assert!(ChainMethod::try_from(m).is_ok(), "{m} should parse"); + } + } + + #[test] + fn signing_and_mutating_methods_have_no_variant() { + for m in [ + "eth_sign", + "eth_signTransaction", + "eth_sendTransaction", + "eth_sendRawTransaction", + "eth_accounts", + "personal_sign", + "personal_unlockAccount", + "admin_peers", + "debug_traceCall", + "miner_start", + "eth_notAMethod", + "", + ] { + assert!(ChainMethod::try_from(m).is_err(), "{m} must be rejected"); + } + } + + #[test] + fn as_str_round_trips_the_wire_name() { + assert_eq!(ChainMethod::EthCall.as_str(), "eth_call"); + assert_eq!( + ChainMethod::try_from(ChainMethod::EthGetBalance.as_str()).unwrap(), + ChainMethod::EthGetBalance, + ); + } +} diff --git a/crates/nexum-runtime/src/host/component/mod.rs b/crates/nexum-runtime/src/host/component/mod.rs index 8005450..013b208 100644 --- a/crates/nexum-runtime/src/host/component/mod.rs +++ b/crates/nexum-runtime/src/host/component/mod.rs @@ -1,6 +1,8 @@ -//! Backend component traits: the seam between the WIT host impls and the -//! concrete capability backends, tied together by the [`RuntimeTypes`] -//! lattice. +//! 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; @@ -9,21 +11,26 @@ mod state; pub use builder::{ BuildError, BuilderContext, ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, - LogPipelineBuilder, ProviderPoolBuilder, + LogPipelineBuilder, ProviderPoolBuilder, RemoteStoreBuilder, }; pub use chain::{ChainMethod, ChainProvider}; pub use runtime_types::{Handle, RuntimeTypes}; pub use state::{StateHandle, StateStore}; -/// Owned bundle of shared backends threaded into every module store; cheap to -/// clone. +/// 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). + /// Extension backends (the lattice `Ext` payload), threaded into + /// `HostState.ext` and reached by extensions through `ExtState`. pub ext: T::Ext, - /// Shared log pipeline. + /// 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 { @@ -33,6 +40,7 @@ impl Clone for Components { store: self.store.clone(), ext: self.ext.clone(), logs: self.logs.clone(), + remote: self.remote.clone(), } } } @@ -43,7 +51,8 @@ mod tests { use crate::host::local_store_redb::{LocalStore, ModuleStore}; use crate::host::provider_pool::ProviderPool; - /// Core-only lattice (no extension payload). + /// 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; diff --git a/crates/nexum-runtime/src/host/component/runtime_types.rs b/crates/nexum-runtime/src/host/component/runtime_types.rs index 70af46a..3374149 100644 --- a/crates/nexum-runtime/src/host/component/runtime_types.rs +++ b/crates/nexum-runtime/src/host/component/runtime_types.rs @@ -1,17 +1,29 @@ -//! The RuntimeTypes lattice: one trait naming the core backend seams plus the -//! pluggable [`RuntimeTypes::Ext`] slot, so every generic signature takes one +//! 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}; -/// Core backend seams a runtime assembly provides, plus the extension slot -/// ([`Ext`](RuntimeTypes::Ext)). Sealed. +/// 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; `()` for an assembly with no extensions. + /// 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; } diff --git a/crates/nexum-runtime/src/host/component/state.rs b/crates/nexum-runtime/src/host/component/state.rs index 587f0bc..635ca0b 100644 --- a/crates/nexum-runtime/src/host/component/state.rs +++ b/crates/nexum-runtime/src/host/component/state.rs @@ -5,7 +5,7 @@ // local_store_redb.rs. #![allow(clippy::result_large_err)] -use crate::host::local_store_redb::{LocalStore, ModuleStore, StorageError, WriteOp}; +use crate::host::local_store_redb::{LocalStore, ModuleStore, StorageError}; /// Process-wide state store that vends per-module handles. pub trait StateStore { @@ -29,20 +29,21 @@ pub trait StateHandle { fn delete(&self, key: &str) -> Result<(), StorageError>; /// Enumerate module-visible keys starting with `prefix`. fn list_keys(&self, prefix: &str) -> Result, StorageError>; - /// Whether `key` exists. + /// 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. + /// 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`. + /// 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) } - /// Apply `ops` as one atomic batch; caps op count and total value bytes. - fn apply(&self, ops: &[WriteOp]) -> Result<(), StorageError>; } impl StateStore for LocalStore { @@ -85,8 +86,4 @@ impl StateHandle for ModuleStore { fn count(&self, prefix: &str) -> Result { ModuleStore::count(self, prefix) } - - fn apply(&self, ops: &[WriteOp]) -> Result<(), StorageError> { - ModuleStore::apply(self, ops) - } } diff --git a/crates/nexum-runtime/src/host/error.rs b/crates/nexum-runtime/src/host/error.rs index 22951a7..9ebd3c8 100644 --- a/crates/nexum-runtime/src/host/error.rs +++ b/crates/nexum-runtime/src/host/error.rs @@ -1,33 +1,42 @@ -//! Constructors and `From` conversions building the WIT error shapes -//! (`chain-error`, `Fault`); `fault_label` / `fault_message` project a -//! `Fault` into metric and log fields. +//! 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. +/// `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`], for metric and log `kind` fields. +/// 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::FaultLabel as Label; + use nexum_world::fault_labels as labels; match fault { - Fault::Unsupported(_) => Label::Unsupported, - Fault::Unavailable(_) => Label::Unavailable, - Fault::Denied(_) => Label::Denied, - Fault::RateLimited(_) => Label::RateLimited, - Fault::Timeout => Label::Timeout, - Fault::InvalidInput(_) => Label::InvalidInput, - Fault::Internal(_) => Label::Internal, + 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, } - .into() } -/// Human-readable detail carried by a [`Fault`], for the log `message` field. +/// 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) @@ -43,9 +52,14 @@ pub fn fault_message(fault: &Fault) -> std::borrow::Cow<'_, str> { } } -/// Project a [`ProviderError`] into `chain-error`: a structured JSON-RPC -/// `ErrorResp` becomes [`ChainError::Rpc`] with its code and revert bytes, -/// everything else a shared [`Fault`]. +/// 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 { @@ -91,8 +105,10 @@ impl From for ChainError { } } -/// Classify a transport RPC failure: 429 to `rate-limited`, 503 or a dropped -/// backend to `unavailable`, a timeout to `timeout`, else `unavailable`. +/// 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() { @@ -120,16 +136,41 @@ fn transport_fault(source: &alloy_transport::TransportError) -> Fault { } } -/// Project a [`StorageError`]: quota breach to `denied`, a per-batch cap to -/// `invalid-input`, else `internal`. +/// 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()), - StorageError::ApplyOpsExceeded { .. } | StorageError::ApplyBytesExceeded { .. } => { - Fault::InvalidInput(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/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index 06761a0..9d097fb 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -1,6 +1,9 @@ -//! Extension seam: what one extension contributes to the host (namespace, -//! capabilities, linker hook, optional service, provider kind, event sources, -//! and manifest-section install predicates). +//! 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}; @@ -20,20 +23,25 @@ use crate::host::component::RuntimeTypes; use crate::host::state::HostState; use crate::manifest::{ExtensionSections, NamespaceCaps}; -/// One runtime extension; a module importing its interface boots only if both -/// the linker entry and the capability namespace are registered. +/// 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. + /// Capability namespace merged into enforcement so a module importing + /// the extension's interfaces still validates. fn capabilities(&self) -> NamespaceCaps; - /// Add the extension's imports to a worker linker, after core interfaces - /// and before instantiation. + /// 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. + /// Host service this extension owns, published under its namespace on + /// [`HostServices`]. fn service(&self) -> Option> { None } @@ -43,21 +51,22 @@ pub trait Extension: Send + Sync + 'static { None } - /// Manifest section names this extension claims; an unclaimed non-core - /// section is refused at boot. + /// 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 manifest sections; `Err` - /// refuses fail-fast. + /// 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 and the loaded providers' - /// sections; `Err` refuses fail-fast. + /// 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, @@ -68,22 +77,24 @@ pub trait Extension: Send + Sync + 'static { Ok(()) } - /// Subscription kinds this extension's event sources emit; an unknown - /// non-core kind is refused at boot. + /// 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 after boot; the event loop merges - /// and dispatches them. + /// 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()) } } -/// Event dispatched to every module with a `[[subscription]]` of `kind` whose -/// filters match `attrs`. +/// 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, @@ -96,7 +107,9 @@ pub struct ExtensionEvent { /// A stream of extension events the event loop merges and drives. pub type ExtensionEventStream = Pin + Send>>; -/// Launch inputs for [`Extension::events`]. +/// 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, @@ -126,8 +139,9 @@ impl<'a> EventSources<'a> { } } - /// Spawn an event-source task; it must end when its stream's receiver - /// drops. + /// 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; @@ -136,11 +150,14 @@ impl<'a> EventSources<'a> { } } -/// Type-erased host service an extension owns, downcast at the call site. +/// 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. +/// 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. @@ -149,8 +166,9 @@ pub trait ProviderKind: Send + Sync + 'static { /// Adds the provider's imports to a provider linker. fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; - /// Instantiate and install one provider; [`Installed::Dead`] is a failed - /// guest `init`, `Err` a boot error. + /// 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>, @@ -158,7 +176,10 @@ pub trait ProviderKind: Send + Sync + 'static { ) -> anyhow::Result; } -/// One provider instance ready to install. +/// 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, @@ -168,18 +189,22 @@ pub struct ProviderInstance<'a, T: RuntimeTypes> { pub store: Store>, /// Manifest `[config]` handed to the guest `init`. pub config: Vec<(String, String)>, - /// The provider's extension-owned manifest sections. + /// 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 instance reports traps on. + /// 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. +/// 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). + /// The provider's namespace: its manifest name. pub name: String, /// Registered kind spelling. pub kind: &'static str, @@ -203,7 +228,9 @@ pub fn downcast_service(service: &Arc) -> Optio erased.downcast().ok() } -/// Immutable per-namespace service map, built once at boot. +/// 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>>); @@ -214,8 +241,8 @@ impl std::fmt::Debug for HostServices { } impl HostServices { - /// Collect each extension's service under its namespace; refuses a - /// duplicate. + /// Collect each extension's service under its namespace. Refuses a + /// duplicate namespace. pub fn from_extensions( extensions: &[Arc>], ) -> anyhow::Result { @@ -232,8 +259,8 @@ impl HostServices { Ok(Self(Arc::new(map))) } - /// Service under `namespace` downcast to `S`; `None` if absent or - /// mismatched. + /// 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)?) } @@ -243,7 +270,8 @@ impl HostServices { self.0.get(namespace) } - /// Publish `service` under `namespace`, refusing a duplicate. + /// 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, diff --git a/crates/nexum-runtime/src/host/http.rs b/crates/nexum-runtime/src/host/http.rs index 57e0059..2624457 100644 --- a/crates/nexum-runtime/src/host/http.rs +++ b/crates/nexum-runtime/src/host/http.rs @@ -1,7 +1,10 @@ -//! wasi:http outgoing gate. [`HttpGate::send_request`] enforces the -//! per-module `[capabilities.http].allow` list, clamps guest timeouts to the -//! `[limits.http]` maxima, and bounds the exchange with a total deadline and -//! response-body cap. Redirects are not followed; each hop re-enters the gate. +//! 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; @@ -23,7 +26,8 @@ use super::state::HostState; use crate::engine_config::OutboundHttpLimits; use crate::manifest::host_allowed; -/// Per-module outbound HTTP policy. +/// 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, @@ -31,7 +35,8 @@ pub struct HttpGate { } impl HttpGate { - /// Gate for `module` with its allowlist and outbound limits. + /// 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, @@ -69,8 +74,11 @@ impl WasiHttpHooks for HttpGate { } } -/// Clamp guest timeouts to the engine maxima, lowering never rejecting; each -/// maximum doubles as the effective default for an unset timeout. +/// 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); @@ -80,10 +88,15 @@ fn clamp(mut config: OutgoingRequestConfig, limits: &OutboundHttpLimits) -> Outg config } -/// Dispatch through the default backend under the total deadline and body -/// cap. The deadline is unconditional: it covers headers and, via -/// [`CappedBody`], the body, and the raced connection driver is aborted when -/// it fires even if the guest never reads the response. +/// 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, @@ -119,9 +132,12 @@ fn send_with_limits( HostFutureIncomingResponse::pending(handle) } -/// Response-body wrapper enforcing the size cap and total deadline. Over-cap -/// yields `HttpResponseBodySize(cap)`; the deadline firing yields -/// `ConnectionReadTimeout`. +/// 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. @@ -181,10 +197,18 @@ impl Body for CappedBody { } } -/// Allowlist decision for one request URI. Host-only, case-insensitive, exact -/// or `*.suffix` per [`host_allowed`]; IPv6 literals stay bracketed. -/// Name-based and pre-resolution, so there is no IP pinning or DNS-rebinding -/// defence. +/// 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); @@ -339,6 +363,7 @@ mod tests { )); } + // ----------------- 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 @@ -520,6 +545,8 @@ mod tests { ); } + // ----------------- timeout clamping ---------------------------- + fn config_with(timeout: Duration) -> OutgoingRequestConfig { OutgoingRequestConfig { use_tls: false, @@ -566,13 +593,17 @@ mod tests { assert_eq!(clamped.between_bytes_timeout, Duration::from_secs(5)); } + // ----------------- deadline + body cap ------------------------- + /// A detached executor for test-server tasks. fn test_executor() -> nexum_tasks::TaskExecutor { nexum_tasks::TaskManager::new().executor() } - /// One-connection loopback server; `hold_open` stalls instead of sending - /// EOF. + /// 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 diff --git a/crates/nexum-runtime/src/host/impls/chain.rs b/crates/nexum-runtime/src/host/impls/chain.rs index 59aa813..f9afad2 100644 --- a/crates/nexum-runtime/src/host/impls/chain.rs +++ b/crates/nexum-runtime/src/host/impls/chain.rs @@ -1,4 +1,4 @@ -//! `nexum:host/chain`: raw JSON-RPC dispatch. +//! `nexum:host/chain`: raw JSON-RPC dispatch over alloy. use std::time::Instant; @@ -10,8 +10,12 @@ 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; an unknown -/// or mutating method is a `Denied` fault. +/// 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!( @@ -20,8 +24,9 @@ fn resolve_method(method: &str) -> Result { }) } -/// Error if `body` exceeds `cap` bytes, checked before the copy into the -/// guest. +/// 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, @@ -103,8 +108,16 @@ impl nexum::host::chain::Host for HostState { result } - /// Dispatch a batch, one `RpcResult` per entry in order. Per-entry - /// failures are independent; the outer `ChainError` is never returned. + /// 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, @@ -169,7 +182,10 @@ mod tests { use crate::host::provider_pool::ProviderError; use alloy_transport::TransportErrorKind; - /// Build a synthetic transport-level [`TransportError`]. + /// 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) } diff --git a/crates/nexum-runtime/src/host/impls/identity.rs b/crates/nexum-runtime/src/host/impls/identity.rs index 392ed55..bbea4d4 100644 --- a/crates/nexum-runtime/src/host/impls/identity.rs +++ b/crates/nexum-runtime/src/host/impls/identity.rs @@ -1,6 +1,6 @@ -//! `nexum:host/identity`: unimplemented stub. `accounts()` returns an -//! empty roster so guests can probe-then-skip; signing returns -//! `unsupported`. +//! `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; diff --git a/crates/nexum-runtime/src/host/impls/local_store.rs b/crates/nexum-runtime/src/host/impls/local_store.rs index e78bdbb..e5abc7c 100644 --- a/crates/nexum-runtime/src/host/impls/local_store.rs +++ b/crates/nexum-runtime/src/host/impls/local_store.rs @@ -1,10 +1,8 @@ //! `nexum:host/local-store`: redb backend with host-side namespacing. use crate::bindings::nexum; -use crate::bindings::nexum::host::local_store::{KeyValue, WriteOp}; use crate::bindings::nexum::host::types::Fault; use crate::host::component::{RuntimeTypes, StateHandle}; -use crate::host::local_store_redb; use crate::host::state::HostState; impl nexum::host::local_store::Host for HostState { @@ -35,17 +33,4 @@ impl nexum::host::local_store::Host for HostState { async fn count(&mut self, prefix: String) -> Result { self.store.count(&prefix).map_err(Fault::from) } - - async fn apply(&mut self, ops: Vec) -> Result<(), Fault> { - let ops: Vec = ops - .into_iter() - .map(|op| match op { - WriteOp::Set(KeyValue { key, value }) => { - local_store_redb::WriteOp::Set { key, value } - } - WriteOp::Delete(key) => local_store_redb::WriteOp::Delete { key }, - }) - .collect(); - self.store.apply(&ops).map_err(Fault::from) - } } diff --git a/crates/nexum-runtime/src/host/impls/logging.rs b/crates/nexum-runtime/src/host/impls/logging.rs index 1219701..37be16c 100644 --- a/crates/nexum-runtime/src/host/impls/logging.rs +++ b/crates/nexum-runtime/src/host/impls/logging.rs @@ -1,5 +1,6 @@ -//! `nexum:host/logging`: builds a [`LogRecord`] from the guest's `log` call -//! and routes it. +//! `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; diff --git a/crates/nexum-runtime/src/host/impls/messaging.rs b/crates/nexum-runtime/src/host/impls/messaging.rs index 3851c71..4cf45da 100644 --- a/crates/nexum-runtime/src/host/impls/messaging.rs +++ b/crates/nexum-runtime/src/host/impls/messaging.rs @@ -1,15 +1,21 @@ -//! `nexum:host/messaging`: stub. `publish` reports `unsupported`, `query` -//! returns empty; the per-store topic scope is still enforced ahead of the -//! stub. +//! `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`. Empty scope admits everything; -/// otherwise a topic matches a scope entry exactly or as a `/`-bounded path -/// prefix, so a grant never leaks into a longer sibling segment. +/// 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; diff --git a/crates/nexum-runtime/src/host/impls/mod.rs b/crates/nexum-runtime/src/host/impls/mod.rs index b86cf5e..2247ed6 100644 --- a/crates/nexum-runtime/src/host/impls/mod.rs +++ b/crates/nexum-runtime/src/host/impls/mod.rs @@ -1,5 +1,10 @@ -//! `Host` trait impls for [`crate::host::state::HostState`], one file per WIT -//! interface: dispatch glue to the backends in [`crate::host`]. +//! `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; diff --git a/crates/nexum-runtime/src/host/impls/remote_store.rs b/crates/nexum-runtime/src/host/impls/remote_store.rs index 1894484..7fb0f56 100644 --- a/crates/nexum-runtime/src/host/impls/remote_store.rs +++ b/crates/nexum-runtime/src/host/impls/remote_store.rs @@ -1,31 +1,34 @@ -//! `nexum:host/remote-store`: unimplemented stub; every call returns the -//! unsupported fault. +//! `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; -const DEFERRED: &str = "Swarm backend deferred to 0.3"; - impl nexum::host::remote_store::Host for HostState { - async fn upload(&mut self, _data: Vec) -> Result, Fault> { - Err(Fault::Unsupported(DEFERRED.into())) + 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> { - Err(Fault::Unsupported(DEFERRED.into())) + 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, + owner: Vec, + topic: Vec, ) -> Result>, Fault> { - Err(Fault::Unsupported(DEFERRED.into())) + self.remote + .read_feed(owner, topic) + .await + .map_err(Fault::from) } - async fn write_feed(&mut self, _topic: Vec, _data: Vec) -> Result, Fault> { - Err(Fault::Unsupported(DEFERRED.into())) + 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/crates/nexum-runtime/src/host/local_store_redb.rs b/crates/nexum-runtime/src/host/local_store_redb.rs index a6c2516..8e2d95e 100644 --- a/crates/nexum-runtime/src/host/local_store_redb.rs +++ b/crates/nexum-runtime/src/host/local_store_redb.rs @@ -1,9 +1,10 @@ -//! `nexum:host/local-store` backend: a single redb file under -//! `EngineConfig.engine.state_dir`. +//! `nexum:host/local-store` backend. //! -//! Every key is prefixed host-side by `keccak256(module_name)`, so modules -//! sharing a key string see disjoint data and cannot forge into another's -//! range. +//! 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)] @@ -19,53 +20,37 @@ const TABLE: TableDefinition<'static, &[u8], &[u8]> = TableDefinition::new("nexu #[cfg(test)] const PREFIX_LEN: usize = 32; -/// Fixed per-entry overhead charged with prefix+key+value so the quota bounds -/// on-disk bytes, not logical payload. +/// 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; -/// Cap on ops per [`ModuleStore::apply`] batch. -pub const MAX_APPLY_OPS: usize = 1024; - -/// Cap on total set-value bytes per [`ModuleStore::apply`] batch. -pub const MAX_APPLY_VALUE_BYTES: u64 = 4 * 1024 * 1024; - -/// One write in a [`ModuleStore::apply`] batch. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum WriteOp { - /// Insert or overwrite `key` with `value`. - Set { - /// Module-visible key. - key: String, - /// Value bytes. - value: Vec, - }, - /// Delete `key`; a missing key is a no-op. - Delete { - /// Module-visible key. - key: String, - }, -} - -/// Process-wide handle to the local-store redb database; cheap to clone. +/// 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, lazily seeded, keeping writes O(1). + /// 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 keccak256 namespace prefix. +/// 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; `None` is unlimited. + /// 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`, initialising the shared table. + /// 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)?; { @@ -79,7 +64,9 @@ impl LocalStore { }) } - /// [`ModuleStore`] for `namespace`; rejects the empty string. + /// 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( @@ -97,14 +84,16 @@ impl LocalStore { } impl ModuleStore { - /// Cap this handle's namespace at `quota_bytes` on-disk; over-cap writes - /// return [`StorageError::QuotaExceeded`]. + /// 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 } - /// Value for `key`, `Ok(None)` when absent. + /// 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)?; @@ -127,7 +116,8 @@ impl ModuleStore { .is_some()) } - /// Value byte length for `key`, `Ok(None)` when absent. + /// 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)?; @@ -138,7 +128,8 @@ impl ModuleStore { .map(|v| v.value().len() as u64)) } - /// Number of module-visible keys starting with `prefix`. + /// 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)?; @@ -157,8 +148,9 @@ impl ModuleStore { Ok(count) } - /// Insert or overwrite; fsync-durable. An over-quota write is rejected - /// untouched. + /// 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)?; @@ -203,103 +195,14 @@ impl ModuleStore { Ok(()) } - /// Apply `ops` atomically, fsync-durable; later ops on a key win. An - /// over-quota batch or one past [`MAX_APPLY_OPS`] / - /// [`MAX_APPLY_VALUE_BYTES`] is rejected before commit. - pub fn apply(&self, ops: &[WriteOp]) -> Result<(), StorageError> { - if ops.len() > MAX_APPLY_OPS { - return Err(StorageError::ApplyOpsExceeded { - ops: ops.len(), - cap: MAX_APPLY_OPS, - }); - } - let value_bytes: u64 = ops - .iter() - .map(|op| match op { - WriteOp::Set { value, .. } => value.len() as u64, - WriteOp::Delete { .. } => 0, - }) - .sum(); - if value_bytes > MAX_APPLY_VALUE_BYTES { - return Err(StorageError::ApplyBytesExceeded { - bytes: value_bytes, - cap: MAX_APPLY_VALUE_BYTES, - }); - } - let txn = self.db.begin_write().map_err(StorageError::Txn)?; - let mut counters = self.counters.lock().unwrap_or_else(|e| e.into_inner()); - let track = self.quota_bytes.is_some() || counters.contains_key(&self.prefix); - let mut projected = 0u64; - { - let mut table = txn.open_table(TABLE).map_err(StorageError::Table)?; - if track { - // Net whole-batch footprint: each touched key's on-disk cost - // is released once and its post-batch cost charged once. - let mut finals: HashMap<&str, Option> = HashMap::new(); - for op in ops { - match op { - WriteOp::Set { key, value } => finals.insert(key, Some(value.len())), - WriteOp::Delete { key } => finals.insert(key, None), - }; - } - let used = match counters.get(&self.prefix) { - Some(&u) => u, - None => self.used_bytes(&table)?, - }; - let mut released = 0u64; - let mut charged = 0u64; - for (key, value_len) in &finals { - let full = self.build_key(key); - released += table - .get(full.as_slice()) - .map_err(StorageError::Storage)? - .map(|v| self.entry_cost(key.len(), v.value().len())) - .unwrap_or(0); - charged += value_len - .map(|len| self.entry_cost(key.len(), len)) - .unwrap_or(0); - } - projected = used.saturating_sub(released) + charged; - if let Some(quota) = self.quota_bytes - && projected > quota - { - // Returning aborts the write transaction: nothing lands. - return Err(StorageError::QuotaExceeded { - needed: projected, - quota, - }); - } - } - for op in ops { - match op { - WriteOp::Set { key, value } => { - let full = self.build_key(key); - table - .insert(full.as_slice(), value.as_slice()) - .map_err(StorageError::Storage)?; - } - WriteOp::Delete { key } => { - let full = self.build_key(key); - table - .remove(full.as_slice()) - .map_err(StorageError::Storage)?; - } - } - } - } - txn.commit().map_err(StorageError::Commit)?; - if track { - counters.insert(self.prefix.clone(), projected); - } - Ok(()) - } - - /// On-disk footprint of one entry: prefix + key + value + overhead. + /// 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 scanning its prefix range once. + /// 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]>, @@ -344,7 +247,9 @@ impl ModuleStore { Ok(()) } - /// Module-visible keys whose post-prefix key starts with `prefix`. + /// 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)?; @@ -401,20 +306,6 @@ pub enum StorageError { /// The module's byte quota. quota: u64, }, - #[error("apply batch has {ops} ops but the cap is {cap}")] - ApplyOpsExceeded { - /// Ops in the rejected batch. - ops: usize, - /// Per-batch op cap. - cap: usize, - }, - #[error("apply batch carries {bytes} value B but the cap is {cap} B")] - ApplyBytesExceeded { - /// Total set-value bytes in the rejected batch. - bytes: u64, - /// Per-batch value-byte cap. - cap: u64, - }, } #[cfg(test)] diff --git a/crates/nexum-runtime/src/host/local_store_redb/tests.rs b/crates/nexum-runtime/src/host/local_store_redb/tests.rs index cbc37c2..3e1fd15 100644 --- a/crates/nexum-runtime/src/host/local_store_redb/tests.rs +++ b/crates/nexum-runtime/src/host/local_store_redb/tests.rs @@ -248,133 +248,9 @@ fn quota_counts_across_short_lived_handles_of_one_namespace() { assert!(matches!(err, StorageError::QuotaExceeded { .. })); } -// Atomic apply batches (#609). - -fn set_op(key: &str, value: &[u8]) -> WriteOp { - WriteOp::Set { - key: key.into(), - value: value.to_vec(), - } -} - -fn delete_op(key: &str) -> WriteOp { - WriteOp::Delete { key: key.into() } -} - -#[test] -fn apply_mixed_batch_commits_atomically() { - let (_dir, store) = fresh(); - let ms = store.module("m").unwrap(); - ms.set("stale", b"old").unwrap(); - ms.set("keep", b"as-is").unwrap(); - ms.apply(&[ - set_op("fresh", b"new"), - set_op("stale", b"overwritten"), - delete_op("keep"), - delete_op("missing"), - ]) - .unwrap(); - assert_eq!(ms.get("fresh").unwrap().as_deref(), Some(&b"new"[..])); - assert_eq!( - ms.get("stale").unwrap().as_deref(), - Some(&b"overwritten"[..]) - ); - assert!(ms.get("keep").unwrap().is_none()); - assert!(ms.get("missing").unwrap().is_none()); -} - -#[test] -fn apply_over_quota_batch_lands_nothing() { - let (_dir, store) = fresh(); - // Room for the seeded entry plus one small one, but not the batch's two. - let quota = cost("seed", b"v") + cost("a", b"1"); - let ms = store.module("m").unwrap().with_quota(quota); - ms.set("seed", b"v").unwrap(); - let err = ms - .apply(&[set_op("a", b"1"), set_op("b", b"2")]) - .unwrap_err(); - match err { - StorageError::QuotaExceeded { needed, quota: q } => { - assert_eq!(needed, quota + cost("b", b"2")); - assert_eq!(q, quota); - } - other => panic!("expected QuotaExceeded, got {other:?}"), - } - // All-or-nothing: even the op that fit on its own must not have landed. - assert!(ms.get("a").unwrap().is_none()); - assert!(ms.get("b").unwrap().is_none()); - assert_eq!(ms.get("seed").unwrap().as_deref(), Some(&b"v"[..])); -} - -#[test] -fn apply_over_op_count_batch_rejected_untouched() { - let (_dir, store) = fresh(); - let ms = store.module("m").unwrap(); - let ops: Vec = (0..=MAX_APPLY_OPS) - .map(|i| set_op(&format!("k{i}"), b"v")) - .collect(); - let err = ms.apply(&ops).unwrap_err(); - match err { - StorageError::ApplyOpsExceeded { ops: n, cap } => { - assert_eq!(n, MAX_APPLY_OPS + 1); - assert_eq!(cap, MAX_APPLY_OPS); - } - other => panic!("expected ApplyOpsExceeded, got {other:?}"), - } - assert!(ms.get("k0").unwrap().is_none()); - assert_eq!(ms.count("").unwrap(), 0); -} - -#[test] -fn apply_over_value_bytes_batch_rejected_untouched() { - let (_dir, store) = fresh(); - let ms = store.module("m").unwrap(); - let big = vec![0u8; MAX_APPLY_VALUE_BYTES as usize]; - let err = ms - .apply(&[set_op("a", &big), set_op("b", b"1")]) - .unwrap_err(); - match err { - StorageError::ApplyBytesExceeded { bytes, cap } => { - assert_eq!(bytes, MAX_APPLY_VALUE_BYTES + 1); - assert_eq!(cap, MAX_APPLY_VALUE_BYTES); - } - other => panic!("expected ApplyBytesExceeded, got {other:?}"), - } - assert!(ms.get("a").unwrap().is_none()); - assert!(ms.get("b").unwrap().is_none()); -} - -#[test] -fn apply_quota_charges_net_batch_footprint() { - let (_dir, store) = fresh(); - // Quota holds exactly one entry: the set alone would bust it, but the - // batch's delete releases the seeded bytes first, so the net fits. - let quota = cost("old", b"12345"); - let ms = store.module("m").unwrap().with_quota(quota); - ms.set("old", b"12345").unwrap(); - assert!(ms.set("new", b"12345").is_err()); - ms.apply(&[delete_op("old"), set_op("new", b"12345")]) - .unwrap(); - assert!(ms.get("old").unwrap().is_none()); - assert_eq!(ms.get("new").unwrap().as_deref(), Some(&b"12345"[..])); - // The counter carried the net footprint: a refill of the freed slot fits. - ms.apply(&[delete_op("new"), set_op("old", b"12345")]) - .unwrap(); -} - -#[test] -fn apply_quota_projects_the_last_op_per_key() { - let (_dir, store) = fresh(); - // The oversized first write on "k" is superseded within the batch; only - // the final small value is charged, so the batch fits a tight quota. - let quota = cost("k", b"ok"); - let ms = store.module("m").unwrap().with_quota(quota); - ms.apply(&[set_op("k", &vec![0u8; 1024]), set_op("k", b"ok")]) - .unwrap(); - assert_eq!(ms.get("k").unwrap().as_deref(), Some(&b"ok"[..])); -} - +// --------------------------------------------------------------------------- // Concurrent access tests: real parallelism via the blocking pool. +// --------------------------------------------------------------------------- fn blocking_executor() -> nexum_tasks::TaskExecutor { nexum_tasks::TaskManager::new().executor() diff --git a/crates/nexum-runtime/src/host/logs/mod.rs b/crates/nexum-runtime/src/host/logs/mod.rs index ddb6853..b619bf3 100644 --- a/crates/nexum-runtime/src/host/logs/mod.rs +++ b/crates/nexum-runtime/src/host/logs/mod.rs @@ -1,14 +1,25 @@ //! Typed module-log pipeline. //! -//! Three capture points build [`LogRecord`]s for one [`LogRouter`]: the -//! `nexum:host/logging` glue, the per-store stdout/stderr pipes, and the -//! supervisor death path. The router fans each record to a host `tracing` -//! event and the retention store. [`LogPipeline`] is the shared handle, -//! carrying the write side and the store's read side. +//! 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. //! -//! One guest panic yields three records distinguished by [`LogSource`] -//! (stderr, host logging call, supervisor death), redundancy covering -//! channels that survive different failure modes. +//! [`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; @@ -22,7 +33,8 @@ use tracing_core::Level; pub use stdio::StdioStream; pub use store::{InMemoryRunLogStore, LogPage, RunLogStore, RunMeta}; -/// Identity of one module run; a restart increments `seq`, keying retention. +/// 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. @@ -34,7 +46,8 @@ pub struct RunId { } impl RunId { - /// Mint a run for `module` at sequence `seq`. + /// 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(), @@ -44,8 +57,8 @@ impl RunId { } } -/// Which capture point produced a record; the snake_case name is the tracing -/// `source` field. +/// 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] @@ -76,7 +89,7 @@ pub struct LogRecord { } impl LogRecord { - /// Record stamped at the current instant. + /// Build a record stamped at the current wall-clock instant. pub fn now(run: RunId, source: LogSource, level: Level, message: String) -> Self { Self { run, @@ -93,16 +106,19 @@ impl LogRecord { } } -/// Fixed per-record charge added to message bytes so empty messages still -/// count against the `[limits.logs]` byte budget. +/// 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; a reader must arm before reading (see - /// [`LogPipeline::appended`]). + /// 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, } @@ -127,7 +143,11 @@ impl LogRouter { } } -/// Emit one record as a host tracing event at its own level. +/// 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; @@ -146,7 +166,9 @@ fn emit_tracing(record: &LogRecord) { } } -/// Shared log pipeline threaded into every module store; cheap to clone. +/// 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, @@ -160,8 +182,8 @@ impl LogPipeline { } } - /// Pipeline over the byte-bounded in-memory backend, sized by - /// `[limits.logs]`. + /// 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))) } @@ -171,8 +193,9 @@ impl LogPipeline { self.router.clone() } - /// Notify woken after each append; arm a `notified()` future before - /// reading so an append is not lost. + /// 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() } @@ -194,7 +217,8 @@ mod tests { use super::*; - /// Store that records appends so the fan-out test can inspect them. + /// 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>, } diff --git a/crates/nexum-runtime/src/host/logs/stdio.rs b/crates/nexum-runtime/src/host/logs/stdio.rs index 9e91a9e..251e675 100644 --- a/crates/nexum-runtime/src/host/logs/stdio.rs +++ b/crates/nexum-runtime/src/host/logs/stdio.rs @@ -1,6 +1,7 @@ -//! Per-store stdout/stderr capture: a [`StdoutStream`] line-buffering guest -//! output and routing each line as a [`LogRecord`] tagged with its run and -//! source. +//! 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; @@ -14,12 +15,15 @@ use tracing_core::Level; use super::{LogRecord, LogRouter, LogSource, RunId}; -/// Cap on an unterminated in-flight line; crossing it force-flushes the -/// buffer as one record. +/// 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; each [`StdoutStream::async_stream`] yields -/// a line-splitting writer bound to the run and source. +/// 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, @@ -54,8 +58,10 @@ impl StdoutStream for StdioStream { } } -/// Line-splitting writer: one record per newline. Cutting only at `\n` -/// reassembles multi-byte code points split across writes. +/// 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, @@ -82,8 +88,8 @@ impl LineWriter { } } - /// Emit any buffered partial line; idempotent, so shutdown and drop never - /// double-emit. + /// 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; @@ -93,7 +99,8 @@ impl LineWriter { } } -/// Level for a captured line: stdout INFO, stderr WARN. +/// 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, @@ -101,7 +108,8 @@ fn level_for(source: LogSource) -> Level { } } -/// Decode and route one line, dropping a trailing `\r` and skipping empties. +/// 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() { @@ -155,7 +163,8 @@ mod tests { use super::*; use crate::host::logs::{LogPipeline, LogRecord, LogSource, RunId, RunLogStore}; - /// Store recording every appended message for assertions. + /// 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>, diff --git a/crates/nexum-runtime/src/host/logs/store.rs b/crates/nexum-runtime/src/host/logs/store.rs index a17c72e..e953d01 100644 --- a/crates/nexum-runtime/src/host/logs/store.rs +++ b/crates/nexum-runtime/src/host/logs/store.rs @@ -1,5 +1,6 @@ -//! Retention store for captured log records: the [`RunLogStore`] trait and -//! the default byte-bounded in-memory backend. +//! 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; @@ -13,10 +14,12 @@ 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 at or after the cursor, oldest first; may be short after - /// eviction. + /// 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 for the next [`RunLogStore::read`]. + /// Cursor to pass on the next [`RunLogStore::read`] to continue after + /// the last returned record. pub next_cursor: u64, } @@ -37,15 +40,17 @@ pub struct RunMeta { pub last_ts: Option, } -/// Retention backend for captured records; appends are infallible, a full -/// ring evicts. +/// 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 records from `cursor`; an unknown or evicted run yields an - /// empty page with `next_cursor` 0. + /// 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; } diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs index a6c0693..57103d2 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -1,11 +1,31 @@ -//! Host-side backends for the `nexum:host` interfaces, plus the per-module -//! [`state::HostState`] and the WIT `Host` trait impls. +//! Host-side backends for the `nexum:host` interfaces, plus the +//! per-module `HostState` and the WIT `Host` trait impls. //! -//! [`provider_pool`] and [`local_store_redb`] are the capability backends; -//! [`component`] is the backend-trait seam; [`extension`] wires in domain -//! extensions; [`actor`] supervises provider instances; [`http`] gates -//! outgoing wasi:http; [`logs`] is the module-log pipeline; [`error`] projects -//! backend errors into the WIT `chain-error` / `Fault` shapes. +//! 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; @@ -16,4 +36,5 @@ 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/crates/nexum-runtime/src/host/provider_pool.rs b/crates/nexum-runtime/src/host/provider_pool.rs index 0540c6a..55d5e91 100644 --- a/crates/nexum-runtime/src/host/provider_pool.rs +++ b/crates/nexum-runtime/src/host/provider_pool.rs @@ -1,10 +1,16 @@ -//! `nexum:host/chain` backend: per-chain provider opened from the engine -//! config at boot. +//! `nexum:host/chain` backend. //! -//! `request` is a raw JSON-RPC dispatch over a typed [`ChainMethod`], so only -//! the permitted read surface reaches the transport; params pass through -//! unencoded and the result body returns verbatim. WS/WSS push `newHeads`; -//! HTTP polls `eth_getBlockByNumber`. +//! 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; @@ -28,19 +34,27 @@ use tracing::info; use crate::engine_config::EngineConfig; use crate::host::component::ChainMethod; -/// Head re-poll cadence for chains without a block-time hint; known chains -/// derive it from [`Chain::average_blocktime_hint`]. +/// 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; heal transient RPC blips below the -/// poller so a node hiccup does not force a re-open. +/// 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 for rate-limited nodes; generous, this -/// pool is read-only and low-QPS. +/// 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; -/// Transport retry layer applied to every provider in the pool. +/// 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) } @@ -50,21 +64,26 @@ fn retry_layer() -> RetryBackoffLayer { struct ChainEndpoint { provider: DynProvider, timeout: Duration, - /// WS/IPC drives block following by pubsub; HTTP polls. + /// WS/IPC transport: `subscribe_blocks` pushes `newHeads`. HTTP has no + /// pubsub, so block following polls `eth_getBlockByNumber` instead. supports_pubsub: bool, } -/// Providers keyed by chain. +/// Pool of alloy providers keyed by chain. #[derive(Debug, Clone)] pub struct ProviderPool { providers: Arc>, - /// In-flight `eth_getLogs` groups during gap backfill; `0` clamps to `1`. + /// 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`; connection failures - /// propagate and are fatal at boot. + /// 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 @@ -121,7 +140,8 @@ impl ProviderPool { }) } - /// Empty pool; every `request` returns `UnknownChain`. + /// Empty pool - used by tests. Every `request` call returns + /// `UnknownChain`. #[cfg(test)] pub fn empty() -> Self { Self { @@ -130,8 +150,9 @@ impl ProviderPool { } } - /// Follow canonical block headers on `chain`: WS via - /// `eth_subscribe(newHeads)`, HTTP by polling at the chain's block time. + /// 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 @@ -188,7 +209,9 @@ impl ProviderPool { Ok(Box::pin(stream)) } - /// Current head block number (`eth_blockNumber`). + /// 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 @@ -205,9 +228,14 @@ impl ProviderPool { }) } - /// Canonical (reorg-aware) log stream on `chain` from `start_block`. Each - /// item is one block's matching logs (possibly empty); reorg rollbacks - /// carry `removed == true`. + /// 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, @@ -257,7 +285,10 @@ impl ProviderPool { Ok(Box::pin(stream)) } - /// Raw JSON-RPC dispatch; `params_json` is the JSON-encoded params array. + /// 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, @@ -322,12 +353,16 @@ impl ProviderPool { /// Boxed stream of `newHeads`-style block headers. pub type BlockStream = Pin> + Send>>; -/// Boxed canonical per-block log stream; reorg rollbacks carry -/// `removed == true`. +/// 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`]. Variant names serialize snake_case as -/// `&'static str` for metric labels. +/// 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] @@ -353,7 +388,7 @@ pub enum ProviderError { #[source] source: url::ParseError, }, - /// Guest-supplied JSON params did not parse. + /// The guest-supplied JSON params did not parse. #[error("invalid params JSON for `{method}`: {source}")] InvalidParams { /// RPC method name. @@ -362,27 +397,39 @@ pub enum ProviderError { #[source] source: serde_json::Error, }, - /// `request_timeout_secs = 0`; rejected at boot. + /// `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, }, - /// RPC node did not respond within the per-request timeout. + /// 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, }, - /// Node returned an error for the dispatched call. JSON-RPC `ErrorResp` - /// payloads propagate `code`/`data`; transport failures leave both `None`. + /// 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, - /// `ErrorResp.code`, `None` for transport-level failures. + /// JSON-RPC error code from `ErrorResp.code`. `None` when + /// the failure was transport-level (no structured response). code: Option, - /// Decoded `ErrorResp.data` (abi-encoded revert body), else `None`. + /// 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] diff --git a/crates/nexum-runtime/src/host/remote_store_bee.rs b/crates/nexum-runtime/src/host/remote_store_bee.rs new file mode 100644 index 0000000..6fcbdde --- /dev/null +++ b/crates/nexum-runtime/src/host/remote_store_bee.rs @@ -0,0 +1,407 @@ +//! `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/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index b6128f6..f641050 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -1,5 +1,8 @@ -//! Per-module host state, held in the wasmtime `Store` and the receiver for -//! every `Host` impl in `super::impls`. +//! 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; @@ -11,8 +14,11 @@ 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. +/// 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, @@ -23,24 +29,34 @@ pub struct HostState { /// Per-module allowlist gate every wasi:http outgoing request /// passes through. pub http_gate: HttpGate, - /// Content topics this store may publish to; empty is unscoped. An - /// out-of-scope publish is refused before the backend. + /// 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; tags every captured log record. + /// 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 via - /// [`ExtState`]. + /// Extension backends (the lattice `Ext` payload). Reached generically + /// by an extension's `Host` impl through [`ExtState`]. pub ext: T::Ext, - /// `chain` backend: per-chain provider pool. + /// `chain` backend - per-chain alloy `DynProvider` pool. pub chain: T::Chain, - /// Cap on a chain JSON-RPC response body; larger responses are rejected. + /// 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 keccak256 prefix. + /// `local-store` backend - per-module handle with pre-computed + /// keccak256 namespace prefix. pub store: Handle, - /// Extension-owned host services, keyed by namespace; a provider store - /// carries an empty map. + /// `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, } @@ -55,8 +71,13 @@ impl WasiView for HostState { } } -/// Generic access to the extension payload of a host state, without naming -/// the concrete lattice `T`. +/// 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; diff --git a/crates/nexum-runtime/src/lib.rs b/crates/nexum-runtime/src/lib.rs index 22fef9e..4a29dd4 100644 --- a/crates/nexum-runtime/src/lib.rs +++ b/crates/nexum-runtime/src/lib.rs @@ -1,9 +1,11 @@ -//! Wasmtime-based host for WASM Component Model modules, embeddable as a -//! library; the bundled binary is a thin consumer of the same surface. +//! 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. //! -//! Settlement-domain-agnostic: no domain symbol or WIT reference, `nexum:host` -//! stays a leaf WIT package, no crate edge reaches a domain crate. Enforced in -//! CI by the zero-leak script under `scripts/`. +//! 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))] diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index ccea7c1..3492634 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -1,19 +1,30 @@ -//! Capability enforcement: cross-checks a component's WIT imports against -//! its `[capabilities]` declarations. +//! Capability enforcement: cross-checks the component's WIT imports +//! against the `[capabilities]` block declared in `module.toml`. //! -//! The core `nexum:host` namespace is built in; each extension registers -//! its own via [`CapabilityRegistry::register`]. `wasi:http` is gated by -//! the `http` capability, `wasi:sockets` and `wasi:filesystem` by `wasi-*`; -//! io/clocks/random and `wasi:cli` are ambient; any other `wasi:` interface -//! is refused fail-closed. +//! 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}; -/// A WIT namespace prefix plus the interface names under it that are -/// capabilities. +/// 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/"`. @@ -28,31 +39,36 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: CORE_CAPABILITIES, }; -/// Interfaces a provider world links: the scoped transport only. `http` is -/// gated by the registry, as in the core set. -pub const PROVIDER_CAPABILITIES: &[&str] = &[ - nexum_world::Cap::Chain.as_str(), - nexum_world::Cap::Messaging.as_str(), -]; - -/// The provider namespace: `nexum:host/` scoped to the transport -/// interfaces, so a provider declaring a core-only interface (e.g. -/// `local-store`) is rejected as unknown. +/// 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 is -/// gated by [`HTTP_CAPABILITY`]. +/// 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::Cap::Http.as_str(); +const HTTP_CAPABILITY: &str = nexum_world::caps::HTTP; -/// Gated WASI capability names; declaring one grants the matching `wasi:` -/// interface group. See [`classify_wasi`]. +/// 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. @@ -84,8 +100,8 @@ fn classify_wasi(import_name: &str) -> WasiGate { } } -/// Capability namespaces recognised by enforcement: the core namespace plus -/// every registered extension. +/// Registry of capability namespaces recognised by enforcement. Built from +/// the core namespace plus every registered extension. #[derive(Clone)] pub struct CapabilityRegistry { namespaces: Vec, @@ -105,9 +121,11 @@ impl CapabilityRegistry { } } - /// The registry a provider validates against: the scoped transport plus - /// `http`. A provider manifest declaring a core-only capability (e.g. - /// `local-store`) fails as unknown. + /// 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], @@ -120,6 +138,7 @@ impl CapabilityRegistry { } /// 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) @@ -137,10 +156,22 @@ impl CapabilityRegistry { .join(", ") } - /// Map a WIT import name to a capability name. `Some(iface)` for an - /// interface under a registered namespace, `Some("http")` for anything - /// under `wasi:http/`, and `None` for a non-capability import (type-only - /// packages, ungated `wasi:*`). + /// 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) { @@ -157,11 +188,16 @@ impl CapabilityRegistry { } } -/// Check that every capability-bearing WIT import is covered by the -/// manifest's declarations; call after loading the component, before -/// instantiation. The WASI surface is gated fail-closed even in the -/// 0.1-fallback (no `[capabilities]`), where the registry surface stays -/// permissive. `component_imports` are the import name parts. +/// 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, diff --git a/crates/nexum-runtime/src/manifest/error.rs b/crates/nexum-runtime/src/manifest/error.rs index e961c8e..ed5858c 100644 --- a/crates/nexum-runtime/src/manifest/error.rs +++ b/crates/nexum-runtime/src/manifest/error.rs @@ -3,7 +3,11 @@ use strum::IntoStaticStr; use thiserror::Error; -/// Errors from loading or validating a manifest. +/// 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] @@ -14,16 +18,18 @@ pub enum ParseError { /// Manifest file was not valid TOML. #[error("manifest: parse: {0}")] Toml(#[from] toml::de::Error), - /// A declared capability the engine does not recognise. + /// `[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 name. + /// The unrecognised capability name. name: String, /// Comma-joined recognised capability names. known: String, }, - /// `[module].name` contains `/`, `\`, or `..`, so it could escape the - /// state directory. + /// `[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), } @@ -35,13 +41,15 @@ pub enum ParseError { [capabilities].required or [capabilities].optional" )] pub struct CapabilityViolation { - /// Capability name. + /// Capability name (e.g. `"remote-store"`). pub capability: String, - /// Full WIT import name. + /// Full WIT import name as it appeared in the component (e.g. + /// `"nexum:host/remote-store@0.1.0"`). pub wit_import: String, } -/// A component's WIT imports exceed its declared capabilities. +/// Error returned when a component's WIT imports exceed its declared +/// capabilities. #[derive(Debug, Error)] #[non_exhaustive] pub enum CapabilityError { diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index 3f26ee6..a7a70e9 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -1,5 +1,9 @@ -//! Parse and validate `module.toml`, plus the host-matching helper the -//! wasi:http gate uses to enforce `[capabilities.http].allow`. +//! 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; @@ -9,9 +13,10 @@ use super::capabilities::CapabilityRegistry; use super::error::ParseError; use super::types::{LoadedManifest, Manifest}; -/// Read, parse, and validate `module.toml`; declared capability names are -/// checked against `registry`. Warns if `[capabilities]` is absent -/// (0.1-compat fallback). +/// 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)?; @@ -70,7 +75,8 @@ pub fn load(path: &Path, registry: &CapabilityRegistry) -> Result LoadedManifest { warn!( target: "manifest", @@ -85,9 +91,9 @@ pub fn fallback_manifest() -> LoadedManifest { } } -/// Reject a `[module].name` that is not a single safe path component, so it -/// cannot escape the state directory. An empty name is allowed (the runtime -/// falls back to `module`). +/// 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())); @@ -95,10 +101,11 @@ fn validate_module_name(name: &str) -> Result<(), ParseError> { Ok(()) } -/// Whether `host` matches any allowlist pattern: exact, or a `*.suffix` -/// wildcard matching any subdomain of `suffix` but not `suffix` itself. -/// Case-insensitive and host-only (no scheme or port; IPv6 literals keep -/// their brackets). +/// 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| { @@ -225,7 +232,8 @@ scope = 7 assert!(err.to_string().contains("must be a string"), "{err}"); } - /// A non-core top-level section parses into the opaque extension map. + /// 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#" @@ -365,7 +373,8 @@ kind = "acme-provider" ); } - /// An unknown spelling parses as a provider kind for boot to refuse. + /// 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; diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs index 7a76828..da7e0c0 100644 --- a/crates/nexum-runtime/src/manifest/mod.rs +++ b/crates/nexum-runtime/src/manifest/mod.rs @@ -1,10 +1,31 @@ -//! `module.toml` parser and capability enforcement. +//! `module.toml` parser and capability-enforcement helpers (0.2 scope). //! -//! `load` parses and validates a manifest; `capabilities` cross-checks a -//! component's WIT imports against its declared `[capabilities]`; `types` -//! holds the serde shapes and `LoadedManifest`; `error` the error types. -//! A manifest with no `[capabilities]` section falls back to all-required, -//! with a deprecation warning. +//! 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; diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index ab5306a..1960dc6 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -1,4 +1,8 @@ -//! Serde shapes: `Manifest`, its sections, and `LoadedManifest`. +//! 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; @@ -6,9 +10,13 @@ use std::fmt; use serde::Deserialize; use serde::de::Error as _; -/// Core capability names: the `nexum:host` interfaces linked into every -/// module. `http` is gated separately (it gates `wasi:http/*`), and -/// extensions register their own namespaces. +/// 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)] @@ -19,13 +27,16 @@ pub struct Manifest { pub capabilities: Option, #[serde(default)] pub config: toml::Table, - /// Event subscriptions wired before `_init`. `block` and `chain-log` - /// are dispatched; `cron` is parsed and ignored. + /// 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 and routed to the wired extensions; a section no extension - /// claims is refused at boot. + /// 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, } @@ -34,45 +45,61 @@ pub struct Manifest { /// to the runtime; each claiming extension parses its own. pub type ExtensionSections = BTreeMap; -/// One `[[subscription]]` table. The `kind` field discriminates; an -/// unknown kind parses as [`Subscription::Extension`] and is validated at -/// boot against the wired extensions' declared kinds. +/// 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; one subscription per chain id, fanned out to every - /// module watching that chain. + /// 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; one subscription per - /// entry, tagged with the owning module. + /// 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 filter as `0x`-prefixed 32-byte hex; absent matches - /// every event from the address(es). + /// 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, - /// Persist a durable per-subscription cursor and re-open from just - /// after the last dispatched block instead of head. Delivery is - /// then at-least-once; the module must tolerate redelivery. + /// Resume 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, - /// Backfill cap for a `resume` subscription, in blocks. `None` - /// backfills the whole gap; set it only for a consumer that + /// 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; parsed but not dispatched (the supervisor - /// warns). + /// 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. Delivered when the kind matches and - /// every filter pair is present in the event's attributes. + /// 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, @@ -81,8 +108,8 @@ pub enum Subscription { }, } -/// Core subscription kinds parsed by shape; others fall through to -/// [`Subscription::Extension`]. +/// 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 { @@ -168,8 +195,10 @@ pub struct ModuleSection { pub version: String, #[serde(default)] pub component: String, - /// Component kind; defaults to the worker (`event-module`), a provider - /// names its registered kind. + /// 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 @@ -181,16 +210,19 @@ pub struct ModuleSection { /// The worker kind's manifest spelling. pub const WORKER_KIND: &str = "event-module"; -/// Component kind a manifest declares: the worker, or a provider spelling -/// an extension registers. Defaults to the worker; an unregistered spelling -/// is refused at boot. +/// 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 (`event-module`). + /// Event-driven worker over the six core primitives (`event-module`). #[default] Worker, - /// A provider, named by its manifest spelling. + /// A provider the host holds behind a serialised actor, named by its + /// manifest spelling. Provider(String), } @@ -212,8 +244,8 @@ impl fmt::Display for ComponentKind { } } } -/// `[module.resources]` overrides; each unset field keeps the engine -/// `[limits]` default. +/// `[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. @@ -251,8 +283,9 @@ pub struct LoadedManifest { /// Hosts wasi:http outgoing requests may target. Each entry is /// either an exact hostname or a `*.suffix` wildcard. pub http_allowlist: Vec, - /// `[config]` flattened to `(key, stringified-value)` pairs for a - /// module's `init`. Scalars become their text form; arrays and tables + /// `[config]` flattened to `(key, stringified-value)` pairs ready to + /// hand to a module's `init`. TOML scalars (string, integer, float, + /// boolean) become their text form. Arrays and tables are rendered as /// their TOML representation. pub config: Vec<(String, String)>, } diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs index 1296d3d..56fe162 100644 --- a/crates/nexum-runtime/src/preset.rs +++ b/crates/nexum-runtime/src/preset.rs @@ -1,10 +1,13 @@ -//! Runtime presets: one preset bundles a lattice, its component builders, -//! extensions, and add-ons, so an embedder launches with -//! `RuntimeBuilder::new(cfg).runtime::().launch()`. A preset carrying -//! pre-built backends or non-static extensions binds by value through +//! 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: a chain provider pool and a -//! local redb store, no extension payload, with the Prometheus add-on. +//! [`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; @@ -19,8 +22,14 @@ 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. +/// 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 { @@ -35,7 +44,7 @@ pub trait Runtime: crate::sealed::SealedRuntime { /// Builds the shared [`LogPipeline`]. type LogsBuilder: ComponentBuilder; - /// Component builders that open the backends at launch; consumes the + /// 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, @@ -49,7 +58,8 @@ pub trait Runtime: crate::sealed::SealedRuntime { /// The cross-cutting add-ons installed before the engine boots. fn add_ons(&self) -> AddOns; - /// Extensions the preset launches with, derived from config. Empty by + /// 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. @@ -59,9 +69,9 @@ pub trait Runtime: crate::sealed::SealedRuntime { } } -/// The domain-free default preset: a chain provider pool and a local redb -/// store, no extension payload, with the Prometheus add-on. Doubles as its own -/// [`RuntimeTypes`] lattice. +/// 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; @@ -81,9 +91,7 @@ impl Runtime for CoreRuntime { type ExtBuilder = (); type LogsBuilder = LogPipelineBuilder; - fn components( - self, - ) -> ComponentsBuilder { + fn components(self) -> ComponentsBuilder { ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) } diff --git a/crates/nexum-runtime/src/runtime/dispatch_rate.rs b/crates/nexum-runtime/src/runtime/dispatch_rate.rs index d7b1a86..23a9853 100644 --- a/crates/nexum-runtime/src/runtime/dispatch_rate.rs +++ b/crates/nexum-runtime/src/runtime/dispatch_rate.rs @@ -55,8 +55,8 @@ impl TokenBucket { } } - /// Refill for elapsed time, then consume one token; `true` allowed, - /// `false` over-rate. `now` is injected. + /// 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 @@ -129,7 +129,8 @@ mod tests { ); } - /// A flooding source is throttled while an independent source is served. + /// 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(); diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index ef884bb..3b84830 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -1,15 +1,32 @@ -//! Open live chain event sources and dispatch their events to the supervisor -//! until shutdown. Blocks come from `eth_subscribe(newHeads)` (WS); chain-logs -//! from an `eth_getLogs` block-range poller (HTTP or WS) that recovers events -//! across a reconnect by re-querying the gap rather than dropping them. +//! Open live chain event sources and dispatch their events to the +//! supervisor until a shutdown signal arrives. Blocks come from +//! `eth_subscribe(newHeads)` (WS); chain-logs come from alloy's +//! canonical `eth_getLogs` block-range poller (HTTP or WS), which +//! recovers events across a reconnect by re-querying the gap instead +//! of dropping them. //! -//! `open_block_streams` and `open_chain_log_streams` each spawn one -//! reconnect-aware task per subscription: it opens the stream, pumps items to -//! an mpsc channel, and on drop waits `restart_policy::backoff_for` before -//! reopening, resetting the backoff once the stream has been healthy for -//! `HEALTHY_WINDOW`. The tasks exit with [`TaskExit::ReceiverGone`] when `run` -//! drops the receivers; their handles collect into a [`TaskSet`] the loop -//! drains on shutdown. +//! ## 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}; @@ -29,31 +46,53 @@ use crate::runtime::restart_policy::backoff_for; use crate::supervisor::{ChainLogSub, Supervisor}; use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; -/// Errors carried by the tagged block and chain-log streams. +/// 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 { - /// Provider or transport failure opening or pumping the subscription. + /// Underlying provider / transport failure while opening or + /// pumping the subscription. #[error(transparent)] Provider(#[from] ProviderError), } -/// Uninterrupted-event duration before the backoff counter resets to 0. +/// 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); -/// Silence between block events beyond which the next event logs a gap-closed -/// line, surfacing an alloy-internal transport reconnect that produced no -/// `stream ended` event. +/// 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 each reconnect task. +/// 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; -/// Block-gap size at or above which a re-open logs a large-backfill notice. +/// 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; -/// Open one reconnect-aware block-subscription task per chain, spawned via -/// `executor` with handles pushed into `tasks` for graceful shutdown. +/// 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], @@ -76,8 +115,10 @@ where streams } -/// Open one reconnect-aware chain-log task per subscription; see -/// [`open_block_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, @@ -107,7 +148,9 @@ where streams } -/// Wrap an `mpsc::Receiver` as a `Stream`. +/// 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 { @@ -116,8 +159,10 @@ fn receiver_stream( }) } -/// Reconnect-aware loop for one chain's block subscription: re-opens the -/// `eth_subscribe` stream with exponential backoff after every drop or error. +/// 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, @@ -202,19 +247,28 @@ where /// Per-subscription resume and backfill knobs for a chain-log task. struct ChainLogResume { - /// Durable cursor key; `Some` for a `resume` subscription. + /// 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 backfill depth; `None` backfills the whole gap. + /// Opt-in cap (in blocks) on how far back the poller backfills; `None` + /// backfills the whole gap. max_lookback: Option, } -/// Poller-backed loop for one (module, chain) chain-log subscription. Drives -/// the `eth_getLogs` block-range poller, which reconciles reorgs and re-queries -/// gaps internally. On a terminal poller error it re-opens from the block after -/// the last delivered one and backfills the whole missed range, bounded only by -/// `max_lookback` if set. +/// 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, @@ -397,19 +451,28 @@ pub type TaggedBlockStream = std::pin::Pin< + Send, >, >; -/// One tagged chain-log item: `(module, chain, log, cursor_key)` or a stream -/// error. `cursor_key` is `Some` for a `resume` subscription and threads the -/// durable cursor key to the dispatch site. +/// 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. /// -/// `shutdown` is observed only between dispatches, never mid-`call_on_event`, -/// so an in-flight wasmtime call finishes before the loop exits; the guard it -/// yields is held until return, so the drain covers the final dispatch and -/// cursor commit. Returns the `(blocks, chain_logs)` dispatch tally. +/// 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, @@ -551,8 +614,13 @@ pub async fn run( } } -/// Start block for a re-opened log poller: `None` (first open) starts at head; -/// otherwise just after the last delivered block, backfilling the whole gap. +/// 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, @@ -560,8 +628,11 @@ fn poller_resume_block(last_seen_block: Option, head: u64) -> u64 { } } -/// `Some(gap)` when `now` is at least `threshold` past the last event; `None` -/// on the first event or when events arrive within `threshold`. +/// 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, @@ -598,6 +669,9 @@ mod tests { // ── 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; @@ -616,7 +690,9 @@ mod tests { tasks.shutdown().await; } - /// `open_chain_log_streams` spawns one reconnect task per subscription. + /// `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; @@ -649,8 +725,11 @@ mod tests { tasks.shutdown().await; } - /// A reconnect task whose receiver drops exits on its own with - /// [`TaskExit::ReceiverGone`], not via abort. + /// 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; @@ -684,7 +763,8 @@ mod tests { // ── block_stream_gap_to_log unit tests ────────────────────────────────── - /// No prior event yields `None`. + /// 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(); diff --git a/crates/nexum-runtime/src/runtime/limits.rs b/crates/nexum-runtime/src/runtime/limits.rs index ab046cd..b7778f1 100644 --- a/crates/nexum-runtime/src/runtime/limits.rs +++ b/crates/nexum-runtime/src/runtime/limits.rs @@ -1,5 +1,6 @@ -//! Re-exports the per-module fuel and memory limits; canonical source -//! [`crate::engine_config::ModuleLimits`]. +//! 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 the +//! Fuel meters only guest instructions; host-call time is unmetered, so a //! per-dispatch wall-clock deadline in [`crate::supervisor`] is the backstop. +//! diff --git a/crates/nexum-runtime/src/runtime/poison_policy.rs b/crates/nexum-runtime/src/runtime/poison_policy.rs index b080e19..bfc1a42 100644 --- a/crates/nexum-runtime/src/runtime/poison_policy.rs +++ b/crates/nexum-runtime/src/runtime/poison_policy.rs @@ -1,18 +1,45 @@ //! Supervisor poison-pill policy. //! -//! A module reaching `max_failures` traps within a sliding `window` is -//! poisoned: the supervisor stops dispatching to it (no further restarts), -//! sets the `shepherd_module_poisoned{module}` gauge to 1, and logs the -//! quarantine. Recovery needs an operator-driven full engine restart. +//! 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 quarantines a module. +/// 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 from `[limits.poison]`, else -/// [`PoisonPolicy::default`]. +/// 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. @@ -36,7 +63,8 @@ impl Default for PoisonPolicy { } } -/// `true` when the recent-failure count crosses the configured threshold. +/// 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 } diff --git a/crates/nexum-runtime/src/runtime/restart_policy.rs b/crates/nexum-runtime/src/runtime/restart_policy.rs index 502cb6c..7b80dd6 100644 --- a/crates/nexum-runtime/src/runtime/restart_policy.rs +++ b/crates/nexum-runtime/src/runtime/restart_policy.rs @@ -1,8 +1,12 @@ //! Supervisor module restart policy. //! -//! On a trap in `on_event` the supervisor marks the module dead and schedules -//! a restart with exponential backoff; the next eligible dispatch retries, and -//! a successful call resets the failure counter. +//! 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 | //! |---|---| @@ -12,16 +16,26 @@ //! | ... | doubles | //! | 9+ | capped at 5 minutes | //! -//! State is in-memory per process; it does not persist across restarts. +//! 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. +/// 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); -/// Backoff before the next restart of a module that trapped `failure_count` -/// times in a row. `0` returns `Duration::ZERO` (steady state, callable -/// unconditionally); `>= 1` is 1 s doubling per trap, capped at 5 min. +/// 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; diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index c7978d5..e96f02f 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -1,17 +1,35 @@ //! Multi-module supervisor. //! -//! Loads every `[[modules]]` and `[[adapters]]` entry from `engine.toml`, -//! instantiates each against a dedicated wasmtime `Store`, and routes -//! subscribed events. +//! 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. //! -//! On a trap in `on_event` a module is marked dead, its failure count -//! bumps, and a backoff `next_attempt` is scheduled; the next eligible -//! dispatch re-instantiates it on a fresh `Store` (the trapped instance is -//! poisoned) and re-runs `init`. A successful dispatch resets the count. A -//! module whose `init` returned `Err` is permanently dead -//! (`next_attempt = None`). Providers ride the same sweeps via a shared -//! [`Liveness`]. Per-module restart, poison, and fuel state are -//! independent across chains. +//! 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; @@ -48,34 +66,42 @@ use crate::manifest::{ self, CapabilityRegistry, ComponentKind, LoadedManifest, ResourceSection, Subscription, }; -/// Owns every loaded module and provider and exposes the dispatch surface. -/// Generic over the [`RuntimeTypes`] backend lattice. +/// 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; swept for restart and poison alongside - /// the modules. + /// 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, for the - /// restart sweep to reinstall through. + /// Registered provider kinds paired with their services, kept for the + /// provider restart sweep to reinstall through. kinds: ProviderKinds, - /// Cached for restart: rebuilding a trapped module needs a fresh - /// `Store` + `Linker`, hence the shared backends held here. + /// 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 to rebuild an identical linker on - /// restart. + /// 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 and carried by - /// every store. + /// 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. + /// 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. `None` - /// leaves the ambient host clocks. + /// 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 (`Ext = ()`). +/// 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; @@ -90,17 +116,22 @@ impl RuntimeTypes for TestTypes { type Ext = (); } -/// The supervisor the runtime's own tests drive. +/// 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`. +/// 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; shared -/// wall and monotonic sources let a test drive guest-visible time. `None` -/// keeps the ambient host clocks. `RunId.started_at` is host wall-clock -/// and unaffected. +/// 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, @@ -117,7 +148,8 @@ impl WasiClockOverride { } } -/// Adapts a shared wall clock into the by-value `HostWallClock` a store owns. +/// Adapts a shared wall clock into the by-value `HostWallClock` the +/// `WasiCtxBuilder` takes ownership of per store. struct SharedWallClock(Arc); impl HostWallClock for SharedWallClock { @@ -130,8 +162,8 @@ impl HostWallClock for SharedWallClock { } } -/// Adapts a shared monotonic clock into the by-value `HostMonotonicClock` a -/// store owns. +/// Adapts a shared monotonic clock into the by-value `HostMonotonicClock` the +/// `WasiCtxBuilder` takes ownership of per store. struct SharedMonotonicClock(Arc); impl HostMonotonicClock for SharedMonotonicClock { @@ -144,16 +176,16 @@ impl HostMonotonicClock for SharedMonotonicClock { } } -/// A module's resource budget: `[module.resources]` layered over engine -/// `[limits]`. +/// 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 `[module.resources]` over engine `[limits]`; unset fields keep the -/// default. +/// 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()), @@ -166,62 +198,83 @@ struct LoadedModule { name: String, bindings: EventModule, store: HostStore, - /// The run this store instantiates; restarts mint a fresh `RunId` with - /// an incremented sequence. + /// 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`, read on every event to - /// decide dispatch. + /// 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). Fuel bounds only guest instructions; this is the backstop - /// for a dispatch parked in a host call (see [`crate::runtime::limits`]). + /// 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 store on reinstantiation. + /// Memory cap applied to the wasmtime store on reinstantiation. memory_limit: usize, - /// Local-store byte quota applied on reinstantiation. + /// Local-store byte quota applied to the module store on reinstantiation. local_store_bytes: u64, - /// Cached for restart; `Component` is internally `Arc`-backed. + /// 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 `[config]` passed to `init`. + /// 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 rebuilt `HostState`. + /// Cached for restart: HTTP allowlist baked into the + /// `HostState` we rebuild on each re-instantiation. http_allowlist: Vec, - /// Cached for restart: outbound HTTP limits. + /// 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. + /// Cached for restart: chain response size cap baked into the + /// `HostState` we rebuild on each re-instantiation. chain_response_max_bytes: usize, - /// Set `false` when `on_event` traps; excluded from dispatch until - /// `next_attempt` passes. An init-failed module has `alive = false` + - /// `next_attempt = None`, so it never returns. + /// 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, - /// Consecutive trap failures since the last success; resets to 0 on - /// success. Drives the backoff via `restart_policy::backoff_for`. + /// 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 the supervisor may retry after a trap. `None` for - /// healthy modules and for init-failed modules (never rescheduled). + /// 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 trap timestamps for the poison-pill check; entries - /// older than `PoisonPolicy.window` drop on push. + /// 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 restarts, no - /// dispatches. Recovery requires removing it from `[[modules]]` and - /// restarting the engine. + /// 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; over-rate events are dropped and counted. + /// 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. +/// 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. + /// The provider's namespace: its manifest name, and the id its kind + /// installs it under. name: String, - /// Registered kind the restart sweep reinstalls through. + /// Registered kind spelling the restart sweep reinstalls through. kind: &'static str, - /// Extension-owned manifest sections. + /// Extension-owned manifest sections, as the worker install + /// predicates see them. sections: manifest::ExtensionSections, /// Cached for restart, like a module's. component: Component, @@ -230,7 +283,7 @@ struct LoadedProvider { /// Cached for restart: the operator's transport grants. http_allow: Vec, messaging_topics: Vec, - /// Cached for restart. + /// Cached for restart: the engine `[limits]` applied at boot. http_limits: OutboundHttpLimits, fuel_per_call: u64, memory_limit: usize, @@ -240,9 +293,9 @@ struct LoadedProvider { liveness: Liveness, /// Sequence of the run currently installed; restarts increment it. run_seq: u64, - /// The sweep's view of `liveness`: `true` against a dead liveness is - /// an unrecorded trap. Init failure leaves it `false` with - /// `next_attempt = None`, permanent. + /// 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, @@ -280,7 +333,8 @@ fn provider_kinds( Ok(kinds) } -/// Union of subscription kinds the wired extensions declare. +/// 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> { @@ -290,7 +344,9 @@ fn extension_subscription_vocabulary( .collect() } -/// Refuse a manifest section no wired extension claims. +/// 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, @@ -309,33 +365,6 @@ fn enforce_extension_sections( Ok(()) } -/// Refuse a name two wired extensions both claim (service namespace, -/// subscription kind, or manifest section), fail-fast at boot. -fn enforce_extension_uniqueness( - extensions: &[Arc>], -) -> Result<()> { - let mut namespaces = BTreeSet::new(); - let mut kinds = BTreeSet::new(); - let mut sections = BTreeSet::new(); - for ext in extensions { - let namespace = ext.namespace(); - if !namespaces.insert(namespace) { - return Err(anyhow!("extension namespace {namespace} is claimed twice")); - } - for kind in ext.subscriptions() { - if !kinds.insert(*kind) { - return Err(anyhow!("subscription kind {kind} is claimed twice")); - } - } - for section in ext.manifest_sections() { - if !sections.insert(*section) { - return Err(anyhow!("manifest section [{section}] is claimed twice")); - } - } - } - Ok(()) -} - /// Insert one kind row, refusing a duplicate manifest spelling. fn register_kind( kinds: &mut ProviderKinds, @@ -355,8 +384,9 @@ fn registered_kinds(kinds: &ProviderKinds) -> String { } impl Supervisor { - /// Compile and instantiate every module and provider in `engine_cfg`. - /// The `Engine` and `Linker` are passed in. + /// 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>, @@ -365,7 +395,6 @@ impl Supervisor { extensions: &[Arc>], clocks: Option, ) -> Result { - enforce_extension_uniqueness(extensions)?; let registry = capability_registry(extensions); let services = HostServices::from_extensions(extensions)?; // Provider kinds the boot loop resolves manifest kinds against. @@ -443,8 +472,10 @@ impl Supervisor { }) } - /// Construct from a single `(component, manifest)` pair, for `just run` - /// without an `engine.toml`. + /// 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)] @@ -458,7 +489,6 @@ impl Supervisor { extensions: &[Arc>], clocks: Option, ) -> Result { - enforce_extension_uniqueness(extensions)?; let registry = capability_registry(extensions); let services = HostServices::from_extensions(extensions)?; let entry = ModuleEntry { @@ -496,8 +526,9 @@ impl Supervisor { } /// Build a fresh wasmtime `Store` wired to the shared backends, with - /// the per-run namespace, allowlist, memory cap, and fuel applied. Each - /// call takes a freshly minted [`RunId`]. + /// 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)] @@ -571,6 +602,7 @@ impl Supervisor { chain: components.chain.clone(), chain_response_max_bytes, store: module_store, + remote: components.remote.clone(), services, }, ); @@ -681,7 +713,7 @@ impl Supervisor { loaded_manifest.config.clone() }; // Whether `init` returned `Ok(())`. When `init` returns - // `Err(fault)` the module's state (e.g. an + // `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 @@ -768,11 +800,12 @@ impl Supervisor { }) } - /// Load one `[[adapters]]` entry: resolve its manifest and kind, - /// enforce the scoped-transport capabilities, build a supervised store - /// with the operator's grants, and hand the instance to its kind to - /// install. A failed `init` loads the provider dead and unroutable, - /// permanently. + /// 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)] @@ -948,7 +981,8 @@ impl Supervisor { self.providers.len() } - /// Number of adapters currently alive and routable. + /// 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 @@ -957,9 +991,12 @@ impl Supervisor { .count() } - /// Chains any alive module subscribes to block events on. Dead modules - /// are excluded so no live subscription opens for an unreachable chain. - /// Sorted by numeric id and deduped. + /// 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) { @@ -974,9 +1011,14 @@ impl Supervisor { out } - /// Per-module chain-log subscriptions for alive modules only. Each - /// entry names the module, chain, and filter the event loop opens; the - /// stream tags every log with `module_name` for routing. + /// 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) { @@ -1028,8 +1070,8 @@ impl Supervisor { out } - /// Read the persisted resume cursor, or `None` when absent or - /// unreadable (both start at head). + /// 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()??; @@ -1037,11 +1079,20 @@ impl Supervisor { Some(u64::from_le_bytes(arr)) } - /// Rebuild a trapped module from its cached `Component` and - /// `init_config` on a fresh `Store` (the trapped instance is poisoned) - /// and re-run `init`, preserving name and subscriptions. On success the - /// caller flips `alive`; on failure the module stays dead and its - /// failure count keeps climbing. + /// 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 @@ -1180,9 +1231,10 @@ impl Supervisor { dispatched } - /// Dispatch a chain-log event to the module that opened the - /// subscription. Returns `true` when accepted; `false` when the module - /// is dead, missing, or its callback failed. A trap marks it dead. + /// 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, @@ -1262,9 +1314,11 @@ impl Supervisor { ok } - /// Dispatch one extension event to every module whose subscription kind - /// and filters match. Returns the number invoked. Like `dispatch_block`: - /// dead modules past backoff restart first, poisoned modules skip. + /// 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()) @@ -1309,8 +1363,10 @@ impl Supervisor { dispatched } - /// Extension subscription kinds at least one loaded module declares. An - /// extension opens an event source only when its kind appears here. + /// 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() @@ -1322,15 +1378,18 @@ impl Supervisor { .collect() } - /// The extension-owned services, shared by every module store. + /// 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: refuel, call `on_event`, handle the - /// three outcomes (ok / fault / trap) with the same telemetry and - /// lifecycle bookkeeping. Returns whether the guest call succeeded. - /// `chain_id` is telemetry only; chain-less kinds pass 0. + /// 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, @@ -1481,8 +1540,10 @@ impl Supervisor { } } - /// Re-instantiate a dead module in place. On success mark it `alive`; - /// on failure bump the counter and slide `next_attempt` per the backoff. + /// 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; @@ -1515,9 +1576,10 @@ impl Supervisor { } } - /// Fold providers into recovery: record any trap the shared liveness - /// reports (backoff plus poison), then reinstall dead, unpoisoned - /// providers past their backoff. Runs at the head of every dispatch. + /// 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; @@ -1571,9 +1633,10 @@ impl Supervisor { } } - /// Reinstall a dead provider in place (fresh store, instance, `init`, - /// re-install). On success revive the shared liveness; on failure slide - /// the backoff. + /// 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; @@ -1601,8 +1664,8 @@ impl Supervisor { } } - /// Rebuild a provider from its cached component and grants and reinstall - /// it over the dead slot. + /// 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 @@ -1641,22 +1704,25 @@ impl Supervisor { .await } - /// Modules currently alive. Not alive when `init` returned `Err` - /// (permanent) or a trap's backoff has not elapsed. + /// 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 an init-failed module declared subscriptions. Lets the - /// launch path tell "no subscriptions declared" (benign) from "every - /// declared subscription belongs to a dead module" (operator error). + /// 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()) } - /// Modules currently poisoned. + /// 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() @@ -1664,10 +1730,13 @@ impl Supervisor { } /// Build a `Linker` binding the core `event-module` interfaces plus every -/// extension's interfaces. Shared by the restart and launch paths. A module -/// importing an extension interface instantiates only if that extension's -/// hook is present, so the same `extensions` slice must drive this and -/// capability enforcement. +/// 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>], @@ -1684,11 +1753,14 @@ pub fn build_linker( Ok(linker) } -/// Build a `Linker` for one provider kind: the kind's scoped imports plus -/// the WASI base and allowlisted `wasi:http`. Core `nexum:host` interfaces -/// (local-store, remote-store, identity, logging) are withheld, so a -/// provider importing one fails to instantiate. Extensions are not linked -/// into providers. +/// 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, @@ -1700,9 +1772,10 @@ pub fn build_provider_linker( Ok(linker) } -/// Resolve a component's manifest: explicit override, else sibling -/// `module.toml`, else the deprecated `nexum.toml` with a rename warning. -/// `None` when neither exists. +/// 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()); @@ -1728,7 +1801,9 @@ fn resolve_manifest_path(component: &Path, explicit: Option<&Path>) -> Option( extensions: &[Arc>], ) -> CapabilityRegistry { @@ -1739,9 +1814,10 @@ pub(crate) fn capability_registry( registry } -/// A dispatch (guest plus every host call it awaited) outlived its -/// wall-clock deadline and was cancelled. Distinct from a fuel trap, which -/// bounds guest instructions. +/// 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 \ @@ -1749,11 +1825,17 @@ pub(crate) fn capability_registry( )] struct DeadlineExceeded(Duration); -/// Run a guest dispatch future under a wall-clock `deadline`. Fuel bounds -/// only guest instructions, so this bounds time in host calls (see -/// [`crate::runtime::limits`]). Returns `Err(DeadlineExceeded)` once the -/// future outlives `deadline`; dropping it cancels the in-flight host call -/// at its next await point. Pure guest spinning stays fuel's job. +/// 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, @@ -1763,28 +1845,35 @@ async fn with_dispatch_deadline( .map_err(|_elapsed| DeadlineExceeded(deadline)) } -/// Outcome of [`Supervisor::dispatch_to`] for one module. Private; only -/// the `dispatch_*` entry points consume it. +/// 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 / etc). Marked dead, maybe - /// quarantined per the poison policy. + /// 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; the module stays alive, this - /// event is skipped. + /// `set_fuel` failed before the call. Module is left alive but + /// this event is skipped. Skipped, - /// Per-module dispatch rate limit exceeded; the event is dropped before - /// the guest runs, liveness untouched. + /// 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 window, and report the recent count once it -/// crosses `policy.max_failures`. +/// 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, @@ -1802,8 +1891,9 @@ fn poison_crossed( crate::runtime::poison_policy::should_poison(policy, recent).then_some(recent) } -/// Flip `poisoned` once the module's failure window crosses the threshold; -/// the first transition emits the gauge and a WARN. +/// 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, @@ -1847,9 +1937,10 @@ fn progress_key(chain: Chain) -> String { format!("last_dispatched_block:{}", chain.id()) } -/// A resolved chain-log subscription for the event loop: owning module, -/// chain, alloy `Filter`, and, when `resume` is set, the durable cursor key -/// and resume block. +/// 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, @@ -1857,21 +1948,23 @@ pub struct ChainLogSub { pub chain: Chain, /// Alloy filter the poller opens with. pub filter: alloy_rpc_types_eth::Filter, - /// `Some` iff `resume = true`: the store key the resume cursor lives - /// under. + /// `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` otherwise. + /// subscription; `None` on first run or when `resume` is off. pub initial_cursor: Option, - /// Opt-in cap on backfill depth, in blocks. `None` backfills the whole - /// gap; `Some(cap)` bounds the start to `head - cap`. + /// 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 -/// normalized manifest inputs, not the alloy `Filter` (whose hash is -/// process-randomized), so it is stable across restarts and subscription -/// ordering. +/// 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>, @@ -1890,8 +1983,10 @@ fn chainlog_cursor_key( } impl From<&alloy_rpc_types_eth::Log> for nexum::host::types::ChainLog { - /// Project an alloy `Log` onto the WIT `chain-log` record without loss. - /// The chain id is not on the alloy log; the batch level supplies it. + /// 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(), @@ -1909,6 +2004,15 @@ impl From<&alloy_rpc_types_eth::Log> for nexum::host::types::ChainLog { } /// Errors surfaced by [`build_alloy_filter`]. +/// +/// Variants thread the underlying alloy parse error via `#[source]` +/// instead of `to_string()`-ing it - keeps the typed chain intact for +/// the supervisor's `tracing::warn!(error = %err, ...)` log line at +/// the call site (where the `Display` chain prints the parse detail). +/// +/// `IntoStaticStr` exposes the snake_case variant name as a +/// `&'static str` so the warn log can carry +/// `error_kind = address | topic` without a match-ladder. #[derive(Debug, thiserror::Error, strum::IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index a16ae71..d5dccb8 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -63,68 +63,6 @@ fn extension_sections_must_be_claimed() { assert!(err.to_string().contains("keeper"), "{err}"); } -/// Two extensions colliding on a subscription kind or a manifest section -/// are refused at boot; a non-colliding set passes the uniqueness pass. -#[test] -fn extension_claims_must_be_unique() { - struct Claiming { - namespace: &'static str, - subscriptions: &'static [&'static str], - sections: &'static [&'static str], - } - impl Extension for Claiming { - fn namespace(&self) -> &'static str { - self.namespace - } - fn capabilities(&self) -> crate::manifest::NamespaceCaps { - crate::manifest::NamespaceCaps { - prefix: "acme:ext/", - ifaces: &[], - } - } - fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { - Ok(()) - } - fn subscriptions(&self) -> &'static [&'static str] { - self.subscriptions - } - fn manifest_sections(&self) -> &'static [&'static str] { - self.sections - } - } - fn ext( - namespace: &'static str, - subscriptions: &'static [&'static str], - sections: &'static [&'static str], - ) -> Arc> { - Arc::new(Claiming { - namespace, - subscriptions, - sections, - }) - } - - enforce_extension_uniqueness(&[ - ext("a", &["orders"], &["venue"]), - ext("b", &["fills"], &["pool"]), - ]) - .expect("non-colliding set boots"); - - let err = enforce_extension_uniqueness(&[ - ext("a", &["orders"], &["venue"]), - ext("b", &["orders"], &["pool"]), - ]) - .expect_err("duplicate subscription kind"); - assert!(err.to_string().contains("orders"), "{err}"); - - let err = enforce_extension_uniqueness(&[ - ext("a", &["orders"], &["venue"]), - ext("b", &["fills"], &["venue"]), - ]) - .expect_err("duplicate manifest section"); - assert!(err.to_string().contains("[venue]"), "{err}"); -} - #[tokio::test] async fn empty_supervisor_returns_no_subscriptions() { let engine = make_wasmtime_engine(); @@ -135,16 +73,27 @@ async fn empty_supervisor_returns_no_subscriptions() { } /// Data-compat guard: the persisted progress marker keys on the numeric -/// chain id, so a named chain still yields `last_dispatched_block:11155111`. +/// 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"); } -/// An engine whose modules declare only `kind = "block"` (or only -/// `kind = "chain-log"`) must not bail at boot when the other stream set -/// is empty. +/// 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}; @@ -180,9 +129,12 @@ async fn run_does_not_bail_when_both_stream_kinds_are_empty() { // Verify the stream-open + run() + shutdown lifecycle end to end at the // supervisor boundary, without loading a real wasm module. -/// The `biased` select drains both block and chain-log streams within one -/// `run()` session without starving either; the returned tally shows both -/// were consumed. +/// 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; @@ -242,8 +194,14 @@ async fn run_delivers_block_and_chain_log_events_without_starvation() { ); } -/// On the shutdown path `run()` aborts and joins every reconnect task, so -/// none detaches and outlives the engine. +/// 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; @@ -290,24 +248,28 @@ async fn run_drains_reconnect_tasks_cleanly_on_shutdown() { // ── E2E helpers ─────────────────────────────────────────────────────── -/// Workspace root: the topmost ancestor with a `Cargo.toml`. -fn workspace_root() -> PathBuf { - let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); - manifest - .ancestors() - .filter(|d| d.join("Cargo.toml").is_file()) - .last() - .unwrap_or(manifest) - .to_path_buf() -} - -/// Path to the pre-built example WASM component. +/// 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 { - workspace_root().join("target/wasm32-wasip2/release/example.wasm") + // 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 { - workspace_root().join("nexum/modules/example/module.toml") + 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. @@ -331,7 +293,8 @@ fn make_wasmtime_engine() -> wasmtime::Engine { wasmtime::Engine::new(&config).expect("wasmtime engine") } -/// The core-only extension set: no domain extensions. +/// 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() } @@ -348,11 +311,14 @@ fn test_components(store: crate::host::local_store_redb::LocalStore) -> Componen 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` and cleans it up -/// on drop. +/// 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"); @@ -361,7 +327,8 @@ fn temp_local_store() -> (tempfile::TempDir, crate::host::local_store_redb::Loca } /// Boot a zero-module supervisor over the in-process mock backends via the -/// real `boot` path. +/// 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 { @@ -405,8 +372,10 @@ async fn e2e_supervisor_boots_example_module() { assert_eq!(supervisor.alive_count(), 1); } -/// The example component's capability-bearing imports are exactly what its -/// manifest declares (`logging`). +/// 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 { @@ -498,8 +467,9 @@ chain_id = 1 } /// A `ManualClock` override threads through `boot_single` onto the module -/// store and is behaviour-neutral: the module boots, dispatches a block, -/// and stays alive as on the ambient clock. +/// 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() { @@ -573,17 +543,26 @@ chain_id = 1 // // One test per module that goes through the real wit-bindgen + // WitBindgenHost adapter + supervisor dispatch path, not just the -// module-level MockHost coverage. Mirrors the example-module e2e +// 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; -/// A production module's built `.wasm`; hyphens in the name become underscores. +/// 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('-', "_"); - workspace_root().join(format!("target/wasm32-wasip2/release/{artifact}.wasm")) + 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 { @@ -607,9 +586,17 @@ fn module_wasm_or_skip(module_name: &str) -> Option { } } -/// Resolve the real `module.toml` for one of the production modules. +/// 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 { - workspace_root().join(relative_path) + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join(relative_path) } fn synthetic_sepolia_block() -> nexum::host::types::Block { @@ -622,7 +609,7 @@ fn synthetic_sepolia_block() -> nexum::host::types::Block { } /// Boot a single module from `(wasm, manifest)` and return the live -/// supervisor. +/// supervisor. Shared body across the 5 integration tests. async fn boot_production_module( engine: &wasmtime::Engine, linker: &Linker>, @@ -651,7 +638,7 @@ async fn e2e_price_alert_block_dispatch() { let Some(wasm) = module_wasm_or_skip("price-alert") else { return; }; - let manifest = production_module_toml("nexum/modules/examples/price-alert/module.toml"); + 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(); @@ -667,7 +654,7 @@ async fn e2e_balance_tracker_block_dispatch() { let Some(wasm) = module_wasm_or_skip("balance-tracker") else { return; }; - let manifest = production_module_toml("nexum/modules/examples/balance-tracker/module.toml"); + 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(); @@ -678,10 +665,13 @@ async fn e2e_balance_tracker_block_dispatch() { assert_eq!(supervisor.alive_count(), 1); } -/// End-to-end wasi:http path: http-probe fetches a loopback server on its -/// allowlist, then an off-list host that the gate denies before any -/// connection. The guest returns `Ok` only when both legs hold, so -/// `dispatched == 1` asserts the allow and deny paths together. +/// 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 { @@ -743,9 +733,15 @@ denied_url = "http://denied.invalid/" // ── Init-failed modules must be marked dead ──────────────── -/// A module whose `[config]` carries a malformed `threshold` fails `init` -/// with `fault.invalid-input`; the supervisor marks it `alive = false` so -/// it receives no dispatches. +/// 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 { @@ -753,7 +749,7 @@ async fn init_failure_marks_module_dead_and_excludes_from_dispatch() { }; // Synthesise a manifest with the same shape as the real - // price-alert module but with a `threshold` that the module + // 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"); @@ -804,8 +800,11 @@ every_n_blocks = "1" ); } -/// An init-failed (dead) module must not contribute its chain to -/// `block_chains()` or `chain_log_subscriptions()`. +/// 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 { @@ -867,7 +866,10 @@ every_n_blocks = "1" } /// Positive control for the alive filter: with one dead and one alive -/// module, the alive module's subscriptions survive the filter. +/// 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 { @@ -935,6 +937,7 @@ chain_id = 1 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, @@ -977,7 +980,8 @@ chain_id = 1 // host-call time fuel cannot meter. /// `with_dispatch_deadline` cancels rather than awaits an over-long future: -/// a sleep far past the deadline is dropped, not run. +/// 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; @@ -1043,10 +1047,10 @@ fn event_deadline_resolves_override_default_and_floor() { } /// A guest suspended inside a host call is cut off by the wall-clock -/// deadline and the module marked dead, then a later dispatch reinstantiates -/// it on a fresh store. The `slow-host` fixture parks its first -/// `chain::request` an hour past a 1s deadline, one-shot, so it recovers -/// after the backoff. +/// 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; @@ -1072,7 +1076,7 @@ async fn dispatch_deadline_cuts_off_a_blocked_host_call_and_recovers() { let components = crate::test_utils::mock_components_from(chain, crate::test_utils::MockStateStore::new()); - let manifest = fixture_module_toml("nexum/modules/fixtures/slow-host/module.toml"); + 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. @@ -1153,10 +1157,16 @@ async fn dispatch_deadline_cuts_off_a_blocked_host_call_and_recovers() { // changes to the supervisor cannot silently bypass the limits. fn fixture_module_toml(relative_path: &str) -> PathBuf { - workspace_root().join(relative_path) + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join(relative_path) } -/// Boot a single fixture (`.wasm` + `module.toml`) under the supervisor. +/// 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); @@ -1183,7 +1193,7 @@ async fn resource_limit_fuel_bomb_traps_and_marks_module_dead() { let Some(wasm) = module_wasm_or_skip("fuel-bomb") else { return; }; - let mut supervisor = boot_fixture(&wasm, "nexum/modules/fixtures/fuel-bomb/module.toml").await; + 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"); @@ -1267,11 +1277,12 @@ chain_id = 1 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( - "nexum/modules/fixtures/fuel-bomb/module.toml", + "modules/fixtures/fuel-bomb/module.toml", )), }, crate::engine_config::ModuleEntry { @@ -1324,8 +1335,7 @@ 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, "nexum/modules/fixtures/memory-bomb/module.toml").await; + 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); @@ -1469,7 +1479,7 @@ async fn poison_pill_quarantines_module_after_threshold() { let Some(wasm) = module_wasm_or_skip("fuel-bomb") else { return; }; - let manifest = production_module_toml("nexum/modules/fixtures/fuel-bomb/module.toml"); + 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(); @@ -1564,13 +1574,17 @@ fn components_with_logs( store, ext: (), logs: logs.clone(), + remote: crate::host::remote_store_bee::RemoteStore::disabled(), }; (components, logs) } -/// The example module logs via the host logging glue at init and on the -/// block, so its run holds retrievable HostInterface records after one -/// dispatch. Driven through the [`TestRuntime`] harness. +/// 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 { @@ -1637,7 +1651,7 @@ async fn dying_run_leaves_a_panic_record() { let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); let (components, logs) = components_with_logs(store); - let manifest = fixture_module_toml("nexum/modules/fixtures/fuel-bomb/module.toml"); + let manifest = fixture_module_toml("modules/fixtures/fuel-bomb/module.toml"); let limits = ModuleLimits::default(); let mut supervisor = Supervisor::boot_single( &engine, @@ -1692,7 +1706,7 @@ async fn facade_panic_leaves_stderr_host_interface_and_panic_records() { let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); let (components, logs) = components_with_logs(store); - let manifest = fixture_module_toml("nexum/modules/fixtures/panic-bomb/module.toml"); + let manifest = fixture_module_toml("modules/fixtures/panic-bomb/module.toml"); let limits = ModuleLimits::default(); let mut supervisor = Supervisor::boot_single( &engine, @@ -1812,6 +1826,7 @@ chain_id = 100 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(), @@ -1862,10 +1877,12 @@ chain_id = 100 assert_eq!(supervisor.alive_count(), 2); } -/// Per-module dispatch rate limit: a source flooding one module is -/// throttled (over-rate events dropped) while a second module on another -/// chain still gets every dispatch. A tiny `[limits.dispatch]` (burst = 2, -/// refill = 1/s) drains the first bucket almost immediately. +/// 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 { @@ -1927,6 +1944,7 @@ chain_id = 100 }, chains: std::collections::HashMap::new(), extensions: std::collections::HashMap::new(), + remote_store: None, modules: vec![ crate::engine_config::ModuleEntry { path: wasm.clone(), @@ -2056,11 +2074,12 @@ chain_id = 100 }, 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( - "nexum/modules/fixtures/fuel-bomb/module.toml", + "modules/fixtures/fuel-bomb/module.toml", )), }, crate::engine_config::ModuleEntry { @@ -2290,7 +2309,8 @@ fn chainlog_cursor_key_differs_by_each_input() { // ── provider boot gating ────────────────────────────────────────────── /// A stub extension registering the `acme-adapter` provider kind behind a -/// unit service, for the boot-gate tests. +/// 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 {} @@ -2353,8 +2373,9 @@ fn acme_extensions() -> Vec>> { vec![Arc::new(AcmeExtension)] } -/// An `[[adapters]]` entry whose manifest is (or defaults to) an -/// event-module is rejected before instantiation, naming the registered +/// 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() { @@ -2434,7 +2455,9 @@ async fn boot_rejects_an_unregistered_provider_kind() { } /// A registered kind clears the discriminator; boot then reaches the -/// compile step and fails only because the referenced wasm is absent. +/// 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(); diff --git a/crates/nexum-runtime/src/test_utils/builders.rs b/crates/nexum-runtime/src/test_utils/builders.rs index d173ce4..529f229 100644 --- a/crates/nexum-runtime/src/test_utils/builders.rs +++ b/crates/nexum-runtime/src/test_utils/builders.rs @@ -1,9 +1,13 @@ -//! Pass-through [`ComponentBuilder`] wrapping a pre-built backend. +//! 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 to compose it through the public builder. +/// 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 { diff --git a/crates/nexum-runtime/src/test_utils/chain.rs b/crates/nexum-runtime/src/test_utils/chain.rs index 78a9444..a1deeea 100644 --- a/crates/nexum-runtime/src/test_utils/chain.rs +++ b/crates/nexum-runtime/src/test_utils/chain.rs @@ -28,10 +28,18 @@ pub struct RecordedRequest { type BlockItem = Result; type LogItem = Result; -/// One subscription kind's channel pair; the receiver is taken by the first -/// subscribe. A second subscribe before any [`close`](Self::close) parks on -/// a pending stream. [`close`](Self::close) ends the open stream and re-arms -/// the slot, so the next subscribe (the reconnect path) resumes delivery. +/// 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>, @@ -85,14 +93,21 @@ struct Inner { /// Mock chain backend. Program `request` responses with [`on_method`] / /// [`on_request`], drive subscriptions with [`push_block`] / -/// [`push_chain_log`] (and the `_err` / `close_*` variants), and read -/// dispatched calls with [`recorded_requests`]. Cheap `Arc` clone shares one -/// backing state, so a test keeps a clone to program and assert. +/// [`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 { @@ -143,8 +158,8 @@ impl MockChainProvider { self } - /// Deliver a block header to the open block subscription; items sent with - /// no open subscription buffer and drain into the next. + /// 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)); } @@ -154,7 +169,9 @@ impl MockChainProvider { self.lock().logs.send(Ok(log)); } - /// Deliver an error item to the open block subscription. + /// 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)); } @@ -164,9 +181,11 @@ impl MockChainProvider { self.lock().logs.send(Err(err)); } - /// End the block subscription (modelling a dropped connection): buffered - /// items drain, the stream terminates, and the slot re-arms so a later - /// `subscribe_blocks` resumes delivery of subsequently pushed items. + /// 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(); } @@ -177,9 +196,11 @@ impl MockChainProvider { self.lock().logs.close(); } - /// Park the next [`ChainProvider::request`] for `delay`. One-shot, - /// consumed when the request begins, so a caller that drops the request - /// future mid-park leaves the following request prompt. + /// 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 diff --git a/crates/nexum-runtime/src/test_utils/clock.rs b/crates/nexum-runtime/src/test_utils/clock.rs index 440efb1..fcda491 100644 --- a/crates/nexum-runtime/src/test_utils/clock.rs +++ b/crates/nexum-runtime/src/test_utils/clock.rs @@ -7,11 +7,13 @@ 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 a store's -/// [`WasiClockOverride`], drive the other from the test. [`set`](Self::set) -/// pins wall time; [`advance`](Self::advance) moves wall and monotonic -/// together. Guest-visible only. +/// 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>, @@ -42,8 +44,10 @@ impl ManualClock { } } - /// Pin wall time to `time` (clamped to the Unix epoch), leaving monotonic - /// untouched; a `set` after an `advance` can put wall behind monotonic. + /// 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; @@ -57,9 +61,12 @@ impl ManualClock { state.monotonic = state.monotonic.saturating_add(nanos); } - /// A [`WasiClockOverride`] over this clock for both sources. Both `Arc`s - /// share this clock's inner state; a fresh `ManualClock::new()` would - /// split it and break the override. + /// 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())) } diff --git a/crates/nexum-runtime/src/test_utils/harness.rs b/crates/nexum-runtime/src/test_utils/harness.rs index 3dc1d02..41eb760 100644 --- a/crates/nexum-runtime/src/test_utils/harness.rs +++ b/crates/nexum-runtime/src/test_utils/harness.rs @@ -1,14 +1,25 @@ //! In-process test harness: launch one module over the mock assembly and -//! drive it from a test. +//! drive it from a test, with no supervisor ceremony. //! -//! [`TestRuntime`] wraps the public builder path over [`MockTypes`] with a -//! manually-driven [`ManualClock`]. Program the mocks and read effects -//! through [`chain`](TestRuntime::chain), [`clock`](TestRuntime::clock), -//! [`store`](TestRuntime::store) and [`logs`](TestRuntime::logs). Events -//! dispatch on the spawned event-loop task, so -//! [`wait_for_log`](TestRuntime::wait_for_log) polls for an observable -//! effect. Bind an extension payload through -//! [`builder_with_ext`](TestRuntime::builder_with_ext). +//! [`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; @@ -34,7 +45,9 @@ enum ManifestSource { Inline(String), } -/// Builder for a [`TestRuntime`]; the launched handle shares the same mock +/// 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 @@ -58,9 +71,9 @@ impl TestRuntime<()> { } impl TestRuntime { - /// Start a harness binding `ext` as the extension payload; pair with - /// [`extension`](TestRuntimeBuilder::extension) to register its linker - /// hook and capability namespace. + /// 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(), @@ -103,19 +116,21 @@ impl TestRuntimeBuilder { self } - /// Replace the `[limits]` the launch resolves; defaults to the + /// 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; the launched handle shares this instance. + /// 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. + /// The mock state store. The launched handle shares this instance. pub fn store(&self) -> &MockStateStore { &self.store } @@ -125,7 +140,8 @@ impl TestRuntimeBuilder { &self.clock } - /// Open the module and start the runtime through the public builder path. + /// 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. @@ -170,8 +186,9 @@ impl TestRuntimeBuilder { } } -/// A launched in-process runtime over the mock assembly; dropping it fires -/// the shutdown trigger. +/// 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, @@ -184,7 +201,9 @@ pub struct TestRuntime { } impl TestRuntime { - /// The mock chain backend. + /// 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 } @@ -204,7 +223,7 @@ impl TestRuntime { &self.ext } - /// The shared log pipeline. + /// The shared log pipeline: read runs and log pages here. pub fn logs(&self) -> &LogPipeline { self.handle.logs() } @@ -219,9 +238,12 @@ impl TestRuntime { self.chain.push_chain_log(log); } - /// Await a `module` log record whose message contains `needle`. - /// Notification-driven, so it resolves as soon as the dispatched event's - /// record lands; the 5s bound is a failure backstop. + /// 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(); @@ -269,16 +291,16 @@ mod tests { use crate::host::extension::Extension; use crate::manifest::NamespaceCaps; - /// The pre-built module wasm named `file`, or `None` with a skip note. + /// 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 { - // Workspace root: the topmost ancestor with a `Cargo.toml`. - let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); - let root = manifest - .ancestors() - .filter(|d| d.join("Cargo.toml").is_file()) - .last() - .unwrap_or(manifest); - let wasm = root.join("target/wasm32-wasip2/release").join(file); + 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 { @@ -290,7 +312,8 @@ mod tests { } } - /// The pre-built example module, or `None` with a skip note. + /// 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") } @@ -330,15 +353,18 @@ chain_id = {chain_id} ) } - /// A header carrying just the block number. + /// 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: launch the example module from an inline manifest, inject - /// a block header, and read the module's log line back. + /// 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 { @@ -366,8 +392,10 @@ chain_id = {chain_id} rt.wait().await.expect("clean shutdown"); } - /// End-to-end on the chain-log leg: launch with a `chain-log` - /// subscription, inject a log, and read the module's log line back. + /// 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 { @@ -389,9 +417,10 @@ chain_id = {chain_id} rt.wait().await.expect("clean shutdown"); } - /// The extension slot threads through the harness: a trivial extension - /// and an ext payload compose, the module dispatches, and the harness - /// hands the payload back. + /// 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 { @@ -448,8 +477,9 @@ chain_id = {chain_id} rt.wait().await.expect("clean shutdown"); } - /// [`TestRuntimeBuilder::limits`] reaches the launch: a one-byte log ring - /// keeps only the newest record, evicting the init line. + /// [`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; @@ -490,9 +520,11 @@ chain_id = {chain_id} rt.wait().await.expect("clean shutdown"); } - /// End to end on the chain-request leg: program the mock `eth_call`, - /// launch price-alert, inject a block, and read its alert line back; the - /// programmed answer is above threshold, so the module logs TRIGGERED. + /// 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; @@ -562,8 +594,9 @@ direction = "above" rt.wait().await.expect("clean shutdown"); } - /// Both block and chain-log events dispatch in one session: the `biased` - /// select in `run()` delivers both kinds without starvation. + /// 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 { @@ -609,9 +642,10 @@ chain_id = 1 rt.wait().await.expect("clean shutdown"); } - /// Blocks pushed in order arrive in the same order; the stream, select, - /// and dispatch path preserve delivery order, asserted on the module's - /// own log records. + /// 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 { @@ -655,9 +689,13 @@ chain_id = 1 rt.wait().await.expect("clean shutdown"); } - /// Shutdown never destroys completed work: a picked-up block finishes its - /// wasmtime call and its log record survives `wait()`. Proven by - /// re-reading the record after full teardown. + /// 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 { @@ -692,8 +730,11 @@ chain_id = 1 } /// `[limits.chain].response_body_max_bytes` is enforced on the real - /// `chain::request` path: an over-cap response is rejected before the - /// guest copy, and the module observes the typed `invalid-input` fault. + /// `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; @@ -776,9 +817,10 @@ direction = "above" rt.wait().await.expect("clean shutdown"); } - /// A dropped block stream is not the end of dispatch: the reconnect task - /// reopens the subscription after backoff and the re-armed mock resumes - /// delivery. + /// 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 { @@ -806,9 +848,12 @@ direction = "above" rt.wait().await.expect("clean shutdown"); } - /// The guest observes the `WasiClockOverride`: pin the harness clock, - /// dispatch a block, and check the clock-reader fixture logs the pinned - /// wall time, not the ambient host clock. + /// 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}; diff --git a/crates/nexum-runtime/src/test_utils/mod.rs b/crates/nexum-runtime/src/test_utils/mod.rs index 073a614..3f075b5 100644 --- a/crates/nexum-runtime/src/test_utils/mod.rs +++ b/crates/nexum-runtime/src/test_utils/mod.rs @@ -1,10 +1,11 @@ -//! Engine-side mock backends for an in-process runtime on fakes. +//! Engine-side mock backends for launching an in-process runtime entirely on +//! fakes. //! //! [`MockChainProvider`] and [`MockStateStore`] implement the component seam -//! traits with no network or disk; [`Prebuilt`] wraps a pre-built instance -//! as a [`ComponentBuilder`](crate::host::component::ComponentBuilder); -//! [`MockTypes`] is the lattice tying them together. Compose through the -//! public builder path: +//! 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; @@ -27,6 +28,9 @@ //! # Ok(()) //! # } //! ``` +//! +//! The caller keeps its own clones of `chain` / `store` to program responses +//! and assert on what a module wrote. mod builders; mod chain; @@ -45,7 +49,8 @@ use crate::engine_config::ModuleLimits; use crate::host::component::Components; use crate::host::logs::LogPipeline; -/// A fresh in-memory [`LogPipeline`] at default retention limits. +/// 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()) } @@ -57,7 +62,8 @@ pub fn mock_components() -> Components { } /// A [`Components`] bundle over the given mock backends, with an empty -/// extension slot and an in-memory log pipeline. +/// 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, @@ -67,6 +73,7 @@ pub fn mock_components_from( store, ext: (), logs: in_memory_logs(), + remote: crate::host::remote_store_bee::RemoteStore::disabled(), } } @@ -83,9 +90,11 @@ mod tests { }; use crate::host::provider_pool::ProviderError; - /// A custom component set launches through the public builder on fakes; - /// it bails at boot only because the default config declares no modules, - /// proving the mock backends composed and the build path ran. + /// 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(); @@ -173,7 +182,8 @@ mod tests { assert!(item.is_ok(), "pushed header arrives as Ok"); } - /// A scripted error surfaces as `Err`, and closing terminates the stream. + /// 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(); @@ -197,8 +207,9 @@ mod tests { ); } - /// After a close, a fresh `subscribe_blocks` (the reconnect path) yields - /// the items pushed since, keeping the drop-then-reconnect contract. + /// 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(); @@ -216,8 +227,9 @@ mod tests { assert!(item.is_ok(), "pushed header arrives on the reopened stream"); } - /// The chain-log poller carries scripted errors and terminates on close; - /// each pushed log arrives as a one-log canonical batch. + /// 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(); @@ -255,8 +267,8 @@ mod tests { ); } - /// The store round-trips values, isolates namespaces, lists by prefix, - /// and rejects the empty namespace. + /// The store round-trips values, isolates namespaces, lists by prefix, and + /// rejects the empty namespace. #[test] fn store_roundtrips_and_isolates_namespaces() { let store = MockStateStore::new(); diff --git a/crates/nexum-runtime/src/test_utils/store.rs b/crates/nexum-runtime/src/test_utils/store.rs index 56c7837..7d8be44 100644 --- a/crates/nexum-runtime/src/test_utils/store.rs +++ b/crates/nexum-runtime/src/test_utils/store.rs @@ -7,12 +7,13 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use crate::host::component::{StateHandle, StateStore}; -use crate::host::local_store_redb::{MAX_APPLY_OPS, MAX_APPLY_VALUE_BYTES, StorageError, WriteOp}; +use crate::host::local_store_redb::StorageError; type Namespaces = HashMap>>; -/// 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. +/// 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>, @@ -114,67 +115,4 @@ impl StateHandle for MockStateHandle { keys.sort(); Ok(keys) } - - fn apply(&self, ops: &[WriteOp]) -> Result<(), StorageError> { - if ops.len() > MAX_APPLY_OPS { - return Err(StorageError::ApplyOpsExceeded { - ops: ops.len(), - cap: MAX_APPLY_OPS, - }); - } - let value_bytes: u64 = ops - .iter() - .map(|op| match op { - WriteOp::Set { value, .. } => value.len() as u64, - WriteOp::Delete { .. } => 0, - }) - .sum(); - if value_bytes > MAX_APPLY_VALUE_BYTES { - return Err(StorageError::ApplyBytesExceeded { - bytes: value_bytes, - cap: MAX_APPLY_VALUE_BYTES, - }); - } - let mut map = self.lock(); - let ns = map.entry(self.namespace.clone()).or_default(); - // Net whole-batch projection, checked once before any mutation so - // an over-quota batch lands nothing (the map mirrors one txn). - if let Some(quota) = self.quota_bytes { - let mut finals: HashMap<&str, Option> = HashMap::new(); - for op in ops { - match op { - WriteOp::Set { key, value } => finals.insert(key, Some(value.len())), - WriteOp::Delete { key } => finals.insert(key, None), - }; - } - let used: u64 = ns.iter().map(|(k, v)| (k.len() + v.len()) as u64).sum(); - let mut released = 0u64; - let mut charged = 0u64; - for (key, value_len) in &finals { - released += ns - .get(*key) - .map(|v| (key.len() + v.len()) as u64) - .unwrap_or(0); - charged += value_len.map(|len| (key.len() + len) as u64).unwrap_or(0); - } - let projected = used.saturating_sub(released) + charged; - if projected > quota { - return Err(StorageError::QuotaExceeded { - needed: projected, - quota, - }); - } - } - for op in ops { - match op { - WriteOp::Set { key, value } => { - ns.insert(key.clone(), value.clone()); - } - WriteOp::Delete { key } => { - ns.remove(key); - } - } - } - Ok(()) - } } diff --git a/crates/nexum-runtime/src/test_utils/types.rs b/crates/nexum-runtime/src/test_utils/types.rs index 16b20b5..4180b32 100644 --- a/crates/nexum-runtime/src/test_utils/types.rs +++ b/crates/nexum-runtime/src/test_utils/types.rs @@ -6,8 +6,13 @@ use crate::host::component::RuntimeTypes; use crate::test_utils::{MockChainProvider, MockStateStore}; /// Lattice binding the mock backends. The extension slot is the type -/// parameter `E` (default `()`); an extension crate binds its own payload as -/// `MockTypes`. A type-level marker, only ever named. +/// 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 {} diff --git a/crates/nexum-sdk-test/Cargo.toml b/crates/nexum-sdk-test/Cargo.toml index 8aea33e..0b1fe71 100644 --- a/crates/nexum-sdk-test/Cargo.toml +++ b/crates/nexum-sdk-test/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nexum-sdk-test" -version.workspace = true +version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index 7c637c7..1da359e 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -1,11 +1,61 @@ -//! In-memory [`nexum_sdk::host`] trait implementations plus assertion -//! helpers, so a module can test its logic without wit-bindgen, -//! wasmtime, or a network round-trip. +//! # nexum-sdk-test //! -//! [`MockHost`] composes the six per-seam mocks ([`MockChain`], -//! [`MockIdentity`], [`MockLocalStore`], [`MockRemoteStore`], -//! [`MockMessaging`], [`MockLogging`]); [`capture_tracing`] records -//! emitted `tracing` events. +//! 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)] @@ -28,7 +78,8 @@ use tracing::level_filters::LevelFilter; use tracing::span::{Attributes, Id, Record}; use tracing::{Event, Metadata, Subscriber}; -/// Composed in-memory host; each field is the per-seam mock. +/// 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. @@ -46,7 +97,7 @@ pub struct MockHost { } impl MockHost { - /// Fresh empty host. + /// Fresh empty host. Equivalent to `Default::default`. pub fn new() -> Self { Self::default() } @@ -132,8 +183,10 @@ impl LoggingHost for MockHost { } } -/// In-memory [`ChainHost`] over a `(method, params)` response map; -/// records every call. +// ---------------------------------------------------------------- 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>>, @@ -152,7 +205,8 @@ pub struct ChainCall { } impl MockChain { - /// Program the response for `(method, params)`; overwrites any prior entry. + /// Program a response for the `(method, params)` pair. Overwrites + /// any prior entry. pub fn respond_to( &self, method: impl Into, @@ -199,6 +253,8 @@ impl ChainHost for MockChain { } } +// ---------------------------------------------------------------- identity + /// One recorded [`MockIdentity`] signing invocation. #[derive(Clone, Debug, Eq, PartialEq)] pub struct SignCall { @@ -217,10 +273,11 @@ pub enum SignPayload { TypedData(String), } -/// In-memory [`IdentityHost`] with a programmable roster and one signing -/// outcome; records every call. Off-roster accounts fail -/// [`Fault::Denied`]; with no outcome programmed signing fails -/// [`Fault::Unsupported`]. +/// 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>, @@ -229,7 +286,8 @@ pub struct MockIdentity { } impl MockIdentity { - /// Add an account to the roster. + /// 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); } @@ -283,6 +341,8 @@ impl IdentityHost for MockIdentity { } } +// ---------------------------------------------------------------- messaging + /// One recorded [`MessagingHost::publish`] invocation. #[derive(Clone, Debug, Eq, PartialEq)] pub struct PublishRecord { @@ -292,9 +352,11 @@ pub struct PublishRecord { pub payload: Vec, } -/// In-memory [`MessagingHost`]: seeded messages answer queries, publishes -/// are recorded, an optional scope mirrors the `messaging_topics` grant. -/// Queries answer from seeds, never from what the guest published. +/// 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>, @@ -310,7 +372,7 @@ impl MockMessaging { } /// Seed a payload on `content_topic` at `timestamp` (ms since the - /// Unix epoch, UTC), no sender. + /// Unix epoch, UTC), with no sender. pub fn seed_payload( &self, content_topic: impl Into, @@ -325,16 +387,19 @@ impl MockMessaging { }); } - /// Confine the mock to `topics`, mirroring the `messaging_topics` - /// grant: a topic is admitted if it equals an entry or descends from - /// one as a `/`-bounded prefix, else [`Fault::Denied`]. An empty - /// grant is unscoped. + /// 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`; first registered match fires. + /// `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)); } @@ -371,8 +436,10 @@ impl MockMessaging { } } -/// Grant matching: empty scope admits all; else a topic must equal an -/// entry or descend from one as a `/`-bounded prefix. +/// 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; @@ -398,8 +465,10 @@ impl MessagingHost for MockMessaging { Ok(()) } - /// Exact-topic seeds within the inclusive `start_time..=end_time` - /// window, in seed order; `limit` keeps the newest, the tail. + /// 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, @@ -429,9 +498,15 @@ impl MessagingHost for MockMessaging { } } -/// In-memory [`RemoteStoreHost`]: `keccak256`-addressed blobs plus -/// mutable `(owner, topic)` feeds. Feed writes land under the mock's own -/// owner ([`set_owner`](Self::set_owner), zero by default). +// ---------------------------------------------------------------- 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>>, @@ -446,7 +521,8 @@ impl MockRemoteStore { self.owner.set(owner); } - /// Seed a blob directly; returns its reference. + /// 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); @@ -454,7 +530,8 @@ impl MockRemoteStore { reference } - /// Seed another owner's feed. + /// 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()); } @@ -507,12 +584,28 @@ impl RemoteStoreHost for MockRemoteStore { } } -/// In-memory [`LocalStoreHost`]: namespaced views over one shared row -/// map, with store-wide entry and byte limits. -/// [`namespaced`](Self::namespaced) derives a sibling view over the same -/// rows; identical keys in different namespaces never collide, and limits -/// are shared across namespaces. Every `set` commits immediately, with no -/// transaction rollback on trap. +// ---------------------------------------------------------------- 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, @@ -536,12 +629,14 @@ struct SharedRows { } impl MockLocalStore { - /// A view over the same rows under `namespace`; same-namespace views - /// alias, different namespaces isolate identical keys. + /// 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. + /// On an empty namespace - the runtime rejects those too. pub fn namespaced(&self, namespace: impl Into) -> MockLocalStore { let namespace = namespace.into(); assert!( @@ -570,7 +665,8 @@ impl MockLocalStore { self.len() == 0 } - /// Direct read of this view's namespace, for assertions. + /// Direct read of this view's namespace for assertions - bypasses + /// the trait. pub fn snapshot(&self) -> HashMap> { self.shared .rows @@ -581,20 +677,22 @@ impl MockLocalStore { .collect() } - /// Cap row count across all namespaces; `set` on a new key then - /// fails, overwrites still succeed. + /// 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, all namespaces); an over-cap - /// `set` fails, deletes and overwrites release displaced bytes. + /// 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 whose key starts with `prefix`; - /// first registered match fires. + /// 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() @@ -705,121 +803,7 @@ impl LocalStoreHost for MockLocalStore { } } -/// Trap-injection wrapper over a [`LocalStoreHost`]: counts `set` and -/// `delete` calls and, once armed, simulates a guest trap mid-flow. -/// [`arm_after`](Self::arm_after)`(n)` lets the next `n` writes land; -/// the write after that trips the trap, and from then on every -/// operation - reads included - faults until -/// [`disarm`](Self::disarm), because nothing past a trap executes. -/// Sweeping `n` over a flow's write count drives the store through -/// every torn prefix a trap can strand, so a recovery pass can be -/// held to convergence from each one. -/// -/// Review rule this harness enforces (#609): no in-store invariant -/// may span two `set` calls unless the intermediate state is -/// self-healing or the writes ride the atomic `apply` batch verb. -pub struct TrapStore { - inner: H, - /// Write calls the trap let through. - writes: Cell, - /// Writes still allowed before the trap trips; `None` when unarmed. - remaining: Cell>, - tripped: Cell, -} - -impl TrapStore { - /// Wrap `inner`, unarmed: every operation delegates, writes are - /// counted. - pub fn new(inner: H) -> Self { - Self { - inner, - writes: Cell::new(0), - remaining: Cell::new(None), - tripped: Cell::new(false), - } - } - - /// Arm the trap: the next `n` writes land, the one after trips it. - pub fn arm_after(&self, n: u64) { - self.remaining.set(Some(n)); - self.tripped.set(false); - } - - /// Clear the trap and the tripped state; operations resume. The - /// write count keeps accumulating. - pub fn disarm(&self) { - self.remaining.set(None); - self.tripped.set(false); - } - - /// `set`/`delete` calls the trap let through since construction. - pub fn writes(&self) -> u64 { - self.writes.get() - } - - /// Whether the trap has fired. - pub fn tripped(&self) -> bool { - self.tripped.get() - } - - /// The wrapped store. - pub fn inner(&self) -> &H { - &self.inner - } - - /// Fault unless still executing: past the trap nothing runs. - fn read_gate(&self) -> Result<(), Fault> { - if self.tripped.get() { - return Err(Fault::Internal("TrapStore: trapped".into())); - } - Ok(()) - } - - /// Spend one write from the armed budget, tripping at zero. - fn write_gate(&self) -> Result<(), Fault> { - self.read_gate()?; - if let Some(remaining) = self.remaining.get() { - if remaining == 0 { - self.tripped.set(true); - return Err(Fault::Internal("TrapStore: trapped".into())); - } - self.remaining.set(Some(remaining - 1)); - } - self.writes.set(self.writes.get() + 1); - Ok(()) - } -} - -impl LocalStoreHost for TrapStore { - fn get(&self, key: &str) -> Result>, Fault> { - self.read_gate()?; - self.inner.get(key) - } - fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { - self.write_gate()?; - self.inner.set(key, value) - } - fn delete(&self, key: &str) -> Result<(), Fault> { - self.write_gate()?; - self.inner.delete(key) - } - fn list_keys(&self, prefix: &str) -> Result, Fault> { - self.read_gate()?; - self.inner.list_keys(prefix) - } - fn contains(&self, key: &str) -> Result { - self.read_gate()?; - self.inner.contains(key) - } - fn len(&self, key: &str) -> Result, Fault> { - self.read_gate()?; - self.inner.len(key) - } - fn count(&self, prefix: &str) -> Result { - self.read_gate()?; - self.inner.count(prefix) - } -} +// ---------------------------------------------------------------- logging /// One recorded log line. #[derive(Clone, Debug, Eq, PartialEq)] @@ -869,6 +853,8 @@ impl LoggingHost for MockLogging { } } +// ---------------------------------------------------------------- tracing capture + /// One tracing event captured pre-flattening. #[derive(Clone, Debug, PartialEq)] pub struct CapturedEvent { @@ -970,7 +956,9 @@ impl CapturedEvents { type Buffer = Arc>>; std::thread_local! { - /// The capture buffer active on this thread, if any. + /// 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) }; } @@ -984,8 +972,9 @@ impl Drop for CaptureGuard { } } -/// Events-only subscriber recording each event into the thread's active -/// buffer; spans are inert. +/// 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, } @@ -1030,7 +1019,9 @@ impl Subscriber for CaptureSubscriber { fn exit(&self, _span: &Id) {} } -/// Splits an event into its `message` field and a name-keyed map of the rest. +/// 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, @@ -1078,9 +1069,20 @@ impl Visit for FieldVisitor { static INSTALL_ROUTING: std::sync::Once = std::sync::Once::new(); -/// Run `f`, returning its value and every `tracing` event it emitted on -/// the calling thread. Capture is thread-scoped; events emitted outside -/// any `capture_tracing` call are dropped. +/// 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 { @@ -1201,59 +1203,6 @@ mod tests { assert!(store.list_keys("bad:").is_err()); } - #[test] - fn trap_store_counts_writes_unarmed() { - let store = TrapStore::new(MockLocalStore::default()); - store.set("a", b"1").unwrap(); - store.set("b", b"2").unwrap(); - store.delete("a").unwrap(); - assert_eq!(store.writes(), 3); - assert!(!store.tripped()); - assert_eq!(store.get("b").unwrap().as_deref(), Some(&b"2"[..])); - } - - #[test] - fn trap_store_trips_after_the_armed_budget() { - let store = TrapStore::new(MockLocalStore::default()); - store.arm_after(2); - store.set("a", b"1").unwrap(); - store.delete("a").unwrap(); - // The third write trips; the row never lands. - assert!(store.set("b", b"2").is_err()); - assert!(store.tripped()); - assert_eq!(store.writes(), 2); - assert!(store.inner().get("b").unwrap().is_none()); - } - - #[test] - fn trap_store_faults_every_operation_once_tripped() { - let store = TrapStore::new(MockLocalStore::default()); - store.set("a", b"1").unwrap(); - store.arm_after(0); - assert!(store.set("b", b"2").is_err()); - // Nothing past a trap executes, reads included. - assert!(store.get("a").is_err()); - assert!(store.list_keys("").is_err()); - assert!(store.contains("a").is_err()); - assert!(store.delete("a").is_err()); - } - - #[test] - fn trap_store_disarm_resumes_over_the_surviving_rows() { - let store = TrapStore::new(MockLocalStore::default()); - store.set("a", b"1").unwrap(); - store.arm_after(0); - assert!(store.set("b", b"2").is_err()); - - store.disarm(); - assert!(!store.tripped()); - // The torn prefix survives: `a` landed, `b` never did. - assert_eq!(store.get("a").unwrap().as_deref(), Some(&b"1"[..])); - assert!(store.get("b").unwrap().is_none()); - store.set("b", b"2").unwrap(); - assert_eq!(store.writes(), 2); - } - #[test] fn local_store_max_entries_enforced() { let store = MockLocalStore::default(); diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index f04525d..445c894 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nexum-sdk" -version.workspace = true +version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true @@ -23,19 +23,14 @@ stderr-echo = [] # calls back into this crate (`bind_host_via_wit_bindgen!`, the host # trait seam, the tracing facade). nexum-module-macros = { path = "../nexum-module-macros" } -# The single-source vocabularies: `ChainMethod` (re-exported as -# `chain::ChainMethod`) and the fault labels. Its world-synthesis half -# is unused here, so the guest links `toml` and never calls it. -nexum-world = { path = "../nexum-world" } +# 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 -# Typed EIP-155 chain id; already in the guest graph via alloy-provider. -alloy-chains.workspace = true # The `Log` type modules receive for chain-log events is alloy's own RPC log, # assembled from the WIT record at the binding edge (see `events`). alloy-rpc-types-eth.workspace = true alloy-sol-types.workspace = true -# The `store` helpers' value codec (`TypedCell`, `TypedMap`, `Counter`). -borsh.workspace = true # The provider seam: `HostTransport` speaks alloy's JSON-RPC packet # vocabulary over `ChainHost::request`, and `provider()` fronts it with # a `RootProvider`. Featureless, so no ws/ipc/reqwest transport reaches @@ -68,9 +63,11 @@ proptest.workspace = true # 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, native tooling) compile the `http` module's types +# 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/crates/nexum-sdk/src/address.rs b/crates/nexum-sdk/src/address.rs index 2e8e8e4..2106e4a 100644 --- a/crates/nexum-sdk/src/address.rs +++ b/crates/nexum-sdk/src/address.rs @@ -1,38 +1,63 @@ //! EVM address parsing helpers. //! -//! Parses `[config]` values such as `addresses = "0xabc..., 0xdef..."` -//! and single `0x...` strings into typed [`Address`] values. The list -//! parser is permissive about whitespace and empty segments, so a -//! trailing comma is not an error. +//! 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 from [`parse_address_list`] and [`parse_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 { - /// An entry failed to parse as an EVM address; `index` is `0` for a - /// single-address parse. + /// 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 in the list (counts skipped empties); - /// `0` for single-address parses. + /// 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, - /// Parse-error detail. + /// Human-readable parse-error detail from + /// `
::Err`. message: String, }, - /// The list held no non-whitespace segment. Only from - /// [`parse_address_list`]. + /// 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, trimming whitespace and -/// skipping empty segments. [`AddressParse::Empty`] on no segment, -/// [`AddressParse::InvalidAddress`] on the first bad entry (`index` -/// counts skipped empties). +/// 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() { @@ -55,9 +80,10 @@ pub fn parse_address_list(raw: &str) -> Result, AddressParse> { Ok(out) } -/// Parse a single `0x...` (or bare-hex) address string, trimming -/// whitespace. Failures surface as [`AddressParse::InvalidAddress`] -/// with `index = 0`. +/// 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 diff --git a/crates/nexum-sdk/src/chain/chainlink.rs b/crates/nexum-sdk/src/chain/chainlink.rs index 9ee53a3..57c49b7 100644 --- a/crates/nexum-sdk/src/chain/chainlink.rs +++ b/crates/nexum-sdk/src/chain/chainlink.rs @@ -1,8 +1,17 @@ //! Chainlink Aggregator V3 reader. //! -//! [`read_latest_answer`] runs `eth_call` against a Chainlink -//! AggregatorV3 oracle and decodes `latestRoundData.answer`, returning -//! `None` (logging at Warn) on any host or decode failure. +//! [`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}; @@ -26,8 +35,16 @@ sol! { } /// Fetch the oracle's latest answer via `eth_call(latestRoundData)`. -/// `None`, with a Warn line prefixed by `domain`, on an `Err` -/// response, a result that is not `0x`-hex, or an ABI decode failure. +/// +/// 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. @@ -74,8 +91,10 @@ pub fn read_latest_answer( #[cfg(test)] mod tests { - //! Coverage of the read path over a stub host returning a synthetic - //! `latestRoundData` result. + //! `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}; diff --git a/crates/nexum-sdk/src/chain/eth_call.rs b/crates/nexum-sdk/src/chain/eth_call.rs index 51b7e1e..36dd41c 100644 --- a/crates/nexum-sdk/src/chain/eth_call.rs +++ b/crates/nexum-sdk/src/chain/eth_call.rs @@ -2,8 +2,28 @@ use alloy_primitives::Address; -/// Build the JSON params array for `eth_call`: `[{to, data}, "latest"]`, -/// ready to pass to `chain::request` without re-serialising. +/// 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. @@ -11,8 +31,27 @@ pub fn eth_call_params(to: &Address, data: &[u8]) -> String { format!(r#"[{{"to":"{to:#x}","data":"{data_hex}"}},"latest"]"#) } -/// Decode the JSON-RPC `result` of an `eth_call`, a JSON string holding -/// `0x`-prefixed hex, to bytes. `None` on shape mismatch. +/// 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 diff --git a/crates/nexum-sdk/src/chain/id.rs b/crates/nexum-sdk/src/chain/id.rs new file mode 100644 index 0000000..598857c --- /dev/null +++ b/crates/nexum-sdk/src/chain/id.rs @@ -0,0 +1,125 @@ +//! 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/crates/nexum-sdk/src/chain/method.rs b/crates/nexum-sdk/src/chain/method.rs new file mode 100644 index 0000000..58ecfae --- /dev/null +++ b/crates/nexum-sdk/src/chain/method.rs @@ -0,0 +1,122 @@ +//! 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/crates/nexum-sdk/src/chain/mod.rs b/crates/nexum-sdk/src/chain/mod.rs index 0534b8e..e10c880 100644 --- a/crates/nexum-sdk/src/chain/mod.rs +++ b/crates/nexum-sdk/src/chain/mod.rs @@ -1,6 +1,6 @@ -//! Chain access for guest modules. +//! Chain access for guest strategies. //! -//! Chain identity (alloy [`Chain`]), the closed JSON-RPC read +//! 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 @@ -9,13 +9,13 @@ pub mod chainlink; pub mod eth_call; +pub mod id; +pub mod method; pub mod provider; pub mod transport; -pub use alloy_chains::Chain; pub use eth_call::{eth_call_params, parse_eth_call_result}; -/// The read surface is defined once in `nexum-world`; guest and host -/// re-export the same type, so the allowlist cannot drift. -pub use nexum_world::ChainMethod; +pub use id::{Chain, ChainId}; +pub use method::ChainMethod; pub use provider::{ProviderHost, block_on}; pub use transport::HostTransport; diff --git a/crates/nexum-sdk/src/chain/provider.rs b/crates/nexum-sdk/src/chain/provider.rs index 27bba09..a46d4f8 100644 --- a/crates/nexum-sdk/src/chain/provider.rs +++ b/crates/nexum-sdk/src/chain/provider.rs @@ -11,10 +11,28 @@ use super::{Chain, HostTransport}; use crate::host::ChainHost; /// Mints an alloy [`Provider`](alloy_provider::Provider) over -/// [`ChainHost::request`], so a module calls typed provider methods +/// [`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 { @@ -27,20 +45,16 @@ pub trait ProviderHost: ChainHost + Clone + Send + Sync + Sized + 'static { impl ProviderHost for H {} -/// Drive a host-backed provider future to completion. The host -/// transport is a synchronous WIT import, so the future resolves on the -/// first poll; a `Pending` panics. +/// 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()); - match future.as_mut().poll(&mut cx) { - Poll::Ready(output) => output, - Poll::Pending => panic!( - "chain provider future did not resolve synchronously: the host \ - transport is a synchronous WIT import, so an alloy layer that \ - awaits a reactor or timer was added; the chain SDK must move \ - to a host-driven async surface, not a poll loop" - ), + loop { + if let Poll::Ready(output) = future.as_mut().poll(&mut cx) { + return output; + } } } @@ -75,14 +89,14 @@ mod tests { #[test] fn provider_reads_typed_values_through_the_host() { - let provider = StubHost.provider(Chain::from_id(100)); + 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::from_id(100)); + let provider = StubHost.provider(Chain::GNOSIS); let tx = TransactionRequest::default() .to(address!("0x9008D19f58AAbD9eD0D60971565AA8510560ab41")); let out = block_on(provider.call(tx)).expect("eth_call"); @@ -91,7 +105,7 @@ mod tests { #[test] fn signing_methods_error_before_the_host() { - let provider = StubHost.provider(Chain::from_id(100)); + 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"); @@ -102,10 +116,4 @@ mod tests { fn block_on_drives_plain_futures() { assert_eq!(block_on(async { 7 }), 7); } - - #[test] - #[should_panic(expected = "did not resolve synchronously")] - fn block_on_panics_when_a_future_is_not_synchronously_ready() { - block_on(std::future::pending::<()>()); - } } diff --git a/crates/nexum-sdk/src/chain/transport.rs b/crates/nexum-sdk/src/chain/transport.rs index ee2ef28..10c6497 100644 --- a/crates/nexum-sdk/src/chain/transport.rs +++ b/crates/nexum-sdk/src/chain/transport.rs @@ -14,14 +14,15 @@ use super::{Chain, ChainMethod}; use crate::host::{ChainError, ChainHost}; /// An alloy `Transport` routing JSON-RPC through the host's chain -/// interface. Dispatch is synchronous, so every returned future is -/// ready on its first poll and [`block_on`](super::block_on) drives it. +/// 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 and fail as a JSON-RPC `-32601`. A structured node error comes -/// back as the error payload (code, message, revert bytes as `0x` hex); -/// a host [`Fault`](crate::host::Fault) surfaces as a custom transport -/// error carrying the typed fault. +/// 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, @@ -174,7 +175,7 @@ mod tests { assert_eq!(params, "[]"); Ok("\"0x2a\"".into()) }); - let mut transport = HostTransport::new(stub, Chain::from_id(100)); + 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:?}"); @@ -185,7 +186,7 @@ mod tests { #[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 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:?}"); @@ -203,7 +204,7 @@ mod tests { data: Some(vec![0x08, 0xc3, 0x79, 0xa0].into()), })) }); - let mut transport = HostTransport::new(stub, Chain::mainnet()); + 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:?}"); @@ -216,7 +217,7 @@ mod tests { #[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 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 { @@ -231,7 +232,7 @@ mod tests { "eth_blockNumber" => Ok("\"0x1\"".into()), _ => Ok("\"0x64\"".into()), }); - let mut transport = HostTransport::new(stub, Chain::mainnet()); + let mut transport = HostTransport::new(stub, Chain::MAINNET); let reqs = vec![ Request::new("eth_blockNumber", Id::Number(1), ()) .serialize() diff --git a/crates/nexum-sdk/src/config.rs b/crates/nexum-sdk/src/config.rs index b1dba85..fdfcfc9 100644 --- a/crates/nexum-sdk/src/config.rs +++ b/crates/nexum-sdk/src/config.rs @@ -1,12 +1,22 @@ -//! Helpers over the `Vec<(String, String)>` `[config]` entries a -//! module's `on_event` receives: required and optional key lookup, and -//! fixed-point decimal parsing. +//! 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 it into a -/// [`Fault::InvalidInput`] at the boundary. +/// 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)] @@ -36,7 +46,10 @@ pub enum ConfigError { }, } -/// Look up a required entry; `Err(MissingKey)` if absent. +/// 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, @@ -50,7 +63,8 @@ pub fn get_required<'a>( }) } -/// Look up an optional entry; `None` when absent. +/// 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() @@ -59,10 +73,18 @@ pub fn get_optional<'a>(entries: &'a [(String, String)], key: &str) -> Option<&' } /// Parse a signed fixed-point decimal string into an `I256` scaled by -/// `10**decimals`. Short fractions are right-padded, long fractions -/// truncated, a leading `-` honoured; empty input and non-digit -/// characters (beyond the sign and one `.`) are rejected. `key` is -/// embedded in the error. +/// `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) diff --git a/crates/nexum-sdk/src/events.rs b/crates/nexum-sdk/src/events.rs index 4342faa..fb9cd0b 100644 --- a/crates/nexum-sdk/src/events.rs +++ b/crates/nexum-sdk/src/events.rs @@ -1,19 +1,24 @@ //! Chain-log delivery at the guest WIT edge. //! //! Modules receive on-chain logs as the native [`Log`] (alloy's -//! `eth_getLogs` shape). The host packs each log into the WIT -//! `chain-log` record; [`ChainLogParts`] borrows its raw fields and -//! `From` rebuilds the alloy value. +//! `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 left-padded -/// into their EVM word (20 bytes for the address, 32 for topics and -/// hashes). +/// 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. diff --git a/crates/nexum-sdk/src/host.rs b/crates/nexum-sdk/src/host.rs index 2621d5e..9fc0cb6 100644 --- a/crates/nexum-sdk/src/host.rs +++ b/crates/nexum-sdk/src/host.rs @@ -1,23 +1,35 @@ -//! Host traits, the seam between module logic and the wit-bindgen -//! shims a module generates per-cdylib. Each trait mirrors one nexum -//! host interface ([`ChainHost`], [`IdentityHost`], [`LocalStoreHost`], -//! [`RemoteStoreHost`], [`MessagingHost`], [`LoggingHost`]); [`Host`] -//! bundles all six. +//! Host traits - the seam between strategy logic and the wit-bindgen +//! shims a module generates per-cdylib. //! -//! Module logic written against these traits runs host-free against -//! the `nexum-sdk-test` mocks. The traits are world-neutral over this -//! module's [`Fault`], mirroring the per-module `Fault` that -//! `wit_bindgen::generate!` emits, so modules wire a one-line converter -//! between the two. +//! 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; -/// Shared cross-domain failure vocabulary, mirrored from -/// `nexum:host/types.fault`. Typed per-interface errors embed it as a -/// case so a caller recovers the structured cause. `#[non_exhaustive]`: -/// the WIT can grow a case. +/// 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] @@ -70,8 +82,15 @@ impl sealed::SealedHost for T where impl sealed::SealedHostFault for Fault {} impl sealed::SealedHostFault for ChainError {} -/// Recovers the shared [`Fault`] and a stable snake_case label from a -/// richer per-interface error. Sealed. +/// 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>; @@ -90,8 +109,16 @@ impl HostFault for Fault { } /// A structured JSON-RPC error response, mirrored from -/// `nexum:host/chain.rpc-error`. `data` holds the host-decoded -/// `error.data` revert bytes, ready for a revert decoder. +/// `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 { @@ -100,13 +127,18 @@ pub struct RpcError { /// 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`: a shared host [`Fault`] or a -/// structured JSON-RPC [`RpcError`]. [`HostFault`] recovers the -/// embedded [`Fault`], present only on the `Fault` case. +/// `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 { @@ -134,9 +166,11 @@ impl HostFault for ChainError { } } -/// Fold a [`ChainError`] into the shared [`Fault`]: the `Fault` case -/// passes through; an [`RpcError`] becomes [`Fault::Internal`] carrying -/// the code, message, and any revert bytes as a `0x` hex suffix. +/// 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 { @@ -156,29 +190,20 @@ impl From for Fault { /// `nexum:host/chain` - raw JSON-RPC dispatch. pub trait ChainHost { - /// Execute a JSON-RPC request against the given chain. + /// 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; } -/// One write in a [`LocalStoreHost::apply`] batch, mirrored from -/// `nexum:host/local-store.write-op`. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum WriteOp { - /// Insert or overwrite `key` with `value`. - Set { - /// Key to write. - key: String, - /// Value bytes. - value: Vec, - }, - /// Delete `key`; a no-op if absent. - Delete { - /// Key to delete. - key: String, - }, -} - /// `nexum:host/local-store` - per-module key-value persistence. +/// +/// 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>; @@ -188,30 +213,18 @@ pub trait LocalStoreHost { fn delete(&self, key: &str) -> Result<(), Fault>; /// Enumerate keys whose raw form starts with `prefix`. fn list_keys(&self, prefix: &str) -> Result, Fault>; - /// Apply a batch of writes; later ops on a key supersede earlier - /// ones. Atomic (every op lands or none does) only on the real - /// host adapter, which overrides this with the host's `apply` - /// verb; the default is a per-op `set`/`delete` fallback for - /// arbitrary impls such as mocks, so a mid-batch failure leaves - /// the earlier ops applied. - fn apply(&self, ops: &[WriteOp]) -> Result<(), Fault> { - for op in ops { - match op { - WriteOp::Set { key, value } => self.set(key, value)?, - WriteOp::Delete { key } => self.delete(key)?, - } - } - Ok(()) - } - /// Whether `key` exists. + /// 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. + /// 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`. + /// 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) } @@ -219,7 +232,9 @@ pub trait LocalStoreHost { /// `nexum:host/logging` - structured runtime logs. pub trait LoggingHost { - /// Emit a log line at the given [`Level`]. + /// 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); } @@ -250,9 +265,9 @@ pub struct Message { pub sender: Option>, } -/// `nexum:host/messaging` - publish to and query content topics, -/// confined to the component's `messaging_topics` grant; an off-scope -/// topic fails as [`Fault::Denied`]. +/// `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 /// (`////`). @@ -283,8 +298,9 @@ pub trait RemoteStoreHost { fn write_feed(&self, topic: B256, data: &[u8]) -> Result; } -/// Lift a host-returned account into an [`Address`]; any length but 20 -/// folds to [`Fault::Internal`]. +/// 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!( @@ -295,14 +311,15 @@ pub fn account_from_wire(raw: &[u8]) -> Result { } /// Lift a host-returned 65-byte `r || s || v` signature into a -/// [`Signature`]; a malformed buffer folds to [`Fault::Internal`]. +/// [`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 folds to [`Fault::Internal`]. +/// 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!( @@ -312,10 +329,95 @@ pub fn reference_from_wire(raw: &[u8]) -> Result { }) } -/// Supertrait bundling all six core host interfaces. Module functions -/// take `` (or bound exactly the interfaces they exercise) and -/// run against `nexum_sdk_test::MockHost` in tests. Blanket-implemented -/// for any type carrying all six; sealed, so that impl is the only one. +/// 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 @@ -379,52 +481,6 @@ mod tests { assert_eq!(TwoRows.count("z").unwrap(), 0); } - #[test] - fn local_store_default_apply_falls_back_to_one_call_per_op() { - use std::cell::RefCell; - - use super::{LocalStoreHost, WriteOp}; - - /// Records each call; only the four required methods are written. - #[derive(Default)] - struct Recorder(RefCell>); - impl LocalStoreHost for Recorder { - fn get(&self, _: &str) -> Result>, Fault> { - Ok(None) - } - fn set(&self, key: &str, _: &[u8]) -> Result<(), Fault> { - self.0.borrow_mut().push(format!("set {key}")); - Ok(()) - } - fn delete(&self, key: &str) -> Result<(), Fault> { - self.0.borrow_mut().push(format!("delete {key}")); - Ok(()) - } - fn list_keys(&self, _: &str) -> Result, Fault> { - Ok(Vec::new()) - } - } - - let recorder = Recorder::default(); - recorder - .apply(&[ - WriteOp::Set { - key: "a".into(), - value: b"1".to_vec(), - }, - WriteOp::Delete { key: "b".into() }, - WriteOp::Set { - key: "c".into(), - value: b"2".to_vec(), - }, - ]) - .unwrap(); - assert_eq!( - recorder.0.into_inner(), - ["set a", "delete b", "set c"].map(str::to_owned) - ); - } - #[test] fn wire_lifts_accept_exact_lengths() { let account = account_from_wire(&[0x11; 20]).unwrap(); @@ -453,21 +509,18 @@ mod tests { #[test] fn fault_labels_match_the_single_source_vocabulary() { - use nexum_world::FaultLabel as Label; + use nexum_world::fault_labels as labels; let cases: [(Fault, &str); 7] = [ - (Fault::Unsupported(String::new()), Label::Unsupported.into()), - (Fault::Unavailable(String::new()), Label::Unavailable.into()), - (Fault::Denied(String::new()), Label::Denied.into()), + (Fault::Unsupported(String::new()), labels::UNSUPPORTED), + (Fault::Unavailable(String::new()), labels::UNAVAILABLE), + (Fault::Denied(String::new()), labels::DENIED), ( Fault::RateLimited(RateLimit::default()), - Label::RateLimited.into(), - ), - (Fault::Timeout, Label::Timeout.into()), - ( - Fault::InvalidInput(String::new()), - Label::InvalidInput.into(), + labels::RATE_LIMITED, ), - (Fault::Internal(String::new()), Label::Internal.into()), + (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); diff --git a/crates/nexum-sdk/src/http.rs b/crates/nexum-sdk/src/http.rs index 7aebce8..4e35b14 100644 --- a/crates/nexum-sdk/src/http.rs +++ b/crates/nexum-sdk/src/http.rs @@ -1,29 +1,40 @@ //! 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 each request -//! against `[capabilities.http].allow` before connecting; a denial -//! surfaces as [`FetchError::Denied`], distinct from a transport -//! failure. Requests and responses are the [`http`] crate's -//! `Request>` / `Response>`. The [`Fetch`] seam, -//! [`FetchError`], and [`FetchOptions`] compile on every target for -//! host-side tests; `fetch` itself exists only on `wasm32-wasip2`. +//! 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 [`FetchOptions::default`] applies to connect, -/// first byte, and between bytes. +/// 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. +/// +/// `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. + /// 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, @@ -40,12 +51,15 @@ impl Default for FetchOptions { } /// Why a fetch failed, folded down from the wasi:http error codes. +/// +/// `IntoStaticStr` yields a snake_case label per variant for log and +/// metric fields. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] pub enum FetchError { - /// The `[capabilities.http].allow` list refused the request before - /// any connection was made. + /// 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 @@ -55,16 +69,18 @@ pub enum FetchError { /// A configured timeout elapsed. #[error("timeout: {0}")] Timeout(String), - /// Connection or protocol failure after the request was admitted. + /// Connection or protocol failure after the allowlist admitted the + /// request. #[error("transport failure: {0}")] Transport(String), } -/// Seam between module logic and the wasi:http transport; module glue -/// passes [`WasiFetch`], tests a stub. +/// 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, blocking until the response body is fully - /// buffered. + /// Perform one request with `options`, blocking until the response + /// body is fully buffered. fn fetch_with( &self, request: http::Request>, @@ -80,7 +96,7 @@ pub trait Fetch { } } -/// A shared reference forwards to its referent. +/// A shared reference forwards, so middleware can borrow its transport. impl Fetch for &F { fn fetch_with( &self, @@ -91,9 +107,11 @@ impl Fetch for &F { } } -/// [`Fetch`] adapter over the host's wasi:http outgoing handler. Exists -/// on every target for host-side tests, but [`Fetch::fetch_with`] is -/// unimplemented off the wasm guest. +/// [`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; @@ -128,9 +146,10 @@ mod wasi_impl { fetch_with(request, FetchOptions::default()) } - /// Perform `request` through the host's wasi:http outgoing handler, - /// blocking until the response body is fully buffered. The body is - /// bounded only by the module's memory limit. + /// 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, @@ -153,9 +172,9 @@ mod wasi_impl { Ok(http::Response::from_parts(parts, bytes.to_vec())) } - /// Fold the client error's wasi:http error code into [`FetchError`]; - /// anything not a policy, timeout, or request-shape failure is - /// transport. + /// 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:#}")); @@ -200,7 +219,9 @@ mod tests { ); } - /// [`Fetch::fetch`] delegates to `fetch_with` with default options. + /// 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; diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 9135489..726bdae 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -1,22 +1,35 @@ -//! Keeper stores: persistent-state conventions shared by -//! conditional-commitment modules, expressed over [`LocalStoreHost`] so -//! they compile for any world and test against the in-memory mocks. +//! 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. //! -//! - [`WatchSet`] - watch-set registry, one `watch:{owner}:{hash}` row -//! per conditional commitment. -//! - [`Gates`] - `next_block:` / `next_epoch:` gate keys holding a u64 -//! little-endian threshold, with an [`is_ready`](Gates::is_ready) -//! predicate. -//! - [`Journal`] - receipt-keyed idempotency journal, with the -//! [`guard`](Journal::guard) reserve-effect-commit combinator. -//! - [`Poller`] - world-neutral poll seam: one watch in, one outcome -//! out at a [`Tick`]. -//! - [`Retrier`] - runs a [`RetryAction`] through the stores after a -//! failed run. +//! 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. //! -//! [`WatchRef`] derives gate and marker keys from the verbatim hex -//! substrings of the stored watch key; [`WatchSet::remove`] drops a -//! watch with all its derived keys so no failure path orphans one. +//! 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}; @@ -68,8 +81,6 @@ //! # Ok::<(), Fault>(()) //! ``` -use std::future::Future; - use alloy_primitives::{Address, B256}; use strum::IntoStaticStr; @@ -81,25 +92,30 @@ pub const WATCH_PREFIX: &str = "watch:"; pub const NEXT_BLOCK_PREFIX: &str = "next_block:"; /// Prefix of the Unix-seconds gate row paired with a watch. pub const NEXT_EPOCH_PREFIX: &str = "next_epoch:"; -/// Journal prefix for receipts the module submitted upstream itself. +/// 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 observed upstream but did not -/// submit. +/// 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:"; -/// First-refusal marker paired with a watch: block of the first -/// [`RetryAction::DropOnRepeat`] refusal, u64 little-endian. +/// 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. +/// 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. Derived keys reuse -/// the verbatim substrings, so parse-then-derive is byte-stable -/// regardless of the original hex casing. +/// 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, @@ -108,7 +124,9 @@ pub struct WatchRef<'k> { impl<'k> WatchRef<'k> { /// Parse a `watch:{owner}:{hash}` key. `None` when the prefix or - /// colon is missing, or either half is empty. + /// 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(':')?; @@ -165,13 +183,15 @@ impl<'h, H: LocalStoreHost> WatchSet<'h, H> { Self { host } } - /// Canonical key for an owner / commitment-hash pair; delegates to - /// [`watch_key`]. + /// 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)?; @@ -188,9 +208,10 @@ impl<'h, H: LocalStoreHost> WatchSet<'h, H> { self.host.list_keys(WATCH_PREFIX) } - /// Drop the watch with its gate and refusal keys. Derived keys go - /// first, so a fault leaves the watch row for a retry and never - /// orphans a derived key. + /// 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())?; @@ -198,9 +219,10 @@ impl<'h, H: LocalStoreHost> WatchSet<'h, H> { } } -/// `next_block:` / `next_epoch:` gate rows holding a u64 little-endian -/// threshold. A malformed or absent row reads as no gate (fail-open: a -/// corrupt value can only make a watch poll sooner, never wedge it). +/// 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, } @@ -222,8 +244,9 @@ impl<'h, H: LocalStoreHost> Gates<'h, H> { .set(&watch.next_epoch_key(), &epoch_s.to_le_bytes()) } - /// Whether the watch is clear to poll. Both gates must pass; each - /// is inclusive at its threshold. + /// 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())? @@ -246,8 +269,11 @@ impl<'h, H: LocalStoreHost> Gates<'h, H> { } } -/// Read a little-endian u64 row. Absent: `None`. Wrong length: warn and -/// fall open to `None`. +/// Little-endian u64 row read shared by the gate and refusal-marker +/// keys. Absent key: silently `None`. Present but wrong length: the +/// value is corrupt, so warn before falling open to `None` - fail-open +/// is deliberate (a corrupt value can only make the watch act sooner, +/// never wedge it), but it must not pass unobserved. fn read_u64(host: &H, key: &str) -> Result, Fault> { let Some(b) = host.get(key)? else { return Ok(None); @@ -261,48 +287,9 @@ fn read_u64(host: &H, key: &str) -> Result, Fault } } -/// RESERVED value tag: the marker owes a durable effect, its body and -/// `next_eligible` follow. -const RESERVED_TAG: u8 = 0x01; -/// COMMITTED value tag: the durable effect landed; nothing is owed. -const COMMITTED_TAG: u8 = 0x02; - -/// Presence class of a durable-effect marker. The leading tag byte -/// separates the two; a corrupt tag falls to [`Reserved`](Mark::Reserved) -/// so a reconcile resubmits rather than skips. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Mark { - /// A submit is in flight or owed; the effect is not yet durable. - Reserved, - /// The upstream effect is durable; no resubmit is owed. - Committed, -} - -/// A live reservation enumerated from the journal. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Reservation { - /// Receipt key as passed to [`Journal::reserve`], prefix stripped. - pub key: String, - /// Earliest Unix-seconds a retry is eligible; `0` when unset. - pub next_eligible: u64, - /// Opaque reservation body stored alongside the marker. - pub body: Vec, -} - -/// Encode a RESERVED value: `[0x01] ++ be64(next_eligible) ++ body`. -fn reserved_value(next_eligible: u64, body: &[u8]) -> Vec { - let mut v = Vec::with_capacity(9 + body.len()); - v.push(RESERVED_TAG); - v.extend_from_slice(&next_eligible.to_be_bytes()); - v.extend_from_slice(body); - v -} - -/// Receipt-keyed idempotency journal under a fixed prefix. The presence -/// path ([`record`](Self::record) / [`contains`](Self::contains)) stores -/// an empty marker; the durable-effect path ([`reserve`](Self::reserve) / -/// [`commit`](Self::commit)) tags the value so [`mark`](Self::mark) tells -/// RESERVED from COMMITTED. +/// 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, @@ -332,166 +319,19 @@ impl<'h, H: LocalStoreHost> Journal<'h, H> { self.host.set(&format!("{}{receipt}", self.prefix), b"") } - /// Whether the receipt is journalled. Presence-only: MUST NOT guard - /// a reserve/commit journal (use [`mark`](Self::mark)), else a - /// corrupt marker is guard-skipped yet never reconciled. + /// Whether the receipt is already journalled. pub fn contains(&self, receipt: &str) -> Result { Ok(self .host .get(&format!("{}{receipt}", self.prefix))? .is_some()) } - - /// Reserve `key`: write a RESERVED marker with `next_eligible` 0. - pub fn reserve(&self, key: &str, body: &[u8]) -> Result<(), Fault> { - self.host - .set(&format!("{}{key}", self.prefix), &reserved_value(0, body)) - } - - /// Re-park a reservation: rewrite its RESERVED marker with - /// `next_eligible = until`, body unchanged. - pub fn park(&self, key: &str, body: &[u8], until: u64) -> Result<(), Fault> { - self.host.set( - &format!("{}{key}", self.prefix), - &reserved_value(until, body), - ) - } - - /// Commit `key`: overwrite with the COMMITTED marker. Idempotent. - pub fn commit(&self, key: &str) -> Result<(), Fault> { - self.host - .set(&format!("{}{key}", self.prefix), &[COMMITTED_TAG]) - } - - /// Release `key`: delete the marker. No-op when absent. - pub fn release(&self, key: &str) -> Result<(), Fault> { - self.host.delete(&format!("{}{key}", self.prefix)) - } - - /// Classify `key`'s marker. Absent: `None`. Legacy empty or leading - /// `0x02`: [`Committed`](Mark::Committed). Any other first byte: - /// [`Reserved`](Mark::Reserved), so a corrupt tag reconciles. - pub fn mark(&self, key: &str) -> Result, Fault> { - let Some(v) = self.host.get(&format!("{}{key}", self.prefix))? else { - return Ok(None); - }; - Ok(Some(match v.first() { - None | Some(&COMMITTED_TAG) => Mark::Committed, - Some(_) => Mark::Reserved, - })) - } - - /// Enumerate every live reservation (non-empty, non-COMMITTED - /// value). Fail-safe and agrees with [`mark`](Self::mark): a `0x01` - /// marker yields its `next_eligible` and body, any other - /// non-committed value yields `0` and the post-tag bytes. - pub fn pending(&self) -> Result, Fault> { - let mut out = Vec::new(); - for full in self.host.list_keys(self.prefix)? { - let Some(v) = self.host.get(&full)? else { - continue; - }; - if v.first().is_none_or(|&b| b == COMMITTED_TAG) { - continue; - } - let key = full.strip_prefix(self.prefix).unwrap_or(&full).to_owned(); - let (next_eligible, body) = if v[0] == RESERVED_TAG && v.len() >= 9 { - let mut be = [0u8; 8]; - be.copy_from_slice(&v[1..9]); - (u64::from_be_bytes(be), v[9..].to_vec()) - } else { - (0, v.get(1..).unwrap_or(&[]).to_vec()) - }; - out.push(Reservation { - key, - next_eligible, - body, - }); - } - Ok(out) - } - - /// Guard one durable effect: reserve `key` with `body`, run - /// `submit`, then dispose of the reservation as the closure's - /// [`Disposition`] directs. The reserve-effect-commit order is - /// fixed by this shape, so a caller cannot run the effect - /// unreserved or leave the marker unsettled. - /// - /// An existing marker short-circuits without running the closure: - /// COMMITTED is an idempotent duplicate, RESERVED is owned by the - /// reconcile pass. A commit-write fault is tolerated (the effect - /// landed; the marker stays RESERVED for reconcile); release and - /// park faults propagate. - /// - /// The caller chooses the `Disposition` per outcome, so `guard` - /// fixes the ordering, not the retry policy. `Keeper::run`'s - /// fresh-submit arm releases on every venue error, so wiring `guard` - /// there must supply that policy, not the reconcile pass's park. - pub async fn guard( - &self, - key: &str, - body: &[u8], - submit: F, - ) -> Result, Fault> - where - F: FnOnce() -> Fut, - Fut: Future, - { - if let Some(mark) = self.mark(key)? { - return Ok(Guarded::Skipped(mark)); - } - self.reserve(key, body)?; - let (disposition, outcome) = submit().await; - match disposition { - Disposition::Commit => { - // Best-effort: the effect is durable upstream, so a - // commit fault leaves the marker RESERVED for the next - // reconcile pass, never an abort. - if let Err(fault) = self.commit(key) { - tracing::error!(%key, %fault, "commit write failed; reconcile owns the marker"); - } - } - Disposition::Release => self.release(key)?, - Disposition::Park { until } => self.park(key, body, until)?, - Disposition::Retain => {} - } - Ok(Guarded::Ran(outcome)) - } -} - -/// How a guarded submit's outcome disposes of its reservation. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Disposition { - /// Accepted: the effect is durable, commit the marker. - Commit, - /// Known synchronous non-accept (requires-signing or a terminal - /// refusal): release the marker. - Release, - /// Retryable refusal with a window: re-park the marker. - Park { - /// Earliest Unix-seconds a retry is eligible. - until: u64, - }, - /// Outcome unknown: leave the marker RESERVED for reconcile. - Retain, -} - -/// What [`Journal::guard`] did with a submission. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Guarded { - /// The submit closure ran; the reservation was disposed as the - /// closure directed. - Ran(T), - /// An existing marker skipped the closure: [`Mark::Committed`] is - /// an idempotent duplicate, [`Mark::Reserved`] is owned by the - /// reconcile pass. - Skipped(Mark), } -/// One poll dispatch's world view: chain, block height, block clock. -/// Gate checks and backoff read the instant the source was polled at, -/// so a watch never gates against a clock it was not judged by. +/// 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. @@ -503,27 +343,37 @@ pub struct Tick { } /// A source of conditional commitments: poll one watch, produce one -/// outcome. Generic over the host for mock-testability; the source owns -/// its own wire. `poll` is infallible by contract: a transient failure -/// surfaces as a retry-flavoured outcome rather than tearing down the -/// run. -pub trait Poller { +/// 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, passed verbatim for the source to decode. + /// 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 keeper name for log lines (e.g. `"twap"`). Diagnostic - /// only. + /// Short strategy name compositions prefix shared log lines with + /// (for example `"twap"`). Diagnostic only - no behaviour keys + /// off it. fn label(&self) -> &'static str { - "poller" + "conditional" } } -/// What the retry ledger does to a watch after a failed run. -/// `#[non_exhaustive]`: treat an unknown variant as leave-in-place. +/// 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] @@ -536,8 +386,8 @@ pub enum RetryAction { seconds: u64, }, /// Grant one next-block retry: the first application records the - /// block and gates to the next; a repeat at a later block removes - /// the watch. + /// 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, @@ -587,9 +437,10 @@ impl<'h, H: LocalStoreHost> Retrier<'h, H> { } } - /// Clear the first-refusal marker, so a later - /// [`RetryAction::DropOnRepeat`] earns a fresh one-block grace. - /// No-op when unset. + /// Clear the watch's first-refusal marker: an accepted submit ends + /// the refusal episode, so a later [`RetryAction::DropOnRepeat`] + /// refusal earns a fresh one-block grace. No-op when no marker is + /// set. pub fn clear_refusal(&self, watch: WatchRef<'_>) -> Result<(), Fault> { self.host.delete(&watch.refused_key()) } diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index 9023a59..b766be1 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -1,30 +1,133 @@ -//! Guest-side SDK for nexum runtime modules: host-neutral, domain-free -//! helpers usable by any module regardless of the world it exports. -//! Domain layers such as the CoW SDK build on top. -//! -//! Modules keep their own `wit_bindgen::generate!` call and pull helpers -//! and canonical primitive types from here; this crate takes primitive -//! types (`&[u8]`, slices) rather than the per-module `Fault`, so it -//! emits no wit-bindgen output of its own. -//! -//! Modules: -//! - [`prelude`] - alloy primitive re-exports. -//! - [`host`] - the [`Host`](host::Host) seam over the six core host interfaces, plus the [`Fault`](host::Fault) vocabulary. -//! - [`keeper`] - keeper stores ([`WatchSet`](keeper::WatchSet), [`Gates`](keeper::Gates), [`Journal`](keeper::Journal)), the [`Poller`](keeper::Poller) seam, and the [`Retrier`](keeper::Retrier). -//! - [`chain`] - typed chain access and the alloy provider seam. -//! - [`events`] - chain-log delivery. -//! - [`store`] - typed local-store helpers ([`WriteBatch`](store::WriteBatch), [`TypedCell`](store::TypedCell), [`TypedMap`](store::TypedMap), [`Counter`](store::Counter)). -//! - [`config`] - config-table lookups and decimal scaling. -//! - [`address`] - EVM address parsing. -//! - [`http`] - outbound HTTP over wasi:http. -//! - [`tracing`] - guest-side `tracing` facade. -//! - [`module`] and [`bind_host_via_wit_bindgen!`](crate::bind_host_via_wit_bindgen) generate the per-cdylib glue. +//! # 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 from an `impl` block of named +/// 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; @@ -36,12 +139,17 @@ pub mod host; pub mod http; pub mod keeper; pub mod prelude; -pub mod store; pub mod tracing; pub mod wit_bindgen_macro; -/// Shared log-level vocabulary for every SDK log path. `Ord` is -/// filter-oriented (`ERROR` is least verbose), not severity-ordered. +/// 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)] diff --git a/crates/nexum-sdk/src/prelude.rs b/crates/nexum-sdk/src/prelude.rs index d587dda..b72a6b0 100644 --- a/crates/nexum-sdk/src/prelude.rs +++ b/crates/nexum-sdk/src/prelude.rs @@ -1,5 +1,10 @@ -//! Alloy address, hash, and numeric primitives the chain helpers -//! consume. The wit-bindgen-generated types are not re-exported here; -//! they live in each module's own crate. +//! 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/crates/nexum-sdk/src/proptests.rs b/crates/nexum-sdk/src/proptests.rs index 634d4df..2a4709e 100644 --- a/crates/nexum-sdk/src/proptests.rs +++ b/crates/nexum-sdk/src/proptests.rs @@ -1,6 +1,18 @@ -//! Property-based round-trip tests for the SDK codecs: `eth_call` -//! params/result, `config::scale_decimal`, and `U256` little-endian -//! bytes. +//! 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)] @@ -49,8 +61,10 @@ fn decimal_string() -> impl Strategy { // ---- properties --------------------------------------------------- proptest! { - /// `eth_call_params` embeds the address; `parse_eth_call_result` - /// round-trips any `0x`-hex body. + /// `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(), @@ -70,7 +84,9 @@ proptest! { prop_assert_eq!(parsed, body); } - /// `parse_eth_call_result` returns `None` on a non-quoted shape. + /// `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}", @@ -79,8 +95,9 @@ proptest! { prop_assert!(parse_eth_call_result(&s).is_none() || s.starts_with('"')); } - /// `config::scale_decimal` round-trips: dividing the scaled value - /// by `10^decimals` reproduces the integer part and the sign. + /// `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(), @@ -115,7 +132,10 @@ proptest! { } } - /// `U256` round-trips through little-endian 32-byte bytes. + /// `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>(); diff --git a/crates/nexum-sdk/src/store.rs b/crates/nexum-sdk/src/store.rs deleted file mode 100644 index bd11534..0000000 --- a/crates/nexum-sdk/src/store.rs +++ /dev/null @@ -1,228 +0,0 @@ -//! Typed local-store helpers over the [`LocalStoreHost`] seam: -//! [`WriteBatch`], [`clear_prefix`], [`TypedCell`], [`TypedMap`], and -//! [`Counter`], so module code hand-rolls neither serialization nor -//! batching. Typed values cross the store as borsh bytes. -//! -//! Batch atomicity follows [`LocalStoreHost::apply`]: all-or-nothing on -//! the real host adapter, per-op on the trait's fallback. - -use core::marker::PhantomData; - -use borsh::{BorshDeserialize, BorshSerialize}; - -use crate::host::{Fault, LocalStoreHost, WriteOp}; - -/// Encode as borsh bytes; a failure folds to [`Fault::Internal`]. -fn encode(value: &T) -> Result, Fault> { - borsh::to_vec(value).map_err(|e| Fault::Internal(format!("borsh encode failed: {e}"))) -} - -/// Decode borsh bytes (trailing bytes are malformed); a failure folds -/// to [`Fault::Internal`] naming the key. -fn decode(key: &str, bytes: &[u8]) -> Result { - borsh::from_slice(bytes) - .map_err(|e| Fault::Internal(format!("stored value at `{key}` failed to decode: {e}"))) -} - -/// Stages set/delete ops, flushed in one [`LocalStoreHost::apply`] -/// call. Dropping an unflushed batch discards it. The host caps a -/// batch at 1024 ops and 4 MiB; a larger batch fails whole. -pub struct WriteBatch<'h, H> { - host: &'h H, - ops: Vec, -} - -impl<'h, H: LocalStoreHost> WriteBatch<'h, H> { - /// Empty batch over the given host. - pub fn new(host: &'h H) -> Self { - Self { - host, - ops: Vec::new(), - } - } - - /// Stage an insert-or-overwrite. - pub fn set(&mut self, key: impl Into, value: impl Into>) -> &mut Self { - self.ops.push(WriteOp::Set { - key: key.into(), - value: value.into(), - }); - self - } - - /// Stage a delete. - pub fn delete(&mut self, key: impl Into) -> &mut Self { - self.ops.push(WriteOp::Delete { key: key.into() }); - self - } - - /// Staged op count. - pub fn len(&self) -> usize { - self.ops.len() - } - - /// Whether nothing is staged. - pub fn is_empty(&self) -> bool { - self.ops.is_empty() - } - - /// Flush every staged op in one [`LocalStoreHost::apply`] call; a - /// no-op when nothing is staged. - pub fn flush(self) -> Result<(), Fault> { - if self.ops.is_empty() { - return Ok(()); - } - self.host.apply(&self.ops) - } -} - -/// Delete every key under `prefix` in one [`LocalStoreHost::apply`] -/// call; returns the number of keys deleted. Fails whole past the -/// host's 1024-key batch cap; chunk manually for larger prefixes. -pub fn clear_prefix(host: &impl LocalStoreHost, prefix: &str) -> Result { - let ops: Vec = host - .list_keys(prefix)? - .into_iter() - .map(|key| WriteOp::Delete { key }) - .collect(); - if ops.is_empty() { - return Ok(0); - } - let count = ops.len() as u64; - host.apply(&ops)?; - Ok(count) -} - -/// One borsh-typed value under one key. -pub struct TypedCell<'h, H, T> { - host: &'h H, - key: String, - _value: PhantomData T>, -} - -impl<'h, H: LocalStoreHost, T: BorshSerialize + BorshDeserialize> TypedCell<'h, H, T> { - /// Cell over the given key. - pub fn new(host: &'h H, key: impl Into) -> Self { - Self { - host, - key: key.into(), - _value: PhantomData, - } - } - - /// Decoded value, `Ok(None)` when absent. - pub fn get(&self) -> Result, Fault> { - self.host - .get(&self.key)? - .map(|bytes| decode(&self.key, &bytes)) - .transpose() - } - - /// Encode and store `value`. - pub fn set(&self, value: &T) -> Result<(), Fault> { - self.host.set(&self.key, &encode(value)?) - } - - /// Delete the value; a no-op if absent. - pub fn clear(&self) -> Result<(), Fault> { - self.host.delete(&self.key) - } -} - -/// Borsh-typed values keyed under a prefix: a keyed collection for -/// sets of things such as watches or orders. -pub struct TypedMap<'h, H, T> { - host: &'h H, - prefix: String, - _value: PhantomData T>, -} - -impl<'h, H: LocalStoreHost, T: BorshSerialize + BorshDeserialize> TypedMap<'h, H, T> { - /// Collection under the given key prefix. - pub fn new(host: &'h H, prefix: impl Into) -> Self { - Self { - host, - prefix: prefix.into(), - _value: PhantomData, - } - } - - fn full_key(&self, key: &str) -> String { - format!("{}{key}", self.prefix) - } - - /// Encode and store `value` under `key`. - pub fn insert(&self, key: &str, value: &T) -> Result<(), Fault> { - self.host.set(&self.full_key(key), &encode(value)?) - } - - /// Decoded value at `key`, `Ok(None)` when absent. - pub fn get(&self, key: &str) -> Result, Fault> { - let full = self.full_key(key); - self.host - .get(&full)? - .map(|bytes| decode(&full, &bytes)) - .transpose() - } - - /// Delete `key`; a no-op if absent. - pub fn remove(&self, key: &str) -> Result<(), Fault> { - self.host.delete(&self.full_key(key)) - } - - /// Keys in the collection, prefix stripped. - pub fn keys(&self) -> Result, Fault> { - Ok(self - .host - .list_keys(&self.prefix)? - .into_iter() - .map(|key| match key.strip_prefix(&self.prefix) { - Some(stripped) => stripped.to_owned(), - None => key, - }) - .collect()) - } - - /// Delete every entry in one [`LocalStoreHost::apply`] call; - /// returns the number deleted. Fails whole past the 1024-entry cap. - pub fn clear(&self) -> Result { - clear_prefix(self.host, &self.prefix) - } -} - -/// A `u64` under one key. Read-modify-write, so safe under the -/// runtime's single-actor serialized dispatch; there is no cross-call -/// lock. -pub struct Counter<'h, H> { - host: &'h H, - key: String, -} - -impl<'h, H: LocalStoreHost> Counter<'h, H> { - /// Counter over the given key. - pub fn new(host: &'h H, key: impl Into) -> Self { - Self { - host, - key: key.into(), - } - } - - /// Current value; 0 when absent. - pub fn get(&self) -> Result { - self.host - .get(&self.key)? - .map_or(Ok(0), |bytes| decode(&self.key, &bytes)) - } - - /// Add `delta` (saturating) and store; returns the new value. - pub fn add(&self, delta: u64) -> Result { - let next = self.get()?.saturating_add(delta); - self.set(next)?; - Ok(next) - } - - /// Store `value`. - pub fn set(&self, value: u64) -> Result<(), Fault> { - self.host.set(&self.key, &encode(&value)?) - } -} diff --git a/crates/nexum-sdk/src/tracing.rs b/crates/nexum-sdk/src/tracing.rs index 3709f61..81d1bb4 100644 --- a/crates/nexum-sdk/src/tracing.rs +++ b/crates/nexum-sdk/src/tracing.rs @@ -1,11 +1,16 @@ -//! Guest-side `tracing` facade routing events to a host log sink, so -//! module authors write `tracing::info!(...)` with no host parameter. +//! Guest-side `tracing` facade: routes `tracing` events to a host log +//! sink so module authors write `tracing::info!(...)` with no host +//! parameter to thread. //! -//! Events-only: each event's fields render to one line, forwarded at -//! the event's [`Level`]; spans are inert. [`init`] also installs a -//! panic hook that writes the panic to stderr, then reports it over the -//! sink (stderr first, so the panic is captured even if the host call -//! traps before `panic = abort`). +//! 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}; @@ -16,16 +21,16 @@ use tracing_core::field::{Field, Visit}; use tracing_core::span::{Attributes, Id, Record}; use tracing_core::{Event, Level, LevelFilter, Metadata, Subscriber}; -/// Sink the facade forwards rendered event lines to; implementors carry -/// the bound host logging call. +/// 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 over `sink`. The subscriber is set once; a second call only -/// re-registers the panic hook. +/// 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))); @@ -34,8 +39,8 @@ pub fn init(sink: impl LogSink + 'static) { set_panic_hook(sink); } -/// The events-only subscriber over `sink`, without touching global -/// state. +/// 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)) } @@ -63,7 +68,8 @@ fn panic_payload(info: &PanicHookInfo<'_>) -> String { } } -/// Render a panic into the reported line. +/// 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}"), @@ -120,7 +126,9 @@ impl Subscriber for FacadeSubscriber { fn exit(&self, _span: &Id) {} } -/// Flattens an event into ` key=value ...` in record order. +/// 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, @@ -174,7 +182,7 @@ mod tests { use super::*; - /// Capturing sink for the scoped subscriber. + /// Capturing sink for the with_default-scoped subscriber. #[derive(Default)] struct Captured { lines: Mutex>, diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 39558de..56e28fe 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -1,28 +1,59 @@ -//! Declarative macro generating the `WitBindgenHost` adapter a module -//! ships in `lib.rs`: `struct WitBindgenHost;` plus the core trait -//! impls and the fault, chain-error, and level conversions. +//! Declarative macro that generates the `WitBindgenHost` adapter +//! every module ships in `lib.rs`. //! -//! Capability-selected: `caps: [...]` emits only the pieces backed by -//! the listed capabilities (how `#[nexum_sdk::module]` invokes it); the -//! zero-argument form emits the full six-interface set. Either way the -//! wit-bindgen output for the world must already be in scope, so -//! selecting a capability the world does not import is a compile error. -//! A domain SDK layers its own interfaces on the same `WitBindgenHost`. +//! 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]); -//! // Call `install_tracing()` once at the top of `Guest::init`. +//! // 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, and the error / +/// Generate `WitBindgenHost` + the `*Host` trait impls + the error / /// level `From` impls for the selected capabilities. See module docs. /// -/// The generated names `WitBindgenHost`, `convert_chain_err`, -/// `HostLogSink`, and `install_tracing` are visible in the caller's -/// scope (`macro_rules!` is not hygienic for items). +/// 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 @@ -36,11 +67,15 @@ macro_rules! bind_host_via_wit_bindgen { // always-present `nexum:host/types`) plus one block per listed // capability. (caps: [$($cap:ident),* $(,)?]) => { - /// Wraps the module's per-cdylib wit-bindgen imports so a - /// module can hold a `&impl Host`. + /// 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` into the SDK's `Fault`. + /// 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 { @@ -59,9 +94,15 @@ macro_rules! bind_host_via_wit_bindgen { } } - /// Lower the SDK `Fault` back into the wit-bindgen `Fault` for - /// the export signature. A future `#[non_exhaustive]` SDK case - /// falls back to `internal` carrying the `Display` detail. + /// 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 { @@ -83,8 +124,10 @@ macro_rules! bind_host_via_wit_bindgen { } } - /// Rebuild the native alloy log from the wit-bindgen `chain-log` - /// record; assembly lives in `nexum_sdk::events`. + /// 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 { @@ -103,8 +146,9 @@ macro_rules! bind_host_via_wit_bindgen { } } - /// Rebuild the SDK `Message` from the wit-bindgen `message` - /// record. + /// 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 { @@ -137,8 +181,9 @@ macro_rules! __bind_host_cap_via_wit_bindgen { } } - /// Lift the wit-bindgen `chain.chain-error` into the SDK's - /// host-neutral `ChainError`. + /// 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) => { @@ -209,32 +254,6 @@ macro_rules! __bind_host_cap_via_wit_bindgen { fn delete(&self, key: &str) -> ::core::result::Result<(), $crate::host::Fault> { nexum::host::local_store::delete(key).map_err($crate::host::Fault::from) } - // Overrides the trait's per-op fallback with the host's - // atomic batch verb. - fn apply( - &self, - ops: &[$crate::host::WriteOp], - ) -> ::core::result::Result<(), $crate::host::Fault> { - let ops: ::std::vec::Vec = ops - .iter() - .map(|op| match op { - $crate::host::WriteOp::Set { key, value } => { - nexum::host::local_store::WriteOp::Set( - nexum::host::local_store::KeyValue { - key: ::std::clone::Clone::clone(key), - value: ::std::clone::Clone::clone(value), - }, - ) - } - $crate::host::WriteOp::Delete { key } => { - nexum::host::local_store::WriteOp::Delete(::std::clone::Clone::clone( - key, - )) - } - }) - .collect(); - nexum::host::local_store::apply(&ops).map_err($crate::host::Fault::from) - } fn list_keys( &self, prefix: &str, @@ -333,7 +352,9 @@ macro_rules! __bind_host_cap_via_wit_bindgen { } /// Translate a `tracing_core::Level` into the wit-bindgen - /// `logging::Level` wire enum. + /// `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 { @@ -359,10 +380,45 @@ macro_rules! __bind_host_cap_via_wit_bindgen { } } - /// Install the guest tracing facade and panic hook over the - /// bound host logging call. Call once at the top of `Guest::init`. + /// 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); } }; } + +/// Install the guest tracing facade routing `tracing::*` events to the +/// bound `nexum:host/logging` import, for modules that generate their own +/// wit-bindgen world (e.g. minimal fixtures) rather than going through +/// `#[nexum_sdk::module]`. Call once at the top of `Guest::init`, then use +/// `tracing::info!(...)` and friends. Unlike [`bind_host_via_wit_bindgen!`] +/// it binds only the logging seam, so it pulls no unused chain/local-store +/// adapters into a `-D warnings` wasm build. +#[macro_export] +macro_rules! install_host_tracing { + () => {{ + struct HostLogSink; + impl $crate::tracing::LogSink for HostLogSink { + fn log(&self, level: $crate::Level, message: &str) { + // `Level` is a set of associated consts, so compare rather + // than match; the five tiers are total, hence the `Trace` + // fallthrough. + let wire = if level == $crate::Level::ERROR { + nexum::host::logging::Level::Error + } else if level == $crate::Level::WARN { + nexum::host::logging::Level::Warn + } else if level == $crate::Level::INFO { + nexum::host::logging::Level::Info + } else if level == $crate::Level::DEBUG { + nexum::host::logging::Level::Debug + } else { + nexum::host::logging::Level::Trace + }; + nexum::host::logging::log(wire, message); + } + } + $crate::tracing::init(HostLogSink); + }}; +} diff --git a/crates/nexum-sdk/tests/keeper.rs b/crates/nexum-sdk/tests/keeper.rs index a67ab99..f73c9ad 100644 --- a/crates/nexum-sdk/tests/keeper.rs +++ b/crates/nexum-sdk/tests/keeper.rs @@ -1,13 +1,15 @@ -//! Keeper-store acceptance tests against `nexum_sdk_test::MockHost`. -//! Integration-test placement (not `#[cfg(test)]`) keeps the host -//! traits linked externally, as a module would see them. +//! 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::{ - Disposition, Gates, Guarded, Journal, Mark, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, Poller, - REFUSED_PREFIX, Reservation, Retrier, RetryAction, Tick, WATCH_PREFIX, WatchRef, WatchSet, - watch_key, + 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; @@ -19,6 +21,8 @@ fn sample_hash() -> B256 { b256!("0202020202020202020202020202020202020202020202020202020202020202") } +// ---- watch keys ---- + #[test] fn watch_key_is_lowercase_prefixed_hex() { let key = watch_key(&sample_owner(), &sample_hash()); @@ -73,6 +77,8 @@ fn parse_preserves_key_substrings_verbatim() { assert_eq!(watch.next_epoch_key(), "next_epoch:0xAABB:0xCCDD"); } +// ---- watch-set registry ---- + #[test] fn put_get_list_round_trip() { let host = MockHost::new(); @@ -125,6 +131,8 @@ fn list_scans_only_the_watch_prefix() { assert_eq!(watches.list().unwrap(), vec![key]); } +// ---- atomic delete ---- + #[test] fn remove_drops_watch_and_all_gate_keys() { let host = MockHost::new(); @@ -207,6 +215,8 @@ fn remove_propagates_a_gate_delete_fault_and_keeps_the_watch() { ); } +// ---- gates ---- + #[test] fn ready_with_no_gates_set() { let host = MockHost::new(); @@ -302,6 +312,8 @@ fn gate_fault_propagates_from_is_ready() { gates.is_ready(watch, 0, 0).unwrap_err(); } +// ---- journal ---- + #[test] fn journal_round_trips_a_receipt() { let host = MockHost::new(); @@ -342,159 +354,7 @@ fn submitted_and_observed_keyspaces_are_disjoint() { assert!(!snapshot.contains_key("observed:0xuid")); } -#[test] -fn mark_distinguishes_reserved_committed_absent_and_legacy() { - let host = MockHost::new(); - let journal = Journal::submitted(&host); - - // Absent. - assert_eq!(journal.mark("absent").unwrap(), None); - - // Reserved. - journal.reserve("res", b"body").unwrap(); - assert_eq!(journal.mark("res").unwrap(), Some(Mark::Reserved)); - - // Committed. - journal.commit("com").unwrap(); - assert_eq!(journal.mark("com").unwrap(), Some(Mark::Committed)); - - // Legacy empty presence marker reads as Committed. - host.store.set("submitted:legacy", b"").unwrap(); - assert_eq!(journal.mark("legacy").unwrap(), Some(Mark::Committed)); - - // A corrupt single-byte tag reads as Reserved. - host.store.set("submitted:corrupt", b"\x7f").unwrap(); - assert_eq!(journal.mark("corrupt").unwrap(), Some(Mark::Reserved)); -} - -#[test] -fn reserve_then_commit_marks_committed() { - let host = MockHost::new(); - let journal = Journal::submitted(&host); - journal.reserve("uid", b"body").unwrap(); - journal.commit("uid").unwrap(); - assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Committed)); -} - -#[test] -fn reserve_then_release_marks_absent() { - let host = MockHost::new(); - let journal = Journal::submitted(&host); - journal.reserve("uid", b"body").unwrap(); - journal.release("uid").unwrap(); - assert_eq!(journal.mark("uid").unwrap(), None); - assert!(host.store.is_empty()); -} - -#[test] -fn mark_and_pending_agree_on_every_non_committed_value() { - // The invariant: every value mark() calls Reserved, pending() must - // enumerate - including a corrupt single-byte tag with empty body. - // A guard-skipped-yet-never-reconciled marker is a dropped effect. - let host = MockHost::new(); - let journal = Journal::submitted(&host); - - journal.reserve("well_formed", b"body").unwrap(); - host.store.set("submitted:corrupt_tag", b"\x7f").unwrap(); - host.store.set("submitted:short_01", b"\x01").unwrap(); - - let listed: std::collections::BTreeSet = journal - .pending() - .unwrap() - .into_iter() - .map(|r| r.key) - .collect(); - - for key in ["well_formed", "corrupt_tag", "short_01"] { - assert_eq!( - journal.mark(key).unwrap(), - Some(Mark::Reserved), - "{key} must mark Reserved", - ); - assert!(listed.contains(key), "{key} must be enumerated"); - } - - // The corrupt tag enumerates with a zero next_eligible and empty body. - let corrupt = journal - .pending() - .unwrap() - .into_iter() - .find(|r| r.key == "corrupt_tag") - .unwrap(); - assert_eq!(corrupt.next_eligible, 0); - assert!(corrupt.body.is_empty()); -} - -#[test] -fn pending_ignores_committed_and_legacy_and_returns_bodies() { - let host = MockHost::new(); - let journal = Journal::submitted(&host); - - journal.park("a", b"aa", 1_700_000_000).unwrap(); - journal.reserve("b", b"bb").unwrap(); - journal.commit("c").unwrap(); - host.store.set("submitted:d", b"").unwrap(); - - let mut listed = journal.pending().unwrap(); - listed.sort_by(|x, y| x.key.cmp(&y.key)); - - assert_eq!( - listed, - vec![ - Reservation { - key: "a".into(), - next_eligible: 1_700_000_000, - body: b"aa".to_vec(), - }, - Reservation { - key: "b".into(), - next_eligible: 0, - body: b"bb".to_vec(), - }, - ], - ); -} - -#[test] -fn park_updates_next_eligible_without_touching_body() { - let host = MockHost::new(); - let journal = Journal::submitted(&host); - journal.reserve("uid", b"body").unwrap(); - journal.park("uid", b"body", 1_700_000_000).unwrap(); - - let listed = journal.pending().unwrap(); - assert_eq!( - listed, - vec![Reservation { - key: "uid".into(), - next_eligible: 1_700_000_000, - body: b"body".to_vec(), - }], - ); -} - -#[test] -fn double_commit_is_idempotent() { - let host = MockHost::new(); - let journal = Journal::submitted(&host); - journal.reserve("uid", b"body").unwrap(); - journal.commit("uid").unwrap(); - journal.commit("uid").unwrap(); - assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Committed)); - assert_eq!(host.store.len(), 1); - assert_eq!( - host.store.snapshot().get("submitted:uid").unwrap(), - &vec![0x02], - ); -} - -#[test] -fn release_of_absent_is_a_no_op() { - let host = MockHost::new(); - let journal = Journal::submitted(&host); - journal.release("nope").unwrap(); - assert!(host.store.is_empty()); -} +// ---- retry ledger ---- fn seeded_watch(host: &MockHost) -> String { WatchSet::new(host) @@ -693,11 +553,15 @@ fn retry_action_labels_are_stable_snake_case() { } } -/// The keeper passes stored params and the judged tick verbatim. +// ---- 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 poller_sees_params_and_tick_verbatim() { +fn conditional_source_sees_params_and_tick_verbatim() { struct EchoSource; - impl Poller for EchoSource { + impl ConditionalSource for EchoSource { type Outcome = (usize, u64, u64, u64, String); fn poll( &self, @@ -732,136 +596,3 @@ fn poller_sees_params_and_tick_verbatim() { assert_eq!(epoch_s, 1_700_000_000); assert_eq!(echoed, key); } - -/// Drive a guard future on the test's synchronous boundary. -fn drive(future: F) -> F::Output { - let mut future = std::pin::pin!(future); - let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); - match future.as_mut().poll(&mut cx) { - std::task::Poll::Ready(output) => output, - std::task::Poll::Pending => panic!("guard futures complete in one poll"), - } -} - -#[test] -fn guard_reserves_before_the_effect_and_commits_after() { - let host = MockHost::new(); - let journal = Journal::submitted(&host); - - let out = drive(journal.guard("uid", b"body", || async { - // The reservation is durable before the effect runs. - assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Reserved)); - (Disposition::Commit, 7u32) - })) - .unwrap(); - - assert_eq!(out, Guarded::Ran(7)); - assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Committed)); - assert!(journal.pending().unwrap().is_empty()); -} - -#[test] -fn guard_skips_a_committed_marker_without_running_the_closure() { - let host = MockHost::new(); - let journal = Journal::submitted(&host); - journal.commit("uid").unwrap(); - - let ran = std::cell::Cell::new(false); - let out = drive(journal.guard("uid", b"body", || async { - ran.set(true); - (Disposition::Commit, ()) - })) - .unwrap(); - - assert_eq!(out, Guarded::Skipped(Mark::Committed)); - assert!(!ran.get(), "a COMMITTED marker must not re-run the effect"); -} - -#[test] -fn guard_skips_a_reserved_marker_for_reconcile() { - let host = MockHost::new(); - let journal = Journal::submitted(&host); - journal.reserve("uid", b"body").unwrap(); - - let ran = std::cell::Cell::new(false); - let out = drive(journal.guard("uid", b"body", || async { - ran.set(true); - (Disposition::Commit, ()) - })) - .unwrap(); - - assert_eq!(out, Guarded::Skipped(Mark::Reserved)); - assert!(!ran.get(), "a RESERVED marker is owned by reconcile"); - assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Reserved)); -} - -#[test] -fn guard_releases_on_a_known_non_accept() { - let host = MockHost::new(); - let journal = Journal::submitted(&host); - - let out = - drive(journal.guard("uid", b"body", || async { (Disposition::Release, ()) })).unwrap(); - - assert_eq!(out, Guarded::Ran(())); - assert_eq!(journal.mark("uid").unwrap(), None); - assert!(journal.pending().unwrap().is_empty()); -} - -#[test] -fn guard_parks_a_retryable_outcome_with_its_body() { - let host = MockHost::new(); - let journal = Journal::submitted(&host); - - let out = drive(journal.guard("uid", b"body", || async { - (Disposition::Park { until: 1_234 }, ()) - })) - .unwrap(); - - assert_eq!(out, Guarded::Ran(())); - assert_eq!( - journal.pending().unwrap(), - vec![Reservation { - key: "uid".into(), - next_eligible: 1_234, - body: b"body".to_vec(), - }], - ); -} - -#[test] -fn guard_retains_an_unknown_outcome_reserved() { - let host = MockHost::new(); - let journal = Journal::submitted(&host); - - let out = drive(journal.guard("uid", b"body", || async { (Disposition::Retain, ()) })).unwrap(); - - assert_eq!(out, Guarded::Ran(())); - assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Reserved)); - assert_eq!( - journal.pending().unwrap(), - vec![Reservation { - key: "uid".into(), - next_eligible: 0, - body: b"body".to_vec(), - }], - "the body must stay enumerable for reconcile", - ); -} - -#[test] -fn guard_propagates_a_reserve_fault_without_running_the_closure() { - let host = MockHost::new(); - host.store - .fail_on("submitted:", Fault::Unavailable("store down".into())); - let journal = Journal::submitted(&host); - - let ran = std::cell::Cell::new(false); - let out = drive(journal.guard("uid", b"body", || async { - ran.set(true); - (Disposition::Commit, ()) - })); - - assert!(out.is_err(), "a reserve fault must abort the guard"); - assert!(!ran.get(), "no effect may run unreserved"); -} diff --git a/crates/nexum-sdk/tests/store.rs b/crates/nexum-sdk/tests/store.rs deleted file mode 100644 index 851b3b2..0000000 --- a/crates/nexum-sdk/tests/store.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! Store-helper acceptance tests over the world-neutral mock local -//! store, which exercises the trait's default per-op `apply` fallback. - -use borsh::{BorshDeserialize, BorshSerialize}; -use nexum_sdk::host::{Fault, LocalStoreHost}; -use nexum_sdk::store::{Counter, TypedCell, TypedMap, WriteBatch, clear_prefix}; -use nexum_sdk_test::MockLocalStore; - -#[derive(BorshSerialize, BorshDeserialize, Debug, Eq, PartialEq)] -struct Row { - label: String, - size: u64, -} - -fn row(label: &str, size: u64) -> Row { - Row { - label: label.to_owned(), - size, - } -} - -#[test] -fn write_batch_flushes_staged_sets_and_deletes() { - let store = MockLocalStore::default(); - store.set("stale", b"x").unwrap(); - - let mut batch = WriteBatch::new(&store); - batch.set("a", b"1".to_vec()).set("b", b"2".to_vec()); - batch.delete("stale"); - assert_eq!(batch.len(), 3); - assert!(!batch.is_empty()); - batch.flush().unwrap(); - - assert_eq!(store.get("a").unwrap(), Some(b"1".to_vec())); - assert_eq!(store.get("b").unwrap(), Some(b"2".to_vec())); - assert_eq!(store.get("stale").unwrap(), None); -} - -#[test] -fn write_batch_dropped_before_flush_writes_nothing() { - let store = MockLocalStore::default(); - let mut batch = WriteBatch::new(&store); - batch.set("a", b"1".to_vec()); - drop(batch); - assert!(store.is_empty()); -} - -#[test] -fn write_batch_empty_flush_is_a_no_op() { - let store = MockLocalStore::default(); - WriteBatch::new(&store).flush().unwrap(); - assert!(store.is_empty()); -} - -#[test] -fn default_apply_fallback_is_per_op_so_a_mid_batch_fault_leaves_earlier_ops() { - let store = MockLocalStore::default(); - store.fail_on("poison", Fault::Internal("injected".into())); - - let mut batch = WriteBatch::new(&store); - batch.set("a", b"1".to_vec()); - batch.set("poison", b"2".to_vec()); - batch.set("z", b"3".to_vec()); - batch.flush().unwrap_err(); - - // The mock exercises the trait default, so the pre-fault op landed - // and the post-fault op did not. - assert_eq!(store.get("a").unwrap(), Some(b"1".to_vec())); - assert_eq!(store.get("z").unwrap(), None); -} - -#[test] -fn clear_prefix_deletes_only_the_prefix_and_counts() { - let store = MockLocalStore::default(); - store.set("watch:a", b"1").unwrap(); - store.set("watch:b", b"2").unwrap(); - store.set("gate:a", b"3").unwrap(); - - assert_eq!(clear_prefix(&store, "watch:").unwrap(), 2); - assert_eq!(store.len(), 1); - assert_eq!(store.get("gate:a").unwrap(), Some(b"3".to_vec())); - assert_eq!(clear_prefix(&store, "watch:").unwrap(), 0); -} - -#[test] -fn typed_cell_round_trips_and_clears() { - let store = MockLocalStore::default(); - let cell: TypedCell<'_, _, Row> = TypedCell::new(&store, "cursor"); - - assert_eq!(cell.get().unwrap(), None); - cell.set(&row("head", 7)).unwrap(); - assert_eq!(cell.get().unwrap(), Some(row("head", 7))); - cell.clear().unwrap(); - assert_eq!(cell.get().unwrap(), None); -} - -#[test] -fn typed_cell_folds_a_corrupt_value_to_internal() { - let store = MockLocalStore::default(); - store.set("cursor", b"not borsh").unwrap(); - let cell: TypedCell<'_, _, Row> = TypedCell::new(&store, "cursor"); - assert!(matches!(cell.get().unwrap_err(), Fault::Internal(_))); -} - -#[test] -fn typed_map_inserts_gets_removes_and_strips_prefix_from_keys() { - let store = MockLocalStore::default(); - store.set("other", b"x").unwrap(); - let map: TypedMap<'_, _, Row> = TypedMap::new(&store, "order:"); - - assert_eq!(map.keys().unwrap(), Vec::::new()); - map.insert("a", &row("first", 1)).unwrap(); - map.insert("b", &row("second", 2)).unwrap(); - assert_eq!(map.get("a").unwrap(), Some(row("first", 1))); - assert_eq!(map.get("missing").unwrap(), None); - assert_eq!(map.keys().unwrap(), vec!["a".to_owned(), "b".to_owned()]); - - map.remove("a").unwrap(); - assert_eq!(map.get("a").unwrap(), None); - assert_eq!(map.clear().unwrap(), 1); - assert!(map.keys().unwrap().is_empty()); - assert_eq!(store.get("other").unwrap(), Some(b"x".to_vec())); -} - -#[test] -fn counter_defaults_to_zero_adds_and_saturates() { - let store = MockLocalStore::default(); - let counter = Counter::new(&store, "submitted"); - - assert_eq!(counter.get().unwrap(), 0); - assert_eq!(counter.add(2).unwrap(), 2); - assert_eq!(counter.add(3).unwrap(), 5); - counter.set(u64::MAX).unwrap(); - assert_eq!(counter.add(1).unwrap(), u64::MAX); -} diff --git a/crates/nexum-status-body/Cargo.toml b/crates/nexum-status-body/Cargo.toml new file mode 100644 index 0000000..cd4361b --- /dev/null +++ b/crates/nexum-status-body/Cargo.toml @@ -0,0 +1,14 @@ +[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/crates/nexum-status-body/src/lib.rs b/crates/nexum-status-body/src/lib.rs new file mode 100644 index 0000000..2bd9c00 --- /dev/null +++ b/crates/nexum-status-body/src/lib.rs @@ -0,0 +1,248 @@ +//! 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/crates/nexum-tasks/Cargo.toml b/crates/nexum-tasks/Cargo.toml index d3a9f90..b5855c7 100644 --- a/crates/nexum-tasks/Cargo.toml +++ b/crates/nexum-tasks/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nexum-tasks" -version.workspace = true +version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/nexum-tasks/src/lib.rs b/crates/nexum-tasks/src/lib.rs index 81eb72c..81e5b3d 100644 --- a/crates/nexum-tasks/src/lib.rs +++ b/crates/nexum-tasks/src/lib.rs @@ -1,7 +1,9 @@ //! 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. The only crate a raw `tokio` -//! spawn appears in, so shutdown reaches every task. +//! 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; diff --git a/crates/nexum-world/Cargo.toml b/crates/nexum-world/Cargo.toml index fd1a64f..088d377 100644 --- a/crates/nexum-world/Cargo.toml +++ b/crates/nexum-world/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nexum-world" -version.workspace = true +version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true @@ -9,16 +9,7 @@ description = "Per-module WIT world synthesis: the core capability table, regist [lints] workspace = true -[features] -# The syn-typed helper (`is_plain_type`), for the proc-macro crates only: -# non-macro consumers take nexum-world without `syn`. -macros = ["dep:syn"] - [dependencies] -# Derives the closed capability / fault-label vocabularies: `VariantNames` -# supersedes a hand-maintained list, `EnumString` parses fail-closed. -strum.workspace = true -syn = { workspace = true, optional = true } toml.workspace = true [dev-dependencies] diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs index 5ee94c7..5ca9e1c 100644 --- a/crates/nexum-world/src/lib.rs +++ b/crates/nexum-world/src/lib.rs @@ -2,162 +2,83 @@ //! declarations into an inline WIT world whose imports are exactly the //! declared capability interfaces. //! -//! Invariant: the capability rows must agree with the runtime's -//! capability registry on both names and WIT interfaces, since the -//! runtime cross-checks a component's imports against the manifest at -//! load time. [`CORE`] carries only the core `nexum:host` rows; -//! per-namespace rows come from a composition root's `extensions.toml` -//! ([`manifest_extensions`]) and are passed to [`synthesize`]. +//! The one non-obvious invariant: the capability rows here must agree +//! with the runtime's capability registry (`nexum-runtime`'s manifest +//! enforcement) on both the capability names and the WIT interfaces they +//! map to. The runtime cross-checks a component's imports against the +//! manifest at load time; because the imports are derived from the same +//! manifest, a macro-built component passes that check by construction +//! rather than by relying on the toolchain eliding unused imports. +//! +//! The table here carries only the core `nexum:host` rows. Per-namespace +//! rows come from the composition root's `extensions.toml` registry +//! ([`manifest_extensions`]): the caller passes them to [`synthesize`], +//! so this crate carries no downstream name. use std::path::{Path, PathBuf}; -use strum::{EnumString, IntoStaticStr, VariantNames}; - -/// A core capability name; the single source [`CORE`] and the runtime's -/// capability registry emit from. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, EnumString, VariantNames)] -#[strum(serialize_all = "kebab-case")] -#[non_exhaustive] -pub enum Cap { + +/// Capability name consts: the single source the [`CORE`] table and the +/// runtime's capability registry emit from. +pub mod caps { /// `nexum:host/chain`. - Chain, + pub const CHAIN: &str = "chain"; /// `nexum:host/identity`. - Identity, + pub const IDENTITY: &str = "identity"; /// `nexum:host/local-store`. - LocalStore, + pub const LOCAL_STORE: &str = "local-store"; /// `nexum:host/remote-store`. - RemoteStore, + pub const REMOTE_STORE: &str = "remote-store"; /// `nexum:host/messaging`. - Messaging, + pub const MESSAGING: &str = "messaging"; /// `nexum:host/logging`. - Logging, + pub const LOGGING: &str = "logging"; /// Gates `wasi:http/*`; no world import. - Http, -} - -impl Cap { - /// The declared name, as a manifest spells it. Const so [`CORE`] and - /// [`CORE_IFACES`] can evaluate it. - pub const fn as_str(self) -> &'static str { - match self { - Self::Chain => "chain", - Self::Identity => "identity", - Self::LocalStore => "local-store", - Self::RemoteStore => "remote-store", - Self::Messaging => "messaging", - Self::Logging => "logging", - Self::Http => "http", - } - } + pub const HTTP: &str = "http"; } -/// A `nexum:host/types.fault` case as a stable snake_case label, in WIT -/// declaration order; the single source every label mirror emits from. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, EnumString, IntoStaticStr, VariantNames)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum FaultLabel { +/// 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`. - Unsupported, + pub const UNSUPPORTED: &str = "unsupported"; /// `fault.unavailable`. - Unavailable, + pub const UNAVAILABLE: &str = "unavailable"; /// `fault.denied`. - Denied, + pub const DENIED: &str = "denied"; /// `fault.rate-limited`. - RateLimited, + pub const RATE_LIMITED: &str = "rate_limited"; /// `fault.timeout`. - Timeout, + pub const TIMEOUT: &str = "timeout"; /// `fault.invalid-input`. - InvalidInput, + pub const INVALID_INPUT: &str = "invalid_input"; /// `fault.internal`. - Internal, -} - -/// The permitted JSON-RPC read surface as a closed type; the single -/// source the guest allowlist and host dispatch table emit from. -/// Signing and state-mutating methods have no variant, so cannot cross -/// the WIT edge. This is the structural ceiling; an operator allowlist -/// narrows within it, never widens it. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, EnumString, IntoStaticStr)] -#[non_exhaustive] -pub enum ChainMethod { - /// `eth_blockNumber`. - #[strum(serialize = "eth_blockNumber")] - EthBlockNumber, - /// `eth_call`. - #[strum(serialize = "eth_call")] - EthCall, - /// `eth_chainId`. - #[strum(serialize = "eth_chainId")] - EthChainId, - /// `eth_estimateGas`. - #[strum(serialize = "eth_estimateGas")] - EthEstimateGas, - /// `eth_feeHistory`. - #[strum(serialize = "eth_feeHistory")] - EthFeeHistory, - /// `eth_gasPrice`. - #[strum(serialize = "eth_gasPrice")] - EthGasPrice, - /// `eth_maxPriorityFeePerGas`. - #[strum(serialize = "eth_maxPriorityFeePerGas")] - EthMaxPriorityFeePerGas, - /// `eth_getBalance`. - #[strum(serialize = "eth_getBalance")] - EthGetBalance, - /// `eth_getBlockByHash`. - #[strum(serialize = "eth_getBlockByHash")] - EthGetBlockByHash, - /// `eth_getBlockByNumber`. - #[strum(serialize = "eth_getBlockByNumber")] - EthGetBlockByNumber, - /// `eth_getBlockReceipts`. - #[strum(serialize = "eth_getBlockReceipts")] - EthGetBlockReceipts, - /// `eth_getCode`. - #[strum(serialize = "eth_getCode")] - EthGetCode, - /// `eth_getLogs`. - #[strum(serialize = "eth_getLogs")] - EthGetLogs, - /// `eth_getProof`. - #[strum(serialize = "eth_getProof")] - EthGetProof, - /// `eth_getStorageAt`. - #[strum(serialize = "eth_getStorageAt")] - EthGetStorageAt, - /// `eth_getTransactionByHash`. - #[strum(serialize = "eth_getTransactionByHash")] - EthGetTransactionByHash, - /// `eth_getTransactionCount`. - #[strum(serialize = "eth_getTransactionCount")] - EthGetTransactionCount, - /// `eth_getTransactionReceipt`. - #[strum(serialize = "eth_getTransactionReceipt")] - EthGetTransactionReceipt, - /// `net_version`. - #[strum(serialize = "net_version")] - NetVersion, -} - -impl ChainMethod { - /// The wire method name. - pub fn as_str(self) -> &'static str { - self.into() - } + 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: Cap, - /// The WIT import the declaration turns into; `None` for a - /// capability with no world import (`http`). + 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 for this - /// capability's host-adapter pieces, if it has a trait seam. + /// 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>, } @@ -165,43 +86,43 @@ pub struct Capability { /// core registry and nothing else; extension rows are the caller's. pub const CORE: &[Capability] = &[ Capability { - name: Cap::Chain, + name: caps::CHAIN, import: Some("nexum:host/chain@0.1.0"), packages: &[], adapter: Some("chain"), }, Capability { - name: Cap::Identity, + name: caps::IDENTITY, import: Some("nexum:host/identity@0.1.0"), packages: &[], adapter: Some("identity"), }, Capability { - name: Cap::LocalStore, + name: caps::LOCAL_STORE, import: Some("nexum:host/local-store@0.1.0"), packages: &[], adapter: Some("local_store"), }, Capability { - name: Cap::RemoteStore, + name: caps::REMOTE_STORE, import: Some("nexum:host/remote-store@0.1.0"), packages: &[], adapter: Some("remote_store"), }, Capability { - name: Cap::Messaging, + name: caps::MESSAGING, import: Some("nexum:host/messaging@0.1.0"), packages: &[], adapter: Some("messaging"), }, Capability { - name: Cap::Logging, + name: caps::LOGGING, import: Some("nexum:host/logging@0.1.0"), packages: &[], adapter: Some("logging"), }, Capability { - name: Cap::Http, + name: caps::HTTP, import: None, packages: &[], adapter: None, @@ -221,15 +142,16 @@ const fn core_iface_count() -> usize { n } -/// Names of the import-bearing [`CORE`] rows, in emission order; the -/// `nexum:host` interface set the runtime enforces. `http` is absent. +/// Names of the import-bearing [`CORE`] rows, in emission order: the +/// `nexum:host` interface set the runtime's capability registry +/// enforces. `http` is absent (no world import). pub const CORE_IFACES: [&str; core_iface_count()] = { let mut out = [""; core_iface_count()]; let mut n = 0; let mut i = 0; while i < CORE.len() { if CORE[i].import.is_some() { - out[n] = CORE[i].name.as_str(); + out[n] = CORE[i].name; n += 1; } i += 1; @@ -237,9 +159,10 @@ pub const CORE_IFACES: [&str; core_iface_count()] = { out }; -/// One registered extension row: a per-namespace capability declared in -/// a composition root's `extensions.toml`. Always has a WIT import, -/// never an adapter ident (adapter seams are core-only). +/// 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]`. @@ -258,15 +181,17 @@ 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). + /// 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>, } -/// The declared capability names (`required` then `optional`) from the -/// manifest text. A missing or malformed `[capabilities]` section is an -/// error. +/// 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() @@ -297,23 +222,8 @@ pub fn manifest_capabilities(text: &str) -> Result, String> { Ok(names) } -/// The declared `[module] name` from the manifest text, the id the -/// module registers under. Absent or non-string is an error. -pub fn manifest_name(text: &str) -> Result { - let value: toml::Table = text - .parse() - .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; - value - .get("module") - .and_then(|module| module.get("name")) - .ok_or_else(|| "[module].name is missing".to_string())? - .as_str() - .map(str::to_owned) - .ok_or_else(|| "[module].name must be a string".to_string()) -} - -/// The declared `[module] kind` from the manifest text; `None` when -/// absent (the runtime defaults it to the worker). +/// 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() @@ -327,10 +237,10 @@ pub fn manifest_kind(text: &str) -> Result, String> { } } -/// The registered extension rows from an `extensions.toml`. Each -/// `[extensions.]` table carries a WIT `import` and the extra -/// `packages` its resolve path needs. No `[extensions]` section -/// registers nothing. +/// 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() @@ -376,8 +286,9 @@ pub fn manifest_extensions(text: &str) -> Result, String> { .collect() } -/// The extension registry for a build rooted at `start`: the nearest -/// ancestor `extensions.toml`, or `None`. +/// 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 { @@ -390,14 +301,17 @@ pub fn find_extensions_manifest(start: &Path) -> Option { None } -/// The per-module world from the declared capability names (required -/// and optional alike; optional imports still resolve, the host stubs -/// them at load time). `extensions` rows emit after the core rows. An -/// unknown name is an error; an extension name that shadows a core row -/// or another registration is an error. +/// Build the per-module world from the declared capability names +/// (required and optional alike: an optional capability must still be +/// importable, the host decides at load time whether to back or stub +/// it). `extensions` carries the per-namespace rows of the registered +/// extensions, emitted after the core rows. Unknown names are an error +/// so a typo cannot silently drop an import; a registered name that +/// shadows a core row or another registration is an error so a +/// colliding registry cannot emit a duplicate import. pub fn synthesize(declared: &[String], extensions: &[ExtensionRow]) -> Result { for (idx, ext) in extensions.iter().enumerate() { - if CORE.iter().any(|c| c.name.as_str() == ext.name) + if CORE.iter().any(|c| c.name == ext.name) || extensions[..idx].iter().any(|prior| prior.name == ext.name) { return Err(format!( @@ -410,7 +324,7 @@ pub fn synthesize(declared: &[String], extensions: &[ExtensionRow]) -> Result Result Result` before owned `wit/`. A package missing -/// from that tree is an error; outer trees are never consulted, so a -/// group cannot leak WIT it has not vendored. +/// 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> { - let wit = find_wit_tree(start).ok_or_else(|| { - format!( - "no `wit/` tree exists under {} or any ancestor", - start.display() - ) - })?; packages .iter() .map(|package| { let package = package.as_ref(); - resolve_wit_package(&wit, package).ok_or_else(|| { + 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 in {}", - wit.display() + `wit/deps/{package}` nor `wit/{package}` exists under {} or any ancestor", + start.display() ) }) }) .collect() } -/// The consuming crate's manifest directory, the root every crate-local -/// lookup starts from. -pub fn manifest_dir() -> Result { - std::env::var("CARGO_MANIFEST_DIR") - .map(PathBuf::from) - .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string()) -} - -/// [`resolve_wit_packages`] rooted at [`manifest_dir`], as the strings -/// `wit_bindgen::generate!` takes for its `path`. -pub fn manifest_wit_packages>(packages: &[S]) -> Result, String> { - Ok(resolve_wit_packages(&manifest_dir()?, packages)? - .into_iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect()) -} - -/// Whether a type is a plain named path (`Foo`), the only shape a module -/// export type may take. -#[cfg(feature = "macros")] -pub fn is_plain_type(ty: &syn::Type) -> bool { - matches!(ty, syn::Type::Path(tp) if tp.qself.is_none()) -} - -/// The nearest ancestor `wit/` directory of `start`: the crate-local or -/// group-local WIT tree the build resolves against. -fn find_wit_tree(start: &Path) -> Option { +/// 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"); - if wit.is_dir() { - return Some(wit); + for candidate in [wit.join("deps").join(package), wit.join(package)] { + if candidate.is_dir() { + return Some(candidate); + } } dir = cur.parent(); } None } -/// One package directory within a WIT tree: vendored `deps/` -/// before owned ``. -fn resolve_wit_package(wit: &Path, package: &str) -> Option { - [wit.join("deps").join(package), wit.join(package)] - .into_iter() - .find(|candidate| candidate.is_dir()) -} - #[cfg(test)] mod tests { use super::*; - /// The base package set every module world resolves against. + /// 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. @@ -615,45 +494,26 @@ mod tests { assert_eq!( CORE_IFACES, [ - Cap::Chain.as_str(), - Cap::Identity.as_str(), - Cap::LocalStore.as_str(), - Cap::RemoteStore.as_str(), - Cap::Messaging.as_str(), - Cap::Logging.as_str(), + caps::CHAIN, + caps::IDENTITY, + caps::LOCAL_STORE, + caps::REMOTE_STORE, + caps::MESSAGING, + caps::LOGGING, ], ); - assert!(!CORE_IFACES.contains(&Cap::Http.as_str())); - } - - /// Pin the hand-written const accessor to the derived vocabulary. - #[test] - fn cap_accessor_agrees_with_the_derived_vocabulary() { - let names: Vec<&str> = CORE.iter().map(|c| c.name.as_str()).collect(); - assert_eq!(names, Cap::VARIANTS); - for name in Cap::VARIANTS { - assert_eq!(name.parse::().unwrap().as_str(), *name); - } + assert!(!CORE_IFACES.contains(&caps::HTTP)); } #[test] fn fault_labels_are_snake_case_and_distinct() { - for label in FaultLabel::VARIANTS { + for label in fault_labels::ALL { assert!(label.chars().all(|c| c.is_ascii_lowercase() || c == '_')); } - let mut labels = FaultLabel::VARIANTS.to_vec(); + let mut labels = fault_labels::ALL.to_vec(); labels.sort_unstable(); labels.dedup(); - assert_eq!(labels.len(), FaultLabel::VARIANTS.len()); - } - - #[test] - fn fault_label_parses_back_from_its_label() { - for label in FaultLabel::VARIANTS { - let parsed: FaultLabel = label.parse().unwrap(); - assert_eq!(<&'static str>::from(parsed), *label); - } - assert!("nonesuch".parse::().is_err()); + assert_eq!(labels.len(), fault_labels::ALL.len()); } #[test] @@ -670,18 +530,13 @@ mod tests { // `http` has no world import (SDK wasi:http client) and no // adapter; every other core row has both. for cap in CORE { - assert_eq!( - cap.import.is_some(), - cap.adapter.is_some(), - "{}", - cap.name.as_str() - ); + 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.as_str().to_owned()).collect(); + let declared: Vec = CORE.iter().map(|c| c.name.to_string()).collect(); let world = synthesize(&declared, &[]).unwrap(); assert_eq!( world.adapters, @@ -881,70 +736,8 @@ allow = [] #[test] fn missing_package_names_the_paths_tried() { let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join("wit")).unwrap(); let err = resolve_wit_packages(dir.path(), &["pkg"]).unwrap_err(); assert!(err.contains("`pkg` WIT package")); assert!(err.contains("wit/deps/pkg")); } - - #[test] - fn absent_wit_tree_is_an_error() { - let dir = tempfile::tempdir().unwrap(); - let err = resolve_wit_packages(dir.path(), &["pkg"]).unwrap_err(); - assert!(err.contains("no `wit/` tree")); - } - - #[test] - fn nearest_tree_never_falls_through_to_an_outer_one() { - 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/other")).unwrap(); - let err = resolve_wit_packages(&leaf, &["pkg"]).unwrap_err(); - assert!(err.contains("`pkg` WIT package")); - } - - #[test] - fn read_surface_methods_parse() { - for m in [ - "eth_call", - "eth_blockNumber", - "eth_getBalance", - "eth_getLogs", - "eth_getTransactionReceipt", - "net_version", - ] { - assert!(ChainMethod::try_from(m).is_ok(), "{m} should parse"); - } - } - - #[test] - fn signing_and_mutating_methods_have_no_variant() { - for m in [ - "eth_sign", - "eth_signTransaction", - "eth_sendTransaction", - "eth_sendRawTransaction", - "eth_accounts", - "personal_sign", - "personal_unlockAccount", - "admin_peers", - "debug_traceCall", - "miner_start", - "eth_notAMethod", - "", - ] { - assert!(ChainMethod::try_from(m).is_err(), "{m} must be rejected"); - } - } - - #[test] - fn as_str_round_trips_the_wire_name() { - assert_eq!(ChainMethod::EthCall.as_str(), "eth_call"); - assert_eq!( - ChainMethod::try_from(ChainMethod::EthGetBalance.as_str()), - Ok(ChainMethod::EthGetBalance), - ); - } } diff --git a/flake.nix b/flake.nix index a861afc..410ffa1 100644 --- a/flake.nix +++ b/flake.nix @@ -43,7 +43,6 @@ devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ rustToolchain - cargo-nextest wasm-tools wabt just @@ -73,7 +72,6 @@ fi echo "nexum dev shell — $(rustc --version)" command -v sccache >/dev/null && echo " compiler cache: $(sccache --version)" - echo " test runner: $(cargo nextest --version | head -n1)" ${lib.optionalString stdenv.isLinux ''command -v mold >/dev/null && echo " linker (native): mold $(mold --version | head -n1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"''} ''; }; diff --git a/justfile b/justfile index 8e26b5b..e146cb1 100644 --- a/justfile +++ b/justfile @@ -1,4 +1,4 @@ -# Build the bare `nexum` engine binary. +# Build the bare `nexum` engine. build-engine: cargo build -p nexum-cli @@ -6,19 +6,8 @@ build-engine: build-module: cargo build --target wasm32-wasip2 --release -p example -# Build the example modules (price-alert + balance-tracker + http-probe) -# for wasm32-wasip2. -build-examples: - cargo build --target wasm32-wasip2 --release -p price-alert -p balance-tracker -p http-probe - -# Build the test fixture modules for wasm32-wasip2. -build-fixtures: - cargo build --target wasm32-wasip2 --release \ - -p clock-reader -p flaky-bomb -p fuel-bomb -p memory-bomb \ - -p panic-bomb -p slow-host - -# Build everything the E2E suite needs. -build: build-engine build-module build-examples build-fixtures +# 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 @@ -28,39 +17,32 @@ run: build-module build-engine # Run host engine unit tests. test: - cargo nextest run -p nexum-runtime + cargo test -p nexum-runtime # Build module + engine, then run E2E integration tests. test-e2e: build-module build-engine - cargo nextest run -p nexum-runtime supervisor::tests::e2e - -# Format the workspace. -fmt: - cargo fmt --all + cargo test -p nexum-runtime supervisor::tests::e2e -# Lint the workspace. -lint: - cargo clippy --workspace --all-targets --all-features -- -D warnings +# Zero-leak gate: host-layer crate graphs, runtime charter-symbol and +# router-field scans, and the nexum:host WIT leaf and foreign-namespace +# scans. Blocking in CI. +check-venue-agnostic: + ./scripts/check-venue-agnostic.sh -# Check the workspace quickly. +# Check the workspace. check: cargo check --target wasm32-wasip2 -p example - cargo check -p nexum-runtime - cargo check -p nexum-cli + 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 via nextest plus the doctests, all under the `-D warnings` the -# CI workflow sets globally. +# suite, all under the `-D warnings` the CI workflow sets globally. ci: #!/usr/bin/env bash set -euo pipefail - # Append -D warnings without clobbering the devshell's flags (mold linker, - # set in flake.nix), so the local run keeps fast native linking. RUSTC_WRAPPER - # is already sccache from the devshell shellHook. - export RUSTFLAGS="${RUSTFLAGS:-} -D warnings" - export RUSTDOCFLAGS="${RUSTDOCFLAGS:-} -D warnings" + export RUSTFLAGS="-D warnings" + export RUSTDOCFLAGS="-D warnings" cargo fmt --all --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo doc --workspace --no-deps @@ -68,7 +50,4 @@ ci: -p example -p price-alert -p balance-tracker -p http-probe \ -p clock-reader -p flaky-bomb -p fuel-bomb -p memory-bomb \ -p panic-bomb -p slow-host - # nextest for the suite (as CI does); doctests run separately since nextest - # does not cover them. - cargo nextest run --workspace --all-features --no-fail-fast - cargo test --doc --workspace --all-features + cargo test --workspace --all-features --no-fail-fast diff --git a/modules/example/Cargo.toml b/modules/example/Cargo.toml index 1931181..79ac1ea 100644 --- a/modules/example/Cargo.toml +++ b/modules/example/Cargo.toml @@ -13,4 +13,5 @@ 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/modules/example/src/lib.rs b/modules/example/src/lib.rs index cc16f55..dc75149 100644 --- a/modules/example/src/lib.rs +++ b/modules/example/src/lib.rs @@ -1,77 +1,68 @@ //! # example (reference Shepherd module) //! -//! Minimal reference module: one handler per event, each logging a -//! one-line summary. The smallest demonstration of -//! `#[nexum_sdk::module]`, which supplies the wit-bindgen call, host -//! adapter, dispatch, and `export!`. +//! The minimal reference module: one handler per event, each logging a +//! one-line summary via `tracing`. 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}; +use nexum::host::types; struct ExampleModule; #[nexum_sdk::module] impl ExampleModule { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { + install_tracing(); 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})"), - ); + tracing::info!("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 - ), + tracing::info!( + "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()), - ); + tracing::info!("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), - ); + tracing::info!("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), - ); + tracing::info!("message on topic {}", msg.content_topic); Ok(()) } - fn on_custom(event: types::CustomEvent) -> Result<(), Fault> { - logging::log( - logging::Level::Info, - &format!( - "custom event kind {} ({} payload bytes)", - event.kind, - event.payload.len(), - ), + 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()))?; + tracing::info!( + "intent status update from venue {}: {:?} ({} receipt bytes)", + update.venue, + body.status, + update.receipt.len(), ); Ok(()) } diff --git a/modules/examples/balance-tracker/src/lib.rs b/modules/examples/balance-tracker/src/lib.rs index 766d52f..ee3ae41 100644 --- a/modules/examples/balance-tracker/src/lib.rs +++ b/modules/examples/balance-tracker/src/lib.rs @@ -1,21 +1,39 @@ //! # balance-tracker (example Shepherd module) //! -//! On each block reads `eth_getBalance` for every `[config].addresses` -//! entry, persists the last value under `balance:{addr}`, and warns -//! when a balance moves more than `[config].change_threshold` wei. -//! Pure logic lives in `logic`; `lib.rs` is the -//! `#[nexum_sdk::module]` glue. +//! 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 logic; +mod strategy; use std::sync::OnceLock; use nexum::host::types; -static SETTINGS: OnceLock = OnceLock::new(); +static SETTINGS: OnceLock = OnceLock::new(); // `WitBindgenHost`, the fault `From` impls, and `install_tracing` (used // by the handlers) are generated by the attribute alongside the @@ -26,7 +44,7 @@ struct BalanceTracker; impl BalanceTracker { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = logic::parse_config(&config)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "balance-tracker init: {} addresses, threshold={} wei", cfg.addresses.len(), @@ -40,6 +58,6 @@ impl BalanceTracker { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - logic::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(Into::into) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(Into::into) } } diff --git a/modules/examples/balance-tracker/src/logic.rs b/modules/examples/balance-tracker/src/strategy.rs similarity index 86% rename from modules/examples/balance-tracker/src/logic.rs rename to modules/examples/balance-tracker/src/strategy.rs index c475eec..f1f5bf7 100644 --- a/modules/examples/balance-tracker/src/logic.rs +++ b/modules/examples/balance-tracker/src/strategy.rs @@ -1,26 +1,42 @@ -//! Pure logic for the balance-tracker module. +//! Pure strategy logic for the balance-tracker module. //! -//! World access flows through the [`ChainHost`] + [`LocalStoreHost`] -//! trait seam; `lib.rs` hands [`on_block`] a `WitBindgenHost`, tests a +//! 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}; -/// Settings parsed from `[config]` at `init`. +/// Resolved settings parsed from `[config]` at `init` and read on +/// every event. #[derive(Clone, Debug)] pub struct Settings { - /// Addresses to track. + /// 0x-prefixed addresses to track. pub addresses: Vec
, - /// Alert fires when a balance moves more than this many wei. + /// Change threshold in wei; an alert fires when the delta exceeds + /// it. pub change_threshold: U256, } -/// Poll every tracked address on a new block, warn on threshold-crossing -/// diffs, persist the latest reading. A single flaky `eth_getBalance` is -/// logged and skipped, not fatal to the loop. +/// 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, @@ -34,8 +50,9 @@ pub fn on_block( Ok(()) } -/// Fetch one address's balance, warn if the diff against the stored -/// value crosses `threshold`, then persist it under `balance:{addr}`. +/// 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, @@ -73,8 +90,10 @@ fn fetch_balance(host: &H, chain_id: u64, addr: Address) -> Result }) } -/// Parse the `"0x..."` JSON string `eth_getBalance` returns; `None` on -/// shape mismatch. +// ---- 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 @@ -142,6 +161,8 @@ mod tests { 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. @@ -225,6 +246,8 @@ mod tests { assert!(message.contains("change_threshold")); } + // ---- MockHost-driven coverage of check_one / fetch_balance ---- + fn one_addr_settings(threshold_wei: u128) -> Settings { Settings { addresses: vec![address!("70997970C51812dc3A010C7d01b50e0d17dc79C8")], diff --git a/modules/examples/http-probe/src/lib.rs b/modules/examples/http-probe/src/lib.rs index 7290114..672f000 100644 --- a/modules/examples/http-probe/src/lib.rs +++ b/modules/examples/http-probe/src/lib.rs @@ -1,24 +1,47 @@ //! # http-probe (example Shepherd module) //! -//! On each matching block fetches the allowlisted `[config].probe_url` -//! over wasi:http and logs its status, then fetches `[config].denied_url` -//! and asserts the `[capabilities.http].allow` gate denies it. -//! Demonstrates the SDK `http::Fetch` seam and the allowlist denial -//! path. Pure logic lives in `logic`; `lib.rs` is the -//! `#[nexum_sdk::module]` glue. +//! 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 logic; +mod strategy; use std::sync::OnceLock; use nexum::host::types; -static SETTINGS: OnceLock = OnceLock::new(); +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 @@ -29,7 +52,7 @@ struct HttpProbe; impl HttpProbe { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = logic::parse_config(&config)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "http-probe init: probe_url={} denied_url={} every_n_blocks={}", cfg.probe_url, @@ -44,6 +67,6 @@ impl HttpProbe { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - logic::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number).map_err(Into::into) + strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number).map_err(Into::into) } } diff --git a/modules/examples/http-probe/src/logic.rs b/modules/examples/http-probe/src/strategy.rs similarity index 86% rename from modules/examples/http-probe/src/logic.rs rename to modules/examples/http-probe/src/strategy.rs index 374c017..3d3d80b 100644 --- a/modules/examples/http-probe/src/logic.rs +++ b/modules/examples/http-probe/src/strategy.rs @@ -1,25 +1,31 @@ -//! Pure logic for the http-probe module. +//! Pure strategy logic for the http-probe module. //! -//! HTTP flows through the [`Fetch`] seam; `lib.rs` hands [`on_block`] -//! `nexum_sdk::http::WasiFetch`, tests hand it a stub fetcher. +//! 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}; -/// Settings parsed from `[config]` at `init`. +/// Resolved settings parsed from `[config]` at `init` and read on +/// every event. #[derive(Clone, Debug)] pub struct Settings { - /// Allowlisted URL fetched on every matching block. + /// URL fetched on every matching block; its host must be on the + /// module's allowlist. pub probe_url: String, - /// Off-list URL; anything other than a denial is a failure. + /// 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, } -/// Probe the allowlisted URL, then verify the off-list URL is denied. -/// `Err` when either leg misbehaves. +/// 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, @@ -32,8 +38,8 @@ pub fn on_block( probe_denied(fetcher, &settings.denied_url) } -/// Fetch the allowlisted URL and log its status; a fetch error is a -/// fault. +/// 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)?) @@ -46,8 +52,8 @@ fn probe_allowlisted(fetcher: &F, url: &str) -> Result<(), Fault> { Ok(()) } -/// Fetch the off-list URL and demand [`FetchError::Denied`]; any other -/// outcome means the allowlist gate did not hold. +/// 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) => { @@ -64,14 +70,16 @@ fn probe_denied(fetcher: &F, url: &str) -> Result<(), Fault> { } } -/// Body-less GET for `url`; a malformed URL is an invalid-input fault. +/// 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 case. +/// 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 { @@ -127,7 +135,8 @@ mod tests { use super::*; - /// Stub fetcher: replays canned outcomes in order, records URLs. + /// Stub fetcher: replays canned outcomes in call order and records + /// the requested URLs. struct StubFetch { outcomes: RefCell>, FetchError>>>, urls: RefCell>, diff --git a/modules/examples/price-alert/Cargo.toml b/modules/examples/price-alert/Cargo.toml index c31f938..30bb528 100644 --- a/modules/examples/price-alert/Cargo.toml +++ b/modules/examples/price-alert/Cargo.toml @@ -17,6 +17,6 @@ wit-bindgen = { version = "0.59", default-features = false, features = ["macros" [dev-dependencies] nexum-sdk-test = { path = "../../../crates/nexum-sdk-test" } -# Only used by tests in `logic.rs` to encode a synthetic oracle +# 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/modules/examples/price-alert/src/lib.rs b/modules/examples/price-alert/src/lib.rs index a9d16b3..f8e3a82 100644 --- a/modules/examples/price-alert/src/lib.rs +++ b/modules/examples/price-alert/src/lib.rs @@ -1,23 +1,54 @@ //! # price-alert (example Shepherd module) //! -//! Polls a Chainlink oracle on each block and warns when the price -//! crosses a `[config]`-supplied threshold on the configured side. -//! Demonstrates `chain::request` with an `alloy_sol_types` ABI decode -//! and the `nexum_sdk::chain` helpers. Pure logic lives in `logic`; -//! `lib.rs` is the `#[nexum_sdk::module]` glue. +//! 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 logic; +mod strategy; use std::sync::OnceLock; use nexum::host::types; -static SETTINGS: OnceLock = OnceLock::new(); +static SETTINGS: OnceLock = OnceLock::new(); // `WitBindgenHost`, the fault `From` impls, and `install_tracing` (used // by the handlers) are generated by the attribute alongside the @@ -28,7 +59,7 @@ struct PriceAlert; impl PriceAlert { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = logic::parse_config(&config)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "price-alert init: oracle={:#x} threshold={} direction={:?} every_n_blocks={}", cfg.oracle_address, @@ -44,6 +75,6 @@ impl PriceAlert { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - logic::on_block(&WitBindgenHost, block.chain_id, cfg, block.number).map_err(Into::into) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number).map_err(Into::into) } } diff --git a/modules/examples/price-alert/src/logic.rs b/modules/examples/price-alert/src/strategy.rs similarity index 85% rename from modules/examples/price-alert/src/logic.rs rename to modules/examples/price-alert/src/strategy.rs index 3c5c0bc..a6c773e 100644 --- a/modules/examples/price-alert/src/logic.rs +++ b/modules/examples/price-alert/src/strategy.rs @@ -1,8 +1,14 @@ -//! Pure logic for the price-alert module. +//! Pure strategy logic for the price-alert module. //! -//! World access flows through the [`ChainHost`] + [`LoggingHost`] trait -//! seam, the module's two declared capabilities; `lib.rs` hands -//! [`on_block`] a `WitBindgenHost`, tests a `nexum_sdk_test::MockHost`. +//! 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; @@ -10,7 +16,8 @@ use nexum_sdk::config::{self, ConfigError}; use nexum_sdk::host::{ChainHost, Fault, LoggingHost}; use nexum_sdk::prelude::Address; -/// Configuration parsed from `[config]` at `init`. +/// Resolved configuration, parsed from `module.toml::[config]` at +/// `init` and read on every `on_event`. #[derive(Debug)] pub struct Settings { /// Chainlink AggregatorV3Interface address. @@ -33,9 +40,13 @@ pub enum Direction { Below, } -/// Poll the oracle and warn on a threshold crossing. Recoverable -/// upstream failures (oracle RPC error, decode failure) log a Warn and -/// return `Ok` so the next block re-polls. +/// 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, @@ -69,7 +80,7 @@ pub fn on_block( } /// `true` when `answer` is on the firing side of `threshold` per -/// `direction`. +/// `direction`. Pure - exercised by the unit tests. pub fn classify(answer: I256, threshold: I256, direction: Direction) -> bool { match direction { Direction::Above => answer >= threshold, @@ -77,7 +88,11 @@ pub fn classify(answer: I256, threshold: I256, direction: Direction) -> bool { } } -/// Parse `[config]` into a typed [`Settings`]. +/// 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)? @@ -124,12 +139,16 @@ pub fn parse_config(entries: &[(String, String)]) -> Result { }) } -/// Lift a free-text detail into a [`Fault::InvalidInput`]. +/// 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 [`ConfigError`] into a [`Fault::InvalidInput`]. +/// 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()) } @@ -156,8 +175,8 @@ mod tests { } } - /// Encode a `latestRoundData` return as the `"0x..."` JSON string - /// `chain::request` yields. + /// 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 { @@ -178,6 +197,8 @@ mod tests { host.chain.respond_to("eth_call", ¶ms, response); } + // ---- pure helpers ---- + #[test] fn classify_below_fires_at_or_under_threshold() { let t = I256::try_from(100_i32).unwrap(); @@ -274,6 +295,8 @@ mod tests { assert!(message.contains("oracle_address")); } + // ---- strategy behaviour against MockHost ---- + #[test] fn on_block_idle_when_price_above_below_trigger() { let host = MockHost::new(); @@ -342,13 +365,13 @@ mod tests { Err(ChainError::Fault(Fault::Timeout)), ); - // The module returns Ok so the supervisor moves on. + // 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 module returns before emitting + // No facade event at all: the strategy returns before emitting // either the ok or TRIGGERED line. assert!(logs.is_empty()); } diff --git a/modules/fixtures/clock-reader/Cargo.toml b/modules/fixtures/clock-reader/Cargo.toml index 6359fcc..fd709ff 100644 --- a/modules/fixtures/clock-reader/Cargo.toml +++ b/modules/fixtures/clock-reader/Cargo.toml @@ -10,4 +10,6 @@ description = "Test fixture: on every event reads the WASI wall clock through st 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/modules/fixtures/clock-reader/src/lib.rs b/modules/fixtures/clock-reader/src/lib.rs index 21ee7b0..f174876 100644 --- a/modules/fixtures/clock-reader/src/lib.rs +++ b/modules/fixtures/clock-reader/src/lib.rs @@ -1,10 +1,14 @@ //! # clock-reader (test fixture) //! -//! Logs `SystemTime::now()` as whole seconds since the epoch. Under -//! `wasm32-wasip2` the read routes to `wasi:clocks/wall-clock`, which -//! the supervisor virtualizes per store, so a test under a pinned clock -//! override can assert the guest observed the overridden time. -//! Test-only. +//! 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)] @@ -19,15 +23,15 @@ wit_bindgen::generate!({ generate_all, }); -use nexum::host::{logging, types}; +use nexum::host::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"); + // Installs the nexum-sdk tracing bridge, so log via `tracing`. + nexum_sdk::install_host_tracing!(); + tracing::info!("clock-reader init"); Ok(()) } @@ -39,7 +43,7 @@ impl Guest for ClockReader { .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - logging::log(logging::Level::Info, &format!("clock wall {secs}")); + tracing::info!("clock wall {secs}"); Ok(()) } } diff --git a/modules/fixtures/flaky-bomb/Cargo.toml b/modules/fixtures/flaky-bomb/Cargo.toml index f040e84..25d5a2a 100644 --- a/modules/fixtures/flaky-bomb/Cargo.toml +++ b/modules/fixtures/flaky-bomb/Cargo.toml @@ -10,4 +10,6 @@ description = "Evil-by-design fixture: traps on the first N events (via unreacha 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/modules/fixtures/flaky-bomb/src/lib.rs b/modules/fixtures/flaky-bomb/src/lib.rs index 49df381..65e74fe 100644 --- a/modules/fixtures/flaky-bomb/src/lib.rs +++ b/modules/fixtures/flaky-bomb/src/lib.rs @@ -1,9 +1,21 @@ //! # flaky-bomb (test fixture) //! -//! Traps on the first `[config].fail_first_n` events and succeeds -//! after, driving the supervisor's exponential-backoff restart policy. -//! The attempt counter rides local-store so it survives restarts. -//! Test-only. +//! 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)] @@ -18,9 +30,10 @@ wit_bindgen::generate!({ use std::sync::OnceLock; -use nexum::host::{local_store, logging, types}; +use nexum::host::{local_store, types}; -/// Consecutive events to trap on, from `[config].fail_first_n`; default 1. +/// 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"; @@ -35,12 +48,9 @@ impl Guest for FlakyBomb { .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)"), - ); + // Installs the nexum-sdk tracing bridge, so log via `tracing`. + nexum_sdk::install_host_tracing!(); + tracing::info!("flaky-bomb init: will trap on the first {n} event(s)"); Ok(()) } @@ -60,10 +70,7 @@ impl Guest for FlakyBomb { 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"), - ); + tracing::warn!("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 @@ -76,10 +83,7 @@ impl Guest for FlakyBomb { std::hint::black_box(x); } } - logging::log( - logging::Level::Info, - &format!("flaky-bomb attempt {attempt}: ok, recovered"), - ); + tracing::info!("flaky-bomb attempt {attempt}: ok, recovered"); Ok(()) } } diff --git a/modules/fixtures/fuel-bomb/Cargo.toml b/modules/fixtures/fuel-bomb/Cargo.toml index 9692cc4..79ec4b2 100644 --- a/modules/fixtures/fuel-bomb/Cargo.toml +++ b/modules/fixtures/fuel-bomb/Cargo.toml @@ -10,4 +10,6 @@ description = "Evil-by-design fixture: on every event runs an unbounded loop to 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/modules/fixtures/fuel-bomb/src/lib.rs b/modules/fixtures/fuel-bomb/src/lib.rs index 6ab9e74..23ec697 100644 --- a/modules/fixtures/fuel-bomb/src/lib.rs +++ b/modules/fixtures/fuel-bomb/src/lib.rs @@ -1,8 +1,13 @@ //! # fuel-bomb (test fixture) //! -//! Exhausts the fuel budget on every `on_event` via an unbounded loop. -//! The engine traps with `OutOfFuel`; the supervisor must catch it, -//! mark the module dead, and keep dispatching others. Test-only. +//! 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)] @@ -15,15 +20,15 @@ wit_bindgen::generate!({ generate_all, }); -use nexum::host::{logging, types}; +use nexum::host::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)"); + // Installs the nexum-sdk tracing bridge, so log via `tracing`. + nexum_sdk::install_host_tracing!(); + tracing::info!("fuel-bomb init (will exhaust fuel)"); Ok(()) } diff --git a/modules/fixtures/memory-bomb/Cargo.toml b/modules/fixtures/memory-bomb/Cargo.toml index 09c352f..4daff4c 100644 --- a/modules/fixtures/memory-bomb/Cargo.toml +++ b/modules/fixtures/memory-bomb/Cargo.toml @@ -10,4 +10,6 @@ description = "Evil-by-design fixture: on every event allocates past the 64 MiB 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/modules/fixtures/memory-bomb/src/lib.rs b/modules/fixtures/memory-bomb/src/lib.rs index 02426ab..d1ffd4b 100644 --- a/modules/fixtures/memory-bomb/src/lib.rs +++ b/modules/fixtures/memory-bomb/src/lib.rs @@ -1,9 +1,12 @@ //! # memory-bomb (test fixture) //! -//! Allocates past the default 64 MiB per-module memory cap on every -//! `on_event`. The `StoreLimits` reject the grow, the module traps, the -//! supervisor marks it dead, and other modules keep dispatching. -//! Test-only. +//! 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)] @@ -16,18 +19,15 @@ wit_bindgen::generate!({ generate_all, }); -use nexum::host::{logging, types}; +use nexum::host::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)", - ); + // Installs the nexum-sdk tracing bridge, so log via `tracing`. + nexum_sdk::install_host_tracing!(); + tracing::info!("memory-bomb init (will exhaust memory)"); Ok(()) } diff --git a/modules/fixtures/panic-bomb/src/lib.rs b/modules/fixtures/panic-bomb/src/lib.rs index 8d9e823..67debf6 100644 --- a/modules/fixtures/panic-bomb/src/lib.rs +++ b/modules/fixtures/panic-bomb/src/lib.rs @@ -1,10 +1,13 @@ //! # panic-bomb (test fixture) //! //! Installs the nexum-sdk tracing facade (subscriber + panic hook) in -//! `init` and panics on every `on_event`. The hook forwards the panic -//! to stderr and the host logging call before the trap reaches the -//! supervisor, so one death leaves Stderr, HostInterface, and Panic -//! records. Test-only. +//! `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)] @@ -17,36 +20,13 @@ wit_bindgen::generate!({ generate_all, }); -use nexum::host::{logging, types}; - -/// Routes facade lines to the bound host logging import. -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); - } -} +use nexum::host::types; struct PanicBomb; impl Guest for PanicBomb { fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { - nexum_sdk::tracing::init(HostLogSink); + nexum_sdk::install_host_tracing!(); tracing::info!("panic-bomb init (will panic)"); Ok(()) } diff --git a/modules/fixtures/slow-host/Cargo.toml b/modules/fixtures/slow-host/Cargo.toml index 8206cab..cd488c5 100644 --- a/modules/fixtures/slow-host/Cargo.toml +++ b/modules/fixtures/slow-host/Cargo.toml @@ -10,4 +10,6 @@ description = "Evil-by-design fixture: on_event issues a single chain::request h 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/modules/fixtures/slow-host/src/lib.rs b/modules/fixtures/slow-host/src/lib.rs index 7d3f405..42a00a8 100644 --- a/modules/fixtures/slow-host/src/lib.rs +++ b/modules/fixtures/slow-host/src/lib.rs @@ -1,12 +1,26 @@ //! # slow-host (test fixture) //! -//! Issues one `chain::request` per event and returns `Ok` regardless of -//! its result. Fuel and epoch interruption only meter wasm instructions, -//! not time suspended inside a host call, so the test parks the first -//! `request` past a short `event_deadline_secs`: the wall-clock deadline -//! must fire, the supervisor drop the suspended call, mark the module -//! dead, and reinstantiate it. The next dispatch answers promptly and -//! recovers. Test-only. +//! 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)] @@ -19,15 +33,15 @@ wit_bindgen::generate!({ generate_all, }); -use nexum::host::{chain, logging, types}; +use nexum::host::{chain, 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"); + // Installs the nexum-sdk tracing bridge, so log via `tracing`. + nexum_sdk::install_host_tracing!(); + tracing::info!("slow-host init"); Ok(()) } @@ -37,7 +51,7 @@ impl Guest for SlowHost { // 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"); + tracing::info!("slow-host on_event returned"); Ok(()) } } diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh new file mode 100755 index 0000000..d7658e4 --- /dev/null +++ b/scripts/check-venue-agnostic.sh @@ -0,0 +1,103 @@ +#!/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/tools/load-gen/Cargo.toml b/tools/load-gen/Cargo.toml deleted file mode 100644 index c5a9e28..0000000 --- a/tools/load-gen/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "load-gen" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[[bin]] -name = "load-gen" -path = "src/main.rs" - -[dependencies] -anyhow.workspace = true -clap.workspace = true -alloy-primitives.workspace = true -alloy-provider = { workspace = true, features = ["ws"] } -alloy-rpc-types-eth.workspace = true -alloy-sol-types.workspace = true -alloy-transport-ws.workspace = true -futures.workspace = true -serde_json = { workspace = true, features = ["std"] } -tokio.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true diff --git a/tools/load-gen/src/main.rs b/tools/load-gen/src/main.rs deleted file mode 100644 index da58399..0000000 --- a/tools/load-gen/src/main.rs +++ /dev/null @@ -1,474 +0,0 @@ -//! Anvil-side load generator for the runtime load test. -//! -//! Connects to an Anvil fork of Sepolia, impersonates the pinned test -//! EOA, and submits N `ComposableCoW.create` plus M -//! `CoWSwapEthFlow.createOrder` calls per block; the resulting -//! `ConditionalOrderCreated` and `OrderPlacement` events are what -//! twap-monitor and ethflow-watcher dispatch on. `--help` lists the -//! knobs. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] - -// `alloy-transport-ws` is pulled into the workspace via -// `alloy-provider`'s `pubsub` feature; declared explicitly here so the -// Cargo.toml dependency surface mirrors what the engine pins. -use alloy_transport_ws as _; - -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -use alloy_primitives::{Address, B256, Bytes, U256, address, b256}; -use alloy_provider::{Provider, ProviderBuilder, WsConnect}; -use alloy_rpc_types_eth::TransactionRequest; -use alloy_sol_types::{SolCall, SolValue, sol}; -use clap::Parser; -use futures::StreamExt; -use tracing::{info, warn}; - -// --- Pinned identities (Sepolia) ----------------------------------- - -const EOA: Address = address!("7bF140727D27ea64b607E042f1225680B40ECa6A"); -const COMPOSABLE_COW: Address = address!("fdaFc9d1902f4e0b84f65F49f244b32b31013b74"); -const TWAP_HANDLER: Address = address!("6cF1e9cA41f7611dEf408122793c358a3d11E5a5"); -const ETHFLOW: Address = address!("ba3cb449bd2b4adddbc894d8697f5170800eadec"); -const WETH: Address = address!("fFf9976782d46CC05630D1f6eBAb18b2324d6B14"); -const COW_TOKEN: Address = address!("0625aFB445C3B6B7B929342a04A22599fd5dBB59"); - -const EMPTY_APP_DATA: B256 = - b256!("b48d38f93eaa084033fc5970bf96e559c33c4cdc07d889ab00b4d63f9590739d"); - -// --- ABI shims (load-gen only needs the call signatures) ----------- - -sol! { - #[allow(missing_docs)] - struct ConditionalOrderParams { - address handler; - bytes32 salt; - bytes staticInput; - } - - #[allow(missing_docs)] - function create(ConditionalOrderParams params, bool dispatch); - - #[allow(missing_docs)] - struct EthFlowOrderData { - address buyToken; - address receiver; - uint256 sellAmount; - uint256 buyAmount; - bytes32 appData; - uint256 feeAmount; - uint32 validTo; - bool partiallyFillable; - int64 quoteId; - } - - #[allow(missing_docs)] - function createOrder(EthFlowOrderData order); -} - -#[derive(Debug, Parser)] -#[command( - name = "load-gen", - about = "Anvil-side load generator for shepherd's load test." -)] -struct Cli { - /// Anvil WebSocket endpoint. - #[arg(long, default_value = "ws://localhost:8545")] - anvil: String, - - /// `ComposableCoW.create(...)` calls submitted per new block. - #[arg(long, default_value_t = 5)] - twap_per_block: u32, - - /// `CoWSwapEthFlow.createOrder(...)` calls submitted per new block. - #[arg(long, default_value_t = 5)] - ethflow_per_block: u32, - - /// Wall-clock minutes the loop should run before exiting. - #[arg(long, default_value_t = 5)] - duration_min: u64, - - /// Address Anvil impersonates. Defaults to the pinned Sepolia test - /// EOA; ignored when `--parallel > 1`, which uses synthetic - /// per-worker EOAs so nonce serialisation does not gate throughput. - #[arg(long, default_value_t = EOA)] - eoa: Address, - - /// Parallel workers, each with its own synthetic EOA and WS - /// connection. Events per block = `parallel * (twap + ethflow)`. - #[arg(long, default_value_t = 1)] - parallel: u32, -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .with_target(false) - .init(); - - let cli = Cli::parse(); - let parallel = cli.parallel.max(1); - - info!( - parallel, - twap_per_block = cli.twap_per_block, - ethflow_per_block = cli.ethflow_per_block, - duration_min = cli.duration_min, - "load-gen running" - ); - - // Build per-worker EOAs. Worker 0 reuses the CLI-provided EOA so - // single-worker runs match the historic behaviour exactly; - // workers 1..N use deterministic synthetic addresses so each gets - // an independent nonce stream on Anvil. - let mut eoas: Vec
= Vec::with_capacity(parallel as usize); - eoas.push(cli.eoa); - for i in 1..parallel { - let mut bytes = [0u8; 20]; - bytes[19] = (i & 0xff) as u8; - bytes[18] = ((i >> 8) & 0xff) as u8; - // Tag bytes[0] with 0x57 ('W' for worker) so synthetic EOAs are - // easy to distinguish from anvil's default unlocked set. - bytes[0] = 0x57; - eoas.push(Address::from(bytes)); - } - - let deadline = Instant::now() + Duration::from_secs(cli.duration_min * 60); - let mut joinset: tokio::task::JoinSet> = - tokio::task::JoinSet::new(); - - for (idx, eoa) in eoas.into_iter().enumerate() { - let anvil = cli.anvil.clone(); - let twap_n = cli.twap_per_block; - let ethflow_m = cli.ethflow_per_block; - joinset.spawn(async move { - worker_loop(idx as u32, anvil, eoa, twap_n, ethflow_m, deadline).await - }); - } - - let mut totals = WorkerStats::default(); - let mut workers_finished = 0u32; - while let Some(res) = joinset.join_next().await { - match res { - Ok(Ok(stats)) => { - totals.merge(&stats); - workers_finished += 1; - } - Ok(Err(e)) => warn!(error = %e, "worker failed"), - Err(e) => warn!(error = %e, "worker panicked"), - } - } - - info!( - workers_finished, - blocks_seen = totals.blocks_seen, - twap_attempted = totals.twap_attempted, - twap_ok = totals.twap_ok, - ethflow_attempted = totals.ethflow_attempted, - ethflow_ok = totals.ethflow_ok, - "load-gen finished" - ); - Ok(()) -} - -#[derive(Debug, Default, Clone)] -struct WorkerStats { - blocks_seen: u64, - twap_attempted: u64, - twap_ok: u64, - ethflow_attempted: u64, - ethflow_ok: u64, -} - -impl WorkerStats { - fn merge(&mut self, other: &Self) { - self.blocks_seen += other.blocks_seen; - self.twap_attempted += other.twap_attempted; - self.twap_ok += other.twap_ok; - self.ethflow_attempted += other.ethflow_attempted; - self.ethflow_ok += other.ethflow_ok; - } -} - -async fn worker_loop( - idx: u32, - anvil: String, - eoa: Address, - twap_n: u32, - ethflow_m: u32, - deadline: Instant, -) -> anyhow::Result { - let provider = ProviderBuilder::new() - .connect_ws(WsConnect::new(&anvil)) - .await?; - provider - .raw_request::<_, ()>( - "anvil_impersonateAccount".into(), - serde_json::json!([format!("{:?}", eoa)]), - ) - .await?; - let funded = format!("0x{:x}", U256::from(10u128.pow(24))); - provider - .raw_request::<_, ()>( - "anvil_setBalance".into(), - serde_json::json!([format!("{:?}", eoa), funded]), - ) - .await?; - let starting_nonce: u64 = provider - .raw_request::<_, String>( - "eth_getTransactionCount".into(), - serde_json::json!([format!("{:?}", eoa), "latest"]), - ) - .await - .map_err(|e| anyhow::anyhow!("get nonce: {e}")) - .and_then(|hex| { - u64::from_str_radix(hex.trim_start_matches("0x"), 16) - .map_err(|e| anyhow::anyhow!("parse nonce {hex:?}: {e}")) - })?; - info!(worker = idx, eoa = %eoa, starting_nonce, "worker started"); - - let mut block_stream = provider.subscribe_blocks().await?.into_stream(); - let mut nonce = starting_nonce; - // Disjoint salt space per worker via a 96-bit-shifted prefix - the - // salt is bytes32 so the upper bits stay free. - let mut salt_counter = (u128::from(idx) + 1) << 96; - // For ethflow_seq the value flows into `BASE_SELL_AMOUNT + seq` and - // becomes the tx's `msg.value`. We MUST keep this small so the - // impersonated EOA's 1_000_000 ETH balance can cover it (the - // first parallel-mode run shifted by 96 and produced a 7.9e28 wei - // sellAmount, blowing past the balance and reverting every - // EthFlow tx). Workers get a 10_000-wide window each, plenty for - // a 2 minute test at 5 ethflow/block. - let mut ethflow_seq: u128 = u128::from(idx) * 10_000; - let mut stats = WorkerStats::default(); - - loop { - tokio::select! { - biased; - _ = tokio::signal::ctrl_c() => break, - _ = tokio::time::sleep_until(deadline.into()) => break, - maybe_block = block_stream.next() => { - let Some(header) = maybe_block else { - warn!(worker = idx, "block stream ended unexpectedly"); - break; - }; - stats.blocks_seen += 1; - let block_ts = header.timestamp; - let n_ok = submit_twaps(&provider, eoa, twap_n, &mut salt_counter, &mut nonce, block_ts).await; - stats.twap_attempted += u64::from(twap_n); - stats.twap_ok += n_ok; - let m_ok = submit_ethflows(&provider, eoa, ethflow_m, &mut ethflow_seq, &mut nonce).await; - stats.ethflow_attempted += u64::from(ethflow_m); - stats.ethflow_ok += m_ok; - if stats.blocks_seen.is_multiple_of(5) { - info!( - worker = idx, - block = header.number, - twap = format!("{}/{}", stats.twap_ok, stats.twap_attempted), - ethflow = format!("{}/{}", stats.ethflow_ok, stats.ethflow_attempted), - "progress" - ); - } - } - } - } - Ok(stats) -} - -async fn submit_twaps( - provider: &P, - eoa: Address, - n: u32, - salt_counter: &mut u128, - nonce: &mut u64, - block_ts: u64, -) -> u64 { - let mut ok = 0u64; - for _ in 0..n { - *salt_counter += 1; - let salt = salt_from_counter(*salt_counter); - let calldata = encode_twap_create(salt, block_ts); - match send_impersonated(provider, eoa, COMPOSABLE_COW, calldata, U256::ZERO, *nonce).await { - Ok(_) => { - ok += 1; - *nonce += 1; - } - Err(e) => warn!(error = %e, nonce = *nonce, "twap create failed"), - } - } - ok -} - -async fn submit_ethflows( - provider: &P, - eoa: Address, - m: u32, - seq: &mut u128, - nonce: &mut u64, -) -> u64 { - // EthFlow.createOrder dedups by the on-chain GPv2 OrderUid which - // is derived from `(buyToken, receiver, sellAmount, buyAmount, - // appData, feeAmount, validTo, partiallyFillable)` - NOT quoteId. - // We vary `sellAmount` by 1 wei per call so the resulting UIDs - // are unique and the contract does not reject with - // `OrderIsAlreadyOwned`. - const BASE_SELL_AMOUNT: u128 = 10_000_000_000; // 1e-8 ETH - let mut ok = 0u64; - for _ in 0..m { - *seq += 1; - let sell_amount = BASE_SELL_AMOUNT + *seq; - let calldata = encode_ethflow_create_order(eoa, sell_amount, 0); - match send_impersonated( - provider, - eoa, - ETHFLOW, - calldata, - U256::from(sell_amount), - *nonce, - ) - .await - { - Ok(_) => { - ok += 1; - *nonce += 1; - } - Err(e) => warn!(error = %e, nonce = *nonce, "ethflow createOrder failed"), - } - } - ok -} - -fn salt_from_counter(n: u128) -> B256 { - let mut bytes = [0u8; 32]; - bytes[16..].copy_from_slice(&n.to_be_bytes()); - B256::from(bytes) -} - -/// Encode `ComposableCoW.create((handler, salt, staticInput), true)` -/// with `t0 = block_ts - 60` so part 0 is Ready immediately. -fn encode_twap_create(salt: B256, block_ts: u64) -> Bytes { - let static_input = ( - WETH, - COW_TOKEN, - EOA, // receiver - load test does not settle - U256::from(1_000_000_000_000_000u128), // partSellAmount = 0.001 WETH - U256::from(500_000_000_000_000_000u128), // minPartLimit = 0.5 COW - U256::from(block_ts.saturating_sub(60)), // t0 = now - 60 - U256::from(2u8), // n - U256::from(600u32), // t (seconds between parts) - U256::ZERO, // span = full part window - B256::ZERO, // appData = empty - ) - .abi_encode(); - let call = createCall { - params: ConditionalOrderParams { - handler: TWAP_HANDLER, - salt, - staticInput: static_input.into(), - }, - dispatch: true, - }; - call.abi_encode().into() -} - -/// Encode `CoWSwapEthFlow.createOrder(EthFlowOrder.Data)` with a sell -/// amount matched to the tx `value`. `appData` is the empty hash; -/// `validTo` is `u32::MAX` per the canonical EthFlow shape. -fn encode_ethflow_create_order(eoa: Address, sell_amount: u128, quote_id: i64) -> Bytes { - let order = EthFlowOrderData { - buyToken: COW_TOKEN, - receiver: eoa, - sellAmount: U256::from(sell_amount), - buyAmount: U256::from(1u8), - appData: EMPTY_APP_DATA, - feeAmount: U256::ZERO, - validTo: u32::MAX, - partiallyFillable: false, - quoteId: quote_id, - }; - let call = createOrderCall { order }; - call.abi_encode().into() -} - -async fn send_impersonated( - provider: &P, - from: Address, - to: Address, - data: Bytes, - value: U256, - nonce: u64, -) -> anyhow::Result { - // `eth_sendTransaction` on Anvil uses the impersonated account's - // virtual signer - no local key needed. We pin the nonce explicitly - // so concurrent submissions do not race on the per-account counter - // (root cause of the 5/270 revert rate in the baseline). - let tx = TransactionRequest::default() - .from(from) - .to(to) - .value(value) - .nonce(nonce) - .input(data.into()); - let hash: B256 = provider - .raw_request("eth_sendTransaction".into(), serde_json::json!([tx])) - .await?; - Ok(hash) -} - -// `now_unix` is kept here for future runbook-driven scenarios that -// drive load-gen without a live block stream. Not used today. -#[allow(dead_code)] -fn now_unix() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) -} - -// Address parser sanity test - keeps the pinned identities in lockstep -// with the prep doc. -#[cfg(test)] -mod tests { - use super::*; - use std::str::FromStr; - - #[test] - fn pinned_addresses_round_trip() { - for (label, addr) in [ - ("EOA", EOA), - ("ComposableCoW", COMPOSABLE_COW), - ("TWAP handler", TWAP_HANDLER), - ("EthFlow", ETHFLOW), - ("WETH", WETH), - ("COW", COW_TOKEN), - ] { - let reparsed = Address::from_str(&format!("{addr:?}")).expect(label); - assert_eq!(reparsed, addr, "{label}"); - } - } - - #[test] - fn salt_from_counter_is_unique_and_big_endian() { - let a = salt_from_counter(1); - let b = salt_from_counter(2); - assert_ne!(a, b); - // High 16 bytes always zero (counter fits in u128). - assert_eq!(&a.as_slice()[..16], &[0u8; 16]); - // Counter sits in the low 16 bytes, big-endian. - assert_eq!(a.as_slice()[31], 1); - assert_eq!(b.as_slice()[31], 2); - } - - #[test] - fn twap_calldata_starts_with_create_selector() { - let calldata = encode_twap_create(B256::ZERO, 1_700_000_000); - // Selector for `create((address,bytes32,bytes),bool)` is the - // first 4 bytes of keccak256("create((address,bytes32,bytes),bool)"). - // We assert structurally rather than pinning a magic constant - // so a future ABI tweak fails the test with a clear shape diff. - assert_eq!(calldata.len() % 32, 4, "selector + abi-encoded body"); - } -} diff --git a/wit/deps.lock b/wit/deps.lock new file mode 100644 index 0000000..e69de29 diff --git a/wit/deps.toml b/wit/deps.toml new file mode 100644 index 0000000..e313c70 --- /dev/null +++ b/wit/deps.toml @@ -0,0 +1 @@ +# nexum:host is a leaf package; this manifest stays empty. diff --git a/wit/nexum-host/local-store.wit b/wit/nexum-host/local-store.wit index 814ef72..8521b37 100644 --- a/wit/nexum-host/local-store.wit +++ b/wit/nexum-host/local-store.wit @@ -26,22 +26,4 @@ interface local-store { /// Number of keys matching a prefix, without materialising the key /// list. On some backends this may be a scan. count: func(prefix: string) -> result; - - /// One pair in an apply batch. - record key-value { - key: string, - value: list, - } - - /// One write in an apply batch. - variant write-op { - set(key-value), - delete(string), - } - - /// Apply a batch of writes atomically: every op lands or none - /// does. Later ops on a key supersede earlier ones. Quota is - /// charged on the net whole-batch footprint; the host caps the op - /// count and total value bytes per batch. - apply: func(ops: list) -> result<_, fault>; } diff --git a/wit/nexum-host/types.wit b/wit/nexum-host/types.wit index 0142f11..8e83426 100644 --- a/wit/nexum-host/types.wit +++ b/wit/nexum-host/types.wit @@ -58,17 +58,20 @@ interface types { fired-at: u64, } - /// The generic extension event: a domain extension's own event kind - /// and its opaque payload. The core routes by `kind` and never reads - /// `payload`; the subscribing module decodes it against the extension - /// that emitted it. - record custom-event { - /// Extension-scoped event kind, matched against a module's - /// `[[subscription]]` kind. - kind: string, - /// Opaque bytes the emitting extension defines and the module - /// decodes. - payload: list, + /// 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 { @@ -76,7 +79,7 @@ interface types { chain-logs(chain-logs), tick(tick), message(message), - custom(custom-event), + intent-status(intent-status-update), } /// Opaque config from module.toml [config] section.