diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a91a3704..69d0b2a5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -308,6 +308,20 @@ jobs: RUSTC_WRAPPER: sccache # See `node-tests` env block above for the 50-GiB rationale. SCCACHE_CACHE_SIZE: "50G" + # Activate the workspace's `coverage_nightly` cfg gate so the + # `#[cfg_attr(coverage_nightly, coverage(off))]` annotations + # (14× repo-wide, plus the platform-detection helpers in + # `node/src/r2_probe.rs`) actually take effect under + # `cargo llvm-cov`. cargo-llvm-cov does NOT auto-set this cfg — + # without it every `coverage(off)` in the workspace is inert and + # llvm-cov counts the excluded fns / lines as uncovered, which + # silently broke the 100%-line + 100%-function gate the moment + # the first annotation landed in the `node` crate. Set only on + # the coverage job: the `lint-and-build` job runs stable 1.81.0 + # and would reject `feature(coverage_attribute)`, and + # `node-tests` doesn't need the cfg (test execution is + # orthogonal to the gate). + RUSTFLAGS: "--cfg coverage_nightly" steps: - name: Checkout uses: actions/checkout@v4 @@ -359,6 +373,31 @@ jobs: --test-threads 1 \ -E 'not binary(api_remote)' + # On gate failure, re-format the existing llvm-cov data (no + # re-run, no new test execution — `report` reads the on-disk + # profraw / profdata produced by the previous step) and emit + # the per-file "Uncovered Lines" block plus a json digest of + # files below 100% line / function. `--show-missing-lines` on + # the gate step sometimes elides this section depending on the + # llvm-cov build (observed empirically across this repo's + # llvm-cov upgrades), so this step makes the detail + # deterministic: whenever the gate fails, the operator sees + # which file/line/function is below 100% without having to + # reproduce locally. + - name: Show missing coverage on gate failure + if: failure() + run: | + echo "--- llvm-cov report: --show-missing-lines (text) ---" + cargo llvm-cov report --release --show-missing-lines \ + --ignore-filename-regex 'main\.rs|lib\.rs|publisher\.rs|runtime\.rs|scanner_runtime\.rs|scanner_ws\.rs|_tests\.rs$|bin/.*\.rs$' || true + echo "--- llvm-cov report: per-file json (filter < 100%) ---" + cargo llvm-cov report --release --json \ + --ignore-filename-regex 'main\.rs|lib\.rs|publisher\.rs|runtime\.rs|scanner_runtime\.rs|scanner_ws\.rs|_tests\.rs$|bin/.*\.rs$' \ + | jq -r '.data[0].files[] + | select(.summary.lines.percent < 100 or .summary.functions.percent < 100) + | {filename, lines: .summary.lines, functions: .summary.functions}' \ + || echo "(jq not available or json parse failed)" + - name: sccache stats (post-build) if: always() run: sccache --show-stats diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d796d673..7ed34e6f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,11 +35,26 @@ Bitcoin / Esplora signals on the node's hot path are subscribed to, never polled. The scanner consumes block events from the mempool.space-compatible WebSocket stream (`scanner_ws.rs`, `ESPLORA_WS_URL`, default `wss://mutinynet.com/api/v1/ws`); the -publisher waits for `track-tx` events between commit and reveal -broadcasts instead of sleeping a fixed propagation interval. The -previous 30-s tip-poll gated `/api/mint` and `/api/send` visibility -by up to a full block-time + poll-interval (issue #84); event-driven -ingestion brings that down to the WS round-trip. +publisher broadcasts the commit and reveal transactions back-to-back +via REST and never sleeps or polls between them. The previous 30-s +tip-poll gated `/api/mint` and `/api/send` visibility by up to a full +block-time + poll-interval (issue #84); event-driven ingestion brings +that down to the WS round-trip. + +Historical note: issue #84 originally replaced a fixed 5 s +`PROPAGATION_WAIT_SECS` sleep with a `{"action":"track-tx","data":""}` +WS wait + REST safety-net. After the deployment moved to a +self-hosted `mempool/backend:v3.3.1`, empirical measurement showed +that backend version emits zero frames for `track-tx`, so the WS +wait always timed out and the REST fallback always confirmed the +tx as already on-chain. 16/16 fallbacks in 72 h DEV `request_log`, +0 not-found, 0 errors. The wait was pure latency tax (~30 s/mint +and ~30 s/send+commit) for an in-cluster scenario where bitcoind's +local-mempool accept already orders the two POSTs correctly. The +publisher now runs `client.broadcast(commit) → client.broadcast(reveal)` +sequentially; race-freedom follows from the topology (node, electrs, +bitcoind share the Docker `bitcoin` network), not from a WS +subscription. Where it applies: @@ -47,7 +62,7 @@ Where it applies: - `node/src/scanner_runtime.rs` — block-walk loop, drains the WS-fed channel. - `node/src/scanner_ws.rs` — WS subscriber + reconnect-with-backoff. - `node/src/scanner_ws_parse.rs` — pure WS frame parsers. -- `node/src/publisher.rs` — `track-tx` wait between commit and reveal. +- `node/src/publisher.rs` — direct sequential commit→reveal broadcast. Where it does NOT apply: integration tests (`node/tests/api_remote.rs`), health-readiness probes, and any @@ -71,13 +86,16 @@ fails the build with a pointer to issue #84. The token is a plain comment marker — not an `#[allow(...)]` attribute, which would have been mistakable for a real lint suppression — and is the documented per-line opt-out for genuinely justified exceptions (today: the -WS-reconnect backoff in `scanner_ws`, the inner `track-tx` -reconnect-with-backoff in `scanner_ws`, and the bounded HTTP-retry +WS-reconnect backoff in `scanner_ws` and the bounded HTTP-retry sleep in `scanner_runtime`). The same line must carry a comment explaining WHY this particular sleep is not a chain-tip poll. New uses require either changing the design or extending this section with the rationale. +The publisher's previous per-broadcast `track-tx` reconnect-with- +backoff inside `scanner_ws.rs` is no longer in the file — it was +removed alongside the WS wait itself (see historical note above). + ### Project invariants (non-negotiable) The five constraints below are decided and apply across every PR on @@ -267,6 +285,13 @@ changing it, drop the local database (`docker rm -f zkcoins-pg`) and re-run `sqlx migrate run` against a fresh instance — there is no `down` migration in the MVP, the migration set is forward-only. +R2-probe results land in `r2_probe_runs` (+ `r2_probe_hosts` / +`r2_probe_warm_calls`) added by migration `0013_r2_probe_results.sql`. +The `r2_probe_runs_summary` view drives `GET +/api/admin/r2-probe/history`; the `probe_r2` binary writes via +`--persist` when `DATABASE_URL` is set. See `node/src/r2_probe.rs` +for the persistence module and the schema rationale. + ## Setup After cloning, enable the repo's pre-push hook. The hook runs `cargo @@ -458,8 +483,10 @@ The node continuously scans the Bitcoin blockchain: The publisher (`publisher.rs`) creates Taproot Inscriptions: - Commit/reveal pattern (two transactions) - Data split into 520-byte chunks (max push size) -- Broadcasts via Esplora API, then waits for the WS `track-tx` event - between commit and reveal instead of sleeping a fixed interval +- Broadcasts via Esplora REST: commit and reveal POSTs run back to + back with no inter-tx wait. Sequencing is provided by bitcoind's + local-mempool accept (node, electrs, bitcoind share the Docker + `bitcoin` network), not by a WS `track-tx` subscription. ### Plonky2 State-Transition Circuit diff --git a/Cargo.lock b/Cargo.lock index 9166f018..4ee9ec95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1989,6 +1989,7 @@ dependencies = [ "hex", "http-body-util", "lazy_static", + "libc", "mimalloc", "rand 0.8.6", "reqwest 0.12.28", @@ -1998,6 +1999,7 @@ dependencies = [ "shared", "socket2 0.5.10", "sqlx", + "sysinfo", "tempfile", "testcontainers", "testcontainers-modules", @@ -2012,6 +2014,15 @@ dependencies = [ "zkcoins-prover-plonky2", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2129,6 +2140,25 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3529,6 +3559,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sysinfo" +version = "0.39.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -4443,6 +4487,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4456,6 +4521,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -4484,6 +4560,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4560,6 +4646,15 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" diff --git a/MIGRATION_RESEARCH.md b/MIGRATION_RESEARCH.md index 39f654f7..ffc9e297 100644 --- a/MIGRATION_RESEARCH.md +++ b/MIGRATION_RESEARCH.md @@ -1314,6 +1314,41 @@ is a build-time signal, not a runtime-readiness signal. deploy-dev post-curl-retry fires on every DEV deploy. A regression that brings back the silent-panic shape fails one or both gates. +### 7.24 Self-hosted `mempool/backend:v3.3.1` does not implement the `track-tx` WS action — **codified** + +**Discovered:** DEV operations (May 2026), via combined evidence: +DEV `request_log` showed `/api/mint` p50 ≈ 40 s and +`/api/send` p50 = 11 s + `/api/commit` p50 = 30.7 s — both with the +30 s shape characteristic of the publisher's `track-tx` safety-net; +16/16 REST fallbacks in the 72 h sample succeeded with 0 not-found +and 0 errors; a direct WS probe against +`mempool-api-mutinynet:8999/api/v1/ws` with +`{"action":"track-tx","data":""}` returned 0 frames in 15 s, +while `{"action":"want","data":["blocks"]}` answered immediately. + +**Root cause:** the self-hosted `mempool/backend:v3.3.1` version +does not emit any frame in response to `track-tx` (the action is +recognised but no event arrives). Issue #84's original design +assumed a public mutinynet endpoint where the action does fire; +self-hosting flipped that assumption silently. + +**Fix:** drop the entire WS subscribe + safety-net + REST fallback +path from `publisher::broadcast_inscription_txs`. The publisher now +runs `client.broadcast(commit) → client.broadcast(reveal)` back to +back; race-freedom comes from the deployment topology (node + +electrs + bitcoind in the shared Docker `bitcoin` network, +`bitcoind::sendrawtransaction` returns only after local-mempool +accept), not from a WS subscription. Expected effect: +`/api/mint` p50 ~40 s → ~11 s, `/api/send + /api/commit` +~42 s → ~13 s. See the perf PR for the removed code. + +**Generalisation for future migrations:** when porting an +event-driven path that was designed against a public upstream onto +a self-hosted reimplementation of the same protocol, smoke-test +each WS action against the self-hosted endpoint before assuming +parity. Empty-frame-budget probe is cheap; silent latency tax is +expensive. + --- ## 8. Local Artifacts diff --git a/README.md b/README.md index 988a4bf1..dfe5b761 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ Per-module coverage (CI-gated): | `publisher.rs` | excluded | Bitcoin commit/reveal broadcasting — needs live signet/regtest node | | `main.rs` | excluded | Runtime bootstrap | | `*_runtime.rs` | excluded | Background-loop wrappers; covered indirectly via integration tests against handlers | -| `scanner_ws.rs` | excluded | WS subscriber + reconnect loop; pure helpers (`parse_ws_frame`, `frame_signals_tx_seen`) are unit-tested, the I/O loop is covered indirectly via the publisher's `track-tx` round-trip | +| `scanner_ws.rs` | excluded | WS subscriber + reconnect loop; the pure helper `parse_ws_frame` is unit-tested, the I/O loop is covered by in-process WS-server tests | `publisher.rs`, `main.rs`, the `*_runtime.rs` wrappers, and `scanner_ws.rs` are excluded by design — they require a live Bitcoin node, a funded publisher key, a bound TCP socket, or an upstream WebSocket peer, none of which fit in a unit test. The exclusion list is encoded in the CI gate's `--ignore-filename-regex`; everything else is held at 100% lines + 100% functions. CI runs the MVP build, the all-features build, `cargo nextest run -p node -p shared --release --all-features --test-threads 1 -E 'not binary(api_remote)'` on the self-hosted M3 Ultra runner pool, and the `Coverage Gate (100% lines + functions)` job. diff --git a/ROADMAP.md b/ROADMAP.md index fc969b7c..70c5ae79 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -374,7 +374,7 @@ Each stage carries the 100 % line coverage gate before commit. - DEV/PRD parity: PR [#73](https://github.com/zk-coins/node/pull/73) dropped the DEV-only Cargo features (`address-list`, `faucet`, `usernames`, `lnurl`) and removed the `DEV_SKIP_BROADCAST_FAILURE` env-gate so the two environments run the identical MVP-only binary. A follow-up refactor removed the `faucet` Cargo feature outright — mint is permanent MVP and ships unconditionally in every build — and a further refactor removed the `usernames` Cargo feature so usernames are permanent MVP and ship unconditionally too (PR [#76](https://github.com/zk-coins/node/pull/76)). **Remaining:** 1. e2e roundtrip on signet from `dev.zkcoins.app`: create account → mint → send → recipient receives. Success criterion: one happy-path + one failure-path per route. Tracked via a follow-up GitHub issue. - 2. Real performance measurement on the M3 Ultra. R2 budget: warm proof ≤ 5 s, ideally ≤ 1 s; cold-start ≤ 30 s including circuit-data load; peak mem < 64 GB during proving. Plonky2 currently runs CPU-only on Apple Silicon (no Metal backend); that's the operative baseline. + 2. Real performance measurement on the M3 Ultra. R2 budget: warm proof ≤ 5 s, ideally ≤ 1 s; cold-start ≤ 30 s including circuit-data load; peak mem < 64 GB during proving. Plonky2 currently runs CPU-only on Apple Silicon (no Metal backend); that's the operative baseline. Tool: the `probe_r2` binary (`node/src/bin/probe_r2.rs`) drives the measurement; `--persist` writes every run into `r2_probe_runs` (migration 0013) and the trend is readable via the `r2_probe_runs_summary` view and `GET /api/admin/r2-probe/history` on the live node. 3. If budget is missed: redesign per R2 (reduce `MAX_IN_COINS`, drop in-coin recursion, or switch to folding). **NOT** add external hardware or move to a cloud prover — the closed-environment + single-host constraint is non-negotiable. **Test plan:** the authoritative coverage gate runs in CI on the self-hosted M3 Ultra runner pool (`.github/workflows/ci.yaml`, jobs `Node + Shared Tests` and `Coverage Gate`, gated behind the `ci:full` label per PR [#48](https://github.com/zk-coins/node/pull/48)); the pre-push hook only enforces fmt + clippy + `cargo check`. Step 9 verifies integration, not unit coverage. e2e success criterion: every endpoint round-trips under realistic conditions (one happy-path traversal per route plus at least one failure path per route). **Risk:** Medium. First real exposure of the cyclic-recursive prover to production hardware under realistic load. If the budget holds, MVP is done. diff --git a/node/Cargo.toml b/node/Cargo.toml index cf09cf5d..a883d8bc 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -3,6 +3,14 @@ name = "node" version.workspace = true edition.workspace = true +[lints.rust] +# Register the `coverage_nightly` cfg so `#[cfg_attr(coverage_nightly, +# coverage(off))]` annotations in `src/r2_probe.rs` don't trip +# `unexpected_cfgs` under `-D warnings`. `cargo llvm-cov` injects this +# cfg automatically on a nightly toolchain; the lint stays a warn-level +# diagnostic to match `program-plonky2` and `script-plonky2`. +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(coverage_nightly)"] } + [dependencies] bitcoin = { workspace = true } bitcoin_hashes = { version = "0.16.0", features = ["std"] } @@ -92,6 +100,29 @@ tracing = { workspace = true } # operator-facing aggregator (Alloy → Loki) parses the default # fmt-layer output today. tracing-subscriber = { workspace = true } +# Used only by the `probe_r2` binary (`src/bin/probe_r2.rs`) for +# `getrusage(RUSAGE_SELF)` to read the process peak RSS at the end of +# the run. No production code path links against this — the main +# binary's resident-set tracking is left to the host OS / cAdvisor. +# Already pulled into the dependency graph transitively (tokio, mio, +# rustls); declared here so the bin target's `unsafe` block resolves +# without leaning on an unstable transitive path. +libc = "0.2" +# Cross-platform host introspection for `r2_probe::detect()` (hostname, +# CPU brand, total RAM, CPU core count). Replaces a previous shape that +# shelled out to `hostname` / `sysctl` on macOS and read +# `/proc/{cpuinfo,meminfo}` on Linux: subprocess- and FS-error arms are +# not deterministically reachable on a healthy CI host, so the 100% +# line/function coverage gate could only be satisfied with +# `#[cfg_attr(coverage_nightly, coverage(off))]` markers on the +# per-platform `_impl` helpers. The `sysinfo` API has a single success +# path (one `Option` for hostname, one `&[Cpu]` slice for CPU +# info, one `u64` for total RAM) so every branch the gate sees is +# reachable from a single `detect()` call. `default-features = false` +# disables `disk`, `network`, `component`, and `user` — `detect()` only +# touches the system / host-name surface, and the disabled features +# pull in `windows` / `objc2-*` crates we don't need on Linux+macOS. +sysinfo = { version = "0.39", default-features = false, features = ["system"] } [dev-dependencies] tower = { version = "0.5", features = ["util"] } @@ -138,6 +169,16 @@ socket2 = "0.5" default = [] address-list = [] lnurl = [] +# Self-host operator opt-in: write path for usernames. Resolve + display +# is part of the MVP and stays on by default — hosted DEV + PRD images +# do not offer claim as a UX policy. With the feature off, the +# `POST /api/username/claim` route, its handler + request type, the +# `db::claim_username` upsert, and the matching `UsernameStore::claim` +# helpers are excluded from the binary at compile time. The +# `Capabilities.username_claim` field is always present in +# `/api/info` so wallet clients can gate their UI on a single +# capability bit without sniffing build flags. +username-claim = [] # Dormant self-host operator opt-in (issue #84). When enabled, a ZMQ # subscriber publishes block-hash events into the same channel the # WebSocket scanner uses. No module activates the subscriber in this diff --git a/node/migrations/0013_r2_probe_results.sql b/node/migrations/0013_r2_probe_results.sql new file mode 100644 index 00000000..db177b7c --- /dev/null +++ b/node/migrations/0013_r2_probe_results.sql @@ -0,0 +1,135 @@ +-- R2 probe result persistence for the `probe_r2` binary. +-- +-- The probe (`node/src/bin/probe_r2.rs`) measures the three ROADMAP +-- step 9 budgets on a single run: warm `prove_*` wall, cold-start +-- wall, peak resident-set-size. Until this migration the only output +-- channel was a JSON file on disk plus stdout. That makes regression +-- tracking — "did PR X push warm wall above the 5 s budget?" — +-- a manual grep-through-old-files exercise. +-- +-- The schema persists every useful field the probe collects so the +-- operator can answer trend / regression queries with a single SQL +-- against the live node DB. Closed test env, so no privacy boundary +-- on these rows (`feedback_zkcoins_no_privacy_promise`). +-- +-- Three tables, 3NF-normalised: +-- +-- * `r2_probe_hosts` — one row per (hostname, os, arch, cpu_brand) +-- tuple. Most fields repeat across runs from the same machine; +-- normalising avoids the duplication and lets the convenience +-- view join cleanly. +-- * `r2_probe_runs` — one row per probe execution. Carries every +-- scalar measurement (build wall, cold prove wall, warm +-- percentiles, peak RSS) plus the run-time context (git sha, +-- binary version, rustc version, build profile, allocator, +-- circuit parameters) and the budgets the run was checked +-- against (so a future budget tweak doesn't silently re-classify +-- historical rows). `succeeded` + `error_message` capture the +-- terminal state so failed runs are queryable too. +-- * `r2_probe_warm_calls` — one row per individual warm call. +-- Allows recomputing percentiles or inspecting outliers later. +-- `ON DELETE CASCADE` from the parent row so retention pruning +-- a single run cleans up its children with it. +-- +-- Plus a `r2_probe_runs_summary` view that joins host + run and +-- inlines the three budget-pass booleans. Trend queries go through +-- the view; the underlying tables are still queryable for ad-hoc +-- forensics. +-- +-- Indices target the common query paths: "last N runs" (`ran_at +-- DESC`), "per-host trend" (`host_id, ran_at DESC`), "regression +-- isolation for a specific commit" (`git_sha`). + +CREATE TABLE r2_probe_hosts ( + id SERIAL PRIMARY KEY, + hostname TEXT NOT NULL, + os TEXT NOT NULL, + arch TEXT NOT NULL, + cpu_brand TEXT NOT NULL, + cpu_cores INT NOT NULL, + -- Nullable on purpose: the probe falls back to `None` when the + -- platform path that reads RAM size is unavailable (Linux without + -- `/proc/meminfo`, sandboxed macOS). Keeping the column nullable + -- avoids forging a sentinel value that would lie about the host. + total_ram_gb INT, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (hostname, os, arch, cpu_brand) +); + +CREATE TABLE r2_probe_runs ( + id BIGSERIAL PRIMARY KEY, + ran_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + host_id INT NOT NULL REFERENCES r2_probe_hosts(id), + git_sha TEXT NOT NULL, + binary_version TEXT NOT NULL, + rustc_version TEXT NOT NULL, + build_profile TEXT NOT NULL, + allocator TEXT NOT NULL, + max_in_coins INT NOT NULL, + max_out_coins INT NOT NULL, + inner_pad_bits INT NOT NULL, + warm_calls_requested INT NOT NULL, + circuit_build_wall_ms BIGINT NOT NULL, + prove_cold_wall_ms BIGINT NOT NULL, + verify_wall_ms BIGINT NOT NULL, + peak_rss_kb BIGINT NOT NULL, + -- Percentiles are nullable so a `--warm-calls 0` run still + -- produces a writeable row. The view treats NULL as "no warm + -- measurement" rather than coercing it to a fake zero. + prove_warm_p50_ms BIGINT, + prove_warm_p90_ms BIGINT, + prove_warm_p99_ms BIGINT, + succeeded BOOLEAN NOT NULL, + error_message TEXT, + notes TEXT, + tags TEXT[] NOT NULL DEFAULT '{}', + -- Budgets persisted alongside each row so trend queries are + -- self-contained: a future PR that retunes the budgets does NOT + -- retroactively flip the pass/fail of historical rows in the + -- summary view. + r2_warm_budget_ms BIGINT NOT NULL, + r2_cold_budget_ms BIGINT NOT NULL, + r2_mem_budget_kb BIGINT NOT NULL +); + +CREATE TABLE r2_probe_warm_calls ( + probe_run_id BIGINT NOT NULL REFERENCES r2_probe_runs(id) ON DELETE CASCADE, + call_index INT NOT NULL, + wall_ms BIGINT NOT NULL, + PRIMARY KEY (probe_run_id, call_index) +); + +CREATE INDEX idx_r2_probe_runs_ran_at ON r2_probe_runs (ran_at DESC); +CREATE INDEX idx_r2_probe_runs_git_sha ON r2_probe_runs (git_sha); +CREATE INDEX idx_r2_probe_runs_host ON r2_probe_runs (host_id, ran_at DESC); + +-- Convenience view: "last N runs with budget verdicts". The pass +-- columns are computed from the row's own persisted budgets, so they +-- reflect the verdict at the time the run was recorded — see the +-- per-row budget rationale above. +CREATE VIEW r2_probe_runs_summary AS +SELECT + r.id, + r.ran_at, + h.hostname, + h.cpu_brand, + r.git_sha, + r.build_profile, + r.allocator, + r.circuit_build_wall_ms, + r.prove_cold_wall_ms, + r.prove_warm_p50_ms, + r.prove_warm_p90_ms, + r.prove_warm_p99_ms, + r.peak_rss_kb, + -- cold-start = build + first prove combined; ROADMAP §Step 9 + -- (`BUDGET_COLD_START_MS = 30_000`). The probe binary computes + -- the verdict the same way; matching it here keeps the view, + -- console verdict and trend table semantically aligned. + ((r.circuit_build_wall_ms + r.prove_cold_wall_ms) <= r.r2_cold_budget_ms) AS r2_cold_pass, + (r.prove_warm_p50_ms IS NOT NULL + AND r.prove_warm_p50_ms <= r.r2_warm_budget_ms) AS r2_warm_pass, + (r.peak_rss_kb <= r.r2_mem_budget_kb) AS r2_mem_pass, + r.succeeded +FROM r2_probe_runs r +JOIN r2_probe_hosts h ON h.id = r.host_id; diff --git a/node/src/audit_tests.rs b/node/src/audit_tests.rs index dc2e5975..7ee442bb 100644 --- a/node/src/audit_tests.rs +++ b/node/src/audit_tests.rs @@ -149,7 +149,6 @@ async fn build_state_with_pool() -> ( is_mainnet: false, network_name: "Mutinynet".to_string(), ws_url: None, - track_tx_timeout: None, }; let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/node/src/bin/probe_r2.rs b/node/src/bin/probe_r2.rs new file mode 100644 index 00000000..44f43d6c --- /dev/null +++ b/node/src/bin/probe_r2.rs @@ -0,0 +1,703 @@ +//! Wall-clock + peak-RSS probe for the Plonky2 prover hot path. +//! +//! ROADMAP step 9 ("R2 — measure on M3 Ultra") tracks three budgets that +//! the node binary must respect at production parameters (`MAX_IN_COINS` +//! = `MAX_OUT_COINS` = 8, Phase 2b outer at degree 16): +//! +//! - warm `prove_*` wall ≤ 5 s (target ≤ 1 s) +//! - cold start (`Prover::new` + first prove) ≤ 30 s +//! - peak resident-set-size < 64 GB +//! +//! There is no automated path that produces those three numbers today. +//! The closest existing thing is the `#[ignore]`-d `prover_init_roundtrip` +//! integration test in `script-plonky2/src/lib.rs`, which only proves an +//! empty `Init` once and never reports RSS. +//! +//! This binary closes that gap. It is a standalone diagnostic tool — +//! NOT wired into `node`'s `main.rs`, never reached by Esplora / +//! Postgres / WebSocket code paths. It uses mimalloc as the global +//! allocator to match `node/src/main.rs` (PR #134), so the probe +//! measures the same allocator behaviour PRD experiences. +//! +//! ## Where to run +//! +//! Run **locally** on the Mac Studio M3 Ultra (96 GB) — that is the +//! reference machine ROADMAP step 9 budgets against. Do NOT run this +//! on the dfx01 self-hosted CI runner: a single warm sweep dominates +//! the m3-ultra runner slot for 5+ minutes and starves PR jobs. +//! +//! ```sh +//! cargo build --release -p node --bin probe_r2 +//! RUST_LOG=warn ./target/release/probe_r2 \ +//! --warm-calls 5 \ +//! --output /tmp/r2-probe-$(date +%s).json +//! ``` +//! +//! ## Persistence (`--persist`) +//! +//! When `--persist` is set the probe writes its results into Postgres +//! via the `node::r2_probe` module (migration 0013): +//! +//! * one row in `r2_probe_hosts` (idempotent on the natural key); +//! * one row in `r2_probe_runs` with every scalar measurement plus +//! run-time context (git sha, rustc version, allocator, circuit +//! params) and the R2 budgets the run was checked against; +//! * N rows in `r2_probe_warm_calls`, one per warm call. +//! +//! Requires `DATABASE_URL` — same env var the node binary uses; the +//! probe panics on bootstrap if it is unset, mirroring `node::DATABASE_URL`. +//! +//! ## What it measures +//! +//! 1. `circuit_build_wall_ms` — `Prover::new()` (cold circuit +//! construction; the slow fixed-point loop inside `build_circuit`). +//! 2. `prove_cold_wall_ms` — first `prove_initial` call (caches cold, +//! rayon worker pool spun up for the first time). +//! 3. `prove_warm_wall_ms` — N follow-up `prove_account_update` calls +//! against the SAME state + witness. These approximate the steady- +//! state hot-path the live node hits per send. +//! 4. `peak_rss_kb` — high-water mark from `getrusage(RUSAGE_SELF)`. +//! The kernel reports `ru_maxrss` in **bytes** on macOS and **KB** +//! on Linux; this binary normalises both to KB and notes the +//! convention in the JSON. +//! +//! Console output prints a PASS/FAIL verdict against each of the three +//! ROADMAP budgets. +//! +//! ## What it intentionally does NOT do +//! +//! - No Esplora HTTP, no WebSocket subscription. +//! - No on-disk state — the AccountState + Coin witness lives in RAM. +//! - The warm sweep reuses the same `prev` proof + `cmp` witness; +//! we want pure prove-wall, not the per-send bookkeeping overhead +//! the live node carries (state lookups, MMR appends, DB writes). + +// Match the production node binary's allocator (see node/src/main.rs +// and PR #134). The R2 budgets gate the PRD binary, so the probe +// must use the same allocator — otherwise warm-wall and peak-RSS +// numbers diverge from what PRD experiences. +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::process::ExitCode; +use std::time::Instant; + +use serde_json::json; +use sqlx::postgres::PgPoolOptions; +use tokio::runtime::Runtime; + +use node::r2_probe::{ + detect, fetch_recent_summary, insert_run, insert_warm_calls, upsert_host, ProbeRun, SummaryRow, +}; +use zkcoins_program::circuit::main::{MAX_IN_COINS, MAX_OUT_COINS, MMR_PROOF_PATH_LEN}; +use zkcoins_program::hash::{digest_to_bytes, hash_bytes, hash_concat, HashDigest, ZERO_HASH}; +use zkcoins_program::inputs::CommitmentMerkleProofs; +use zkcoins_program::merkle::merkle_mountain_range::MerkleMountainRange; +use zkcoins_program::merkle::sparse_merkle_tree::SparseMerkleTree; +use zkcoins_program::types::{AccountState, MINTING_ADDRESS}; +use zkcoins_prover::Prover; + +// ROADMAP step 9 budgets (Mac Studio M3 Ultra reference). These are +// the defaults; the CLI accepts overrides for experimentation. +const BUDGET_WARM_PROVE_MS: i64 = 5_000; +const BUDGET_COLD_START_MS: i64 = 30_000; +const BUDGET_PEAK_RSS_KB: i64 = 64 * 1024 * 1024; // 64 GiB in KB + +/// Inner-pad-bits constant the active Phase 2b shape was built with +/// (see `INNER_PAD_BITS_STAGE_5D_NEXT_5` in +/// `program-plonky2/src/circuit/main.rs`). Recorded so the R2 +/// regression view can later answer "did the prove wall move when +/// we changed pad bits?". +const INNER_PAD_BITS: i32 = 15; + +// ===== CLI ===== + +#[derive(Debug)] +struct CliArgs { + warm_calls: usize, + output: Option, + persist: bool, + notes: Option, + tags: Vec, + warm_budget_ms: i64, + cold_budget_ms: i64, + mem_budget_kb: i64, +} + +fn print_usage(program: &str) { + eprintln!( + "usage: {program} [--warm-calls N] [--output ] [--persist] \ + [--notes ] [--tags a,b,c] \ + [--warm-budget-ms ] [--cold-budget-ms ] [--mem-budget-kb ] + + --warm-calls N number of warm prove_account_update calls (default 5) + --output PATH write JSON report to PATH (default: stdout) + --persist persist results into Postgres (requires DATABASE_URL) + --notes TEXT free-form note attached to the persisted run + --tags A,B,C comma-separated tags attached to the persisted run + --warm-budget-ms N override warm prove budget (default {warm} ms) + --cold-budget-ms N override cold-start budget (default {cold} ms) + --mem-budget-kb N override peak-RSS budget (default {mem} KB) + +env: + DATABASE_URL required when --persist is set + GIT_SHA optional override for the recorded git sha + RUSTC_VERSION optional override for the recorded rustc version + RUST_LOG optional, log level (defaults to off here) +", + warm = BUDGET_WARM_PROVE_MS, + cold = BUDGET_COLD_START_MS, + mem = BUDGET_PEAK_RSS_KB + ); +} + +fn parse_args(argv: Vec) -> Result { + let mut iter = argv.into_iter(); + let program = iter.next().unwrap_or_else(|| "probe_r2".into()); + + let mut warm_calls: usize = 5; + let mut output: Option = None; + let mut persist = false; + let mut notes: Option = None; + let mut tags: Vec = Vec::new(); + let mut warm_budget_ms = BUDGET_WARM_PROVE_MS; + let mut cold_budget_ms = BUDGET_COLD_START_MS; + let mut mem_budget_kb = BUDGET_PEAK_RSS_KB; + + while let Some(arg) = iter.next() { + match arg.as_str() { + "--warm-calls" => { + let v = iter + .next() + .ok_or_else(|| "--warm-calls requires a value".to_string())?; + warm_calls = v + .parse::() + .map_err(|e| format!("--warm-calls: {e}"))?; + } + "--output" => { + let v = iter + .next() + .ok_or_else(|| "--output requires a value".to_string())?; + output = Some(PathBuf::from(v)); + } + "--persist" => { + persist = true; + } + "--notes" => { + let v = iter + .next() + .ok_or_else(|| "--notes requires a value".to_string())?; + notes = Some(v); + } + "--tags" => { + let v = iter + .next() + .ok_or_else(|| "--tags requires a value".to_string())?; + tags = v + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + "--warm-budget-ms" => { + let v = iter + .next() + .ok_or_else(|| "--warm-budget-ms requires a value".to_string())?; + warm_budget_ms = v + .parse::() + .map_err(|e| format!("--warm-budget-ms: {e}"))?; + } + "--cold-budget-ms" => { + let v = iter + .next() + .ok_or_else(|| "--cold-budget-ms requires a value".to_string())?; + cold_budget_ms = v + .parse::() + .map_err(|e| format!("--cold-budget-ms: {e}"))?; + } + "--mem-budget-kb" => { + let v = iter + .next() + .ok_or_else(|| "--mem-budget-kb requires a value".to_string())?; + mem_budget_kb = v + .parse::() + .map_err(|e| format!("--mem-budget-kb: {e}"))?; + } + "-h" | "--help" => { + print_usage(&program); + std::process::exit(0); + } + other => return Err(format!("unknown argument: {other}")), + } + } + + Ok(CliArgs { + warm_calls, + output, + persist, + notes, + tags, + warm_budget_ms, + cold_budget_ms, + mem_budget_kb, + }) +} + +// ===== Witness construction ===== + +/// Stable test pubkey, mirrors the helper in +/// `script-plonky2/src/lib.rs::tests::dummy_pubkey`. +fn dummy_pubkey(seed: u8) -> [u8; 33] { + let mut pk = [0u8; 33]; + pk[0] = 0x02; + for (i, b) in pk.iter_mut().enumerate().skip(1) { + *b = seed.wrapping_add(i as u8); + } + pk +} + +/// Off-circuit `CommitmentMerkleProofs` witness. Mirrors the private +/// `build_test_commitment_witness` helper in +/// `program-plonky2/src/circuit/main.rs` (test module — not reachable +/// from this crate). Reproduced here so the probe doesn't need a test +/// re-export. +fn build_commitment_witness( + prev_asth: HashDigest, + prev_ocr: HashDigest, +) -> (CommitmentMerkleProofs, HashDigest) { + let pk_hash = hash_bytes(b"probe-r2-pubkey"); + let pk_key = digest_to_bytes(&pk_hash); + + let commitment = hash_concat(&prev_asth, &prev_ocr); + + let mut smt = SparseMerkleTree::new(); + smt.insert(pk_key, commitment) + .expect("smt insert (fresh key into fresh tree)"); + let smt_root = smt.root(); + let (smt_inclusion, _) = smt + .generate_inclusion_proof(&pk_key) + .expect("smt inclusion proof"); + + let prev_mmr_root = ZERO_HASH; + let mmr_leaf = hash_concat(&smt_root, &prev_mmr_root); + let mut mmr = MerkleMountainRange::new(); + mmr.append(mmr_leaf); + let history_root_extended = mmr.root_extended(MMR_PROOF_PATH_LEN); + let mmr_proof = mmr + .get_proof(0) + .expect("mmr proof for leaf 0") + .extend_to(MMR_PROOF_PATH_LEN); + + let cmp = CommitmentMerkleProofs { + commitment_root: smt_root, + commitment_proof: smt_inclusion, + commitment_root_history_proof: mmr_proof.clone(), + commitment_root_mmr_sibling: prev_mmr_root, + previous_root_history_proof: (smt_root, mmr_proof), + commitment_account_state_hash: prev_asth, + commitment_out_coins_root: prev_ocr, + }; + (cmp, history_root_extended) +} + +// ===== RSS sampling ===== + +/// Return current peak resident-set-size in KB. +/// +/// `getrusage(RUSAGE_SELF).ru_maxrss` is the cleanest cross-platform +/// path. The unit differs by OS: +/// +/// - Linux: kilobytes (already what we want). +/// - macOS / iOS / FreeBSD: bytes — divide by 1024. +/// +/// `ru_maxrss` is the high-water mark over the process lifetime, so +/// calling this once at the end of the run is sufficient — sampling +/// during the prove loop would be wasted work. +fn peak_rss_kb() -> u64 { + // SAFETY: `getrusage` is a POSIX syscall with no preconditions + // beyond a valid out-param, which we provide as a fully-initialised + // zeroed struct. + let mut usage: libc::rusage = unsafe { std::mem::zeroed() }; + let rc = unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) }; + if rc != 0 { + return 0; + } + let raw = usage.ru_maxrss as u64; + if cfg!(target_os = "macos") || cfg!(target_os = "ios") || cfg!(target_os = "freebsd") { + raw / 1024 + } else { + raw + } +} + +// ===== Run-time context ===== + +fn detect_git_sha() -> String { + if let Ok(v) = std::env::var("GIT_SHA") { + if !v.trim().is_empty() { + return v.trim().to_string(); + } + } + std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn detect_rustc_version() -> String { + if let Ok(v) = std::env::var("RUSTC_VERSION") { + if !v.trim().is_empty() { + return v.trim().to_string(); + } + } + std::process::Command::new("rustc") + .arg("--version") + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) +} + +/// Compute a percentile (0..=100) of `samples` in milliseconds. Uses +/// the nearest-rank method — adequate for the small N this probe +/// captures (typically 5–20 warm calls). +fn percentile_ms(samples: &[i64], p: f64) -> Option { + if samples.is_empty() { + return None; + } + let mut sorted = samples.to_vec(); + sorted.sort_unstable(); + let n = sorted.len(); + let rank = ((p / 100.0) * n as f64).ceil() as usize; + let idx = rank.saturating_sub(1).min(n - 1); + Some(sorted[idx]) +} + +// ===== Main ===== + +fn run() -> Result<(), String> { + let args = parse_args(std::env::args().collect())?; + + eprintln!("[probe_r2] starting — warm_calls={}", args.warm_calls); + eprintln!( + "[probe_r2] os={} arch={}", + std::env::consts::OS, + std::env::consts::ARCH + ); + + let host_info = detect(); + let git_sha = detect_git_sha(); + let rustc_version = detect_rustc_version(); + + // 1) Circuit build. + eprintln!("[probe_r2] building circuit (cold) ..."); + let t = Instant::now(); + let prover = Prover::new(); + let circuit_build_wall_ms = t.elapsed().as_millis() as i64; + eprintln!("[probe_r2] circuit_build_wall_ms = {circuit_build_wall_ms}"); + + // 2) Account state for the init proof + downstream updates. + let mut account_state = AccountState::new(dummy_pubkey(7)); + account_state.owner = *MINTING_ADDRESS; + account_state.balance = 1_000_000; + + // 3) Cold prove — first prove_initial after build. + eprintln!("[probe_r2] proving initial (cold) ..."); + let t = Instant::now(); + let init_proof = prover + .prove_initial(&account_state, ZERO_HASH) + .map_err(|e| format!("prove_initial: {e}"))?; + let prove_cold_wall_ms = t.elapsed().as_millis() as i64; + eprintln!("[probe_r2] prove_cold_wall_ms = {prove_cold_wall_ms}"); + + // Verify the init proof once so a regression in the prove path + // doesn't quietly produce garbage timings. + let t = Instant::now(); + prover + .verify(&init_proof) + .map_err(|e| format!("verify cold init: {e}"))?; + let verify_wall_ms = t.elapsed().as_millis() as i64; + + // 4) Build the AccountUpdate witness ONCE and reuse it across + // warm calls. We want pure prove-wall, not witness-construction + // cost. + let prev_asth = account_state.hash(); + let prev_ocr = init_proof_out_coins_root_from_init(&prev_asth); + let (cmp, history_root_extended) = build_commitment_witness(prev_asth, prev_ocr); + + // 5) Warm prove sweep. + let mut prove_warm_wall_ms: Vec = Vec::with_capacity(args.warm_calls); + for i in 0..args.warm_calls { + eprintln!("[probe_r2] warm prove {} / {} ...", i + 1, args.warm_calls); + let t = Instant::now(); + let update_proof = prover + .prove_account_update(&account_state, history_root_extended, &init_proof, &cmp) + .map_err(|e| format!("warm prove_account_update #{i}: {e}"))?; + let ms = t.elapsed().as_millis() as i64; + prove_warm_wall_ms.push(ms); + eprintln!("[probe_r2] warm[{i}] = {ms} ms"); + + if i == 0 { + prover + .verify(&update_proof) + .map_err(|e| format!("verify warm #{i}: {e}"))?; + } + } + + let peak_rss = peak_rss_kb() as i64; + + // ===== Report ===== + + let cold_start_ms = circuit_build_wall_ms + prove_cold_wall_ms; + let warm_p50 = percentile_ms(&prove_warm_wall_ms, 50.0); + let warm_p90 = percentile_ms(&prove_warm_wall_ms, 90.0); + let warm_p99 = percentile_ms(&prove_warm_wall_ms, 99.0); + let warm_min = prove_warm_wall_ms.iter().min().copied().unwrap_or(0); + let warm_max = prove_warm_wall_ms.iter().max().copied().unwrap_or(0); + let warm_mean = if prove_warm_wall_ms.is_empty() { + 0 + } else { + prove_warm_wall_ms.iter().sum::() / prove_warm_wall_ms.len() as i64 + }; + + let report = json!({ + "platform": { + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "hostname": host_info.hostname, + "cpu_brand": host_info.cpu_brand, + "cpu_cores": host_info.cpu_cores, + "total_ram_gb": host_info.total_ram_gb, + }, + "git_sha": git_sha, + "rustc_version": rustc_version, + "build_profile": "release", + "allocator": "mimalloc", + "max_in_coins": MAX_IN_COINS, + "max_out_coins": MAX_OUT_COINS, + "inner_pad_bits": INNER_PAD_BITS, + "warm_calls_requested": args.warm_calls, + "circuit_build_wall_ms": circuit_build_wall_ms, + "prove_cold_wall_ms": prove_cold_wall_ms, + "verify_wall_ms": verify_wall_ms, + "prove_warm_wall_ms": prove_warm_wall_ms, + "prove_warm_p50_ms": warm_p50, + "prove_warm_p90_ms": warm_p90, + "prove_warm_p99_ms": warm_p99, + "peak_rss_kb": peak_rss, + "rss_unit_note": + "macOS reports ru_maxrss in bytes; Linux reports KB. This tool normalises to KB.", + "budgets": { + "warm_prove_ms_max": args.warm_budget_ms, + "cold_start_ms_max": args.cold_budget_ms, + "peak_rss_kb_max": args.mem_budget_kb, + }, + "notes": args.notes, + "tags": args.tags, + }); + + let json_text = + serde_json::to_string_pretty(&report).map_err(|e| format!("serialise report: {e}"))?; + + if let Some(path) = args.output.as_ref() { + let mut f = + fs::File::create(path).map_err(|e| format!("create {}: {e}", path.display()))?; + f.write_all(json_text.as_bytes()) + .map_err(|e| format!("write {}: {e}", path.display()))?; + f.write_all(b"\n") + .map_err(|e| format!("write nl {}: {e}", path.display()))?; + eprintln!("[probe_r2] report -> {}", path.display()); + } else { + println!("{json_text}"); + } + + // ===== Optional persistence ===== + + let mut history_after: Option> = None; + if args.persist { + let database_url = std::env::var("DATABASE_URL").map_err(|_| { + "--persist requires DATABASE_URL to be set (e.g. \ + postgresql://zkcoins:@postgres:5432/zkcoins)" + .to_string() + })?; + eprintln!("[probe_r2] persisting to DATABASE_URL ..."); + + let rt = Runtime::new().map_err(|e| format!("tokio runtime: {e}"))?; + let rows = rt.block_on(async { + let pool = PgPoolOptions::new() + .max_connections(2) + .connect(&database_url) + .await + .map_err(|e| format!("connect DATABASE_URL: {e}"))?; + let host_id = upsert_host(&pool, &host_info) + .await + .map_err(|e| format!("upsert_host: {e}"))?; + let run_row = ProbeRun { + host_id, + git_sha: git_sha.clone(), + binary_version: env!("CARGO_PKG_VERSION").to_string(), + rustc_version: rustc_version.clone(), + build_profile: "release".to_string(), + allocator: "mimalloc".to_string(), + max_in_coins: MAX_IN_COINS as i32, + max_out_coins: MAX_OUT_COINS as i32, + inner_pad_bits: INNER_PAD_BITS, + warm_calls_requested: args.warm_calls as i32, + circuit_build_wall_ms, + prove_cold_wall_ms, + verify_wall_ms, + peak_rss_kb: peak_rss, + prove_warm_p50_ms: warm_p50, + prove_warm_p90_ms: warm_p90, + prove_warm_p99_ms: warm_p99, + succeeded: true, + error_message: None, + notes: args.notes.clone(), + tags: args.tags.clone(), + r2_warm_budget_ms: args.warm_budget_ms, + r2_cold_budget_ms: args.cold_budget_ms, + r2_mem_budget_kb: args.mem_budget_kb, + }; + let run_id = insert_run(&pool, &run_row) + .await + .map_err(|e| format!("insert_run: {e}"))?; + insert_warm_calls(&pool, run_id, &prove_warm_wall_ms) + .await + .map_err(|e| format!("insert_warm_calls: {e}"))?; + let rows = fetch_recent_summary(&pool, 5) + .await + .map_err(|e| format!("fetch_recent_summary: {e}"))?; + Ok::, String>(rows) + })?; + eprintln!( + "[probe_r2] persisted run; {} recent rows read back", + rows.len() + ); + history_after = Some(rows); + } + + // Console verdict against the three ROADMAP budgets. + let warm_ok = (warm_p50.unwrap_or(i64::MAX)) <= args.warm_budget_ms; + let cold_ok = cold_start_ms <= args.cold_budget_ms; + let rss_ok = peak_rss <= args.mem_budget_kb; + + eprintln!(); + eprintln!("===== ROADMAP step 9 budgets ====="); + eprintln!( + " warm prove p50 over {} calls: {} ms {} [budget {} ms]", + args.warm_calls, + warm_p50 + .map(|v| v.to_string()) + .unwrap_or_else(|| "n/a".into()), + check(warm_ok), + args.warm_budget_ms + ); + eprintln!( + " cold start (build + first prove): {} ms {} [budget {} ms]", + cold_start_ms, + check(cold_ok), + args.cold_budget_ms + ); + eprintln!( + " peak RSS: {} KB ({} MiB) {} [budget {} KB]", + peak_rss, + peak_rss / 1024, + check(rss_ok), + args.mem_budget_kb + ); + eprintln!(); + eprintln!( + " warm distribution: min {} / mean {} / max {} ms", + warm_min, warm_mean, warm_max + ); + + if let Some(rows) = history_after.as_ref() { + print_history_table(rows); + } + + Ok(()) +} + +fn check(ok: bool) -> &'static str { + if ok { + "PASS" + } else { + "FAIL" + } +} + +/// ASCII trend table — last few persisted runs newest first. Width +/// is tuned for an 80-column terminal; the columns map 1:1 to the +/// `r2_probe_runs_summary` view. +/// +/// `coldstart_ms` is `circuit_build_wall_ms + prove_cold_wall_ms` to +/// match the cold-start budget (`BUDGET_COLD_START_MS`, ROADMAP §Step +/// 9). The `C` pass marker in the same row reads the view's +/// `r2_cold_pass` which is computed against the same sum — so the +/// number the operator sees is exactly what the pass/fail is judged +/// against. +fn print_history_table(rows: &[SummaryRow]) { + eprintln!(); + eprintln!("===== Recent runs (from DB) ====="); + eprintln!( + " {:<25} {:<14} {:>12} {:>9} {:>10} W C M", + "ran_at", "git_sha", "coldstart_ms", "warm_p50", "rss_kb" + ); + for r in rows { + let git_sha_short = r.git_sha.chars().take(12).collect::(); + let warm_p50 = r + .prove_warm_p50_ms + .map(|v| v.to_string()) + .unwrap_or_else(|| "n/a".into()); + let cold_start_ms = r.circuit_build_wall_ms + r.prove_cold_wall_ms; + eprintln!( + " {:<25} {:<14} {:>12} {:>9} {:>10} {} {} {}", + r.ran_at, + git_sha_short, + cold_start_ms, + warm_p50, + r.peak_rss_kb, + pass_marker(r.r2_warm_pass), + pass_marker(r.r2_cold_pass), + pass_marker(r.r2_mem_pass), + ); + } +} + +fn pass_marker(ok: bool) -> &'static str { + if ok { + "+" + } else { + "-" + } +} + +/// The post-Init `coin_history_root` is conventionally +/// `DEFAULT_HASHES[0]` — the empty SMT root. Independent of `prev_asth` +/// but kept as a function so the call site reads symmetrically. We +/// hash a sentinel to obtain that empty-tree root without depending on +/// the `DEFAULT_HASHES` private indexing. +fn init_proof_out_coins_root_from_init(_prev_asth: &HashDigest) -> HashDigest { + SparseMerkleTree::new().root() +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("probe_r2: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/node/src/db.rs b/node/src/db.rs index aa6a0730..5e0fc220 100644 --- a/node/src/db.rs +++ b/node/src/db.rs @@ -807,6 +807,13 @@ pub async fn load_all_usernames(pool: &PgPool) -> Result)>, /// fresh claim, `Ok(false)` if the name is already taken (no row /// inserted, existing row left untouched). The `ON CONFLICT DO /// NOTHING` makes this race-free at the SQL level. +/// +/// Gated by the `username-claim` Cargo feature so the write path can +/// be excluded from hosted images that don't offer claim as a UX +/// policy. The shared `usernames` table + `resolve_username` / +/// `load_all_usernames` read paths stay unconditional so existing +/// claimed names continue to resolve. +#[cfg(feature = "username-claim")] pub async fn claim_username( pool: &PgPool, name: &str, diff --git a/node/src/db_tests.rs b/node/src/db_tests.rs index 052ae8f3..649e6a8a 100644 --- a/node/src/db_tests.rs +++ b/node/src/db_tests.rs @@ -67,6 +67,12 @@ async fn connect_and_migrate_creates_all_tables() { // * After 0007 (request_log): 9 tables // * After 0008 (full DB trail): 19 tables + 1 trigger // * After 0009 / 0010: 19 tables (polish only) + // * After 0013 (R2 probe results): 22 tables + 1 view + // (`r2_probe_runs_summary` is a VIEW from migration 0013. + // Postgres lists views in BOTH `information_schema.views` AND + // `information_schema.tables` (with `table_type = 'VIEW'`), so + // it shows up here when introspecting without a `table_type` + // filter — included at the correct alphabetic position below.) assert_eq!( names, vec![ @@ -83,6 +89,10 @@ async fn connect_and_migrate_creates_all_tables() { "mmr_state".to_string(), "observed_inscriptions".to_string(), "pending_inscriptions".to_string(), + "r2_probe_hosts".to_string(), + "r2_probe_runs".to_string(), + "r2_probe_runs_summary".to_string(), + "r2_probe_warm_calls".to_string(), "request_log".to_string(), "smt_state".to_string(), "state_update_log".to_string(), @@ -300,6 +310,7 @@ async fn load_all_usernames_returns_empty_initially() { assert!(rows.is_empty()); } +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_returns_true_on_new() { let (pool, _container) = setup_pool().await; @@ -310,6 +321,7 @@ async fn claim_username_returns_true_on_new() { assert_eq!(rows, vec![("alice".to_string(), addr)]); } +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_returns_false_on_conflict() { let (pool, _container) = setup_pool().await; @@ -323,6 +335,11 @@ async fn claim_username_returns_false_on_conflict() { assert_eq!(rows, vec![("alice".to_string(), addr1)]); } +// Setup uses `claim_username` to seed a row, so this test only runs +// when the claim path is compiled in. The pure resolve-by-raw-INSERT +// path is exercised by `resolve_username_returns_none_for_unknown` +// plus the `username_tests.rs::resolve_*` cases. +#[cfg(feature = "username-claim")] #[tokio::test] async fn resolve_username_returns_address_for_claimed_name() { let (pool, _container) = setup_pool().await; diff --git a/node/src/lib.rs b/node/src/lib.rs index f5433dc9..c8a6b20b 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -14,6 +14,14 @@ //! Everything declared here is also `use`d from `main.rs` so the //! production binary keeps working with no change in behaviour. +// Opt in to the unstable `coverage_attribute` feature only when +// `cargo llvm-cov` defines the `coverage_nightly` cfg (it injects the +// flag automatically on a nightly toolchain). The `coverage(off)` +// annotations on the platform-`_impl` helpers in `r2_probe.rs` rely on +// this feature being enabled; the same pattern is used in +// `program-plonky2/src/lib.rs` and `script-plonky2/src/lib.rs`. Without +// the cfg gate the stable toolchain would refuse to compile the crate. +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] // `Account::new()` and `State::new()` are visible from the lib root // after the binary → bin+lib split. Clippy's `new_without_default` // lint did not fire while these types lived in a `bin` target — the @@ -29,6 +37,7 @@ pub mod account_node; pub mod audit; pub mod db; pub mod publisher; +pub mod r2_probe; pub mod router; pub mod runtime; pub mod scanner; @@ -140,7 +149,6 @@ where is_mainnet, network_name, ws_url, - track_tx_timeout: None, } } diff --git a/node/src/publisher.rs b/node/src/publisher.rs index 78cc57e5..c81f40d9 100644 --- a/node/src/publisher.rs +++ b/node/src/publisher.rs @@ -28,25 +28,12 @@ pub struct EsploraConfig { pub url: String, pub is_mainnet: bool, pub network_name: String, - /// Esplora WebSocket endpoint used by the publisher's per-broadcast - /// `track-tx` wait (issue #84). `None` falls back to - /// `ESPLORA_WS_URL` (defaulting to `wss://mutinynet.com/api/v1/ws`); - /// tests inject an in-process URL to avoid hitting the real - /// upstream. + /// Esplora WebSocket endpoint consumed by the block-tip scanner + /// (`scanner_ws::run_scanner_ws`). `None` falls back to + /// `ESPLORA_WS_URL` (defaulting to `wss://mutinynet.com/api/v1/ws`). + /// The publisher no longer uses this field — see + /// `broadcast_inscription_txs` for the direct-broadcast rationale. pub ws_url: Option, - /// Override for the per-broadcast `track-tx` safety-net (issue - /// #84). `None` uses the production default - /// `TRACK_TX_TIMEOUT_SECS = 30`; tests pass a short Duration so - /// the "broadcast genuinely failed" path (short WS timeout + - /// wiremock default 404 on `GET /tx/{txid}` ⇒ REST fallback returns - /// `None` ⇒ hard `WsError::Timeout`) does not stall the suite for - /// the full 30 s. - /// - /// Test-injection backdoor: production callers always leave this - /// `None` and inherit the 30 s safety-net. Hidden from the - /// rustdoc index (issue #84 review round 4 MINOR 5). - #[doc(hidden)] - pub track_tx_timeout: Option, } impl EsploraConfig { @@ -66,23 +53,6 @@ const MAX_CHUNK_SIZE: usize = 520; const MAX_MINING_ATTEMPTS: u32 = 400000; const MIN_INSCRIPTION_AMOUNT: u64 = 800; -/// Safety-net deadline for the per-broadcast `track-tx` WS wait -/// (issue #84). The publisher subscribes to the Esplora WS for the -/// commit txid before broadcasting the reveal, and proceeds the -/// moment the peer reports the commit as seen. If 30 s pass without -/// any track-tx event, the publisher issues a SINGLE REST -/// `GET /tx/{commit_txid}` fallback against the Esplora endpoint: -/// a 200 means the tx is in mempool / a block (the WS just missed -/// the frame, a regularly-observed Mutinynet failure mode) and the -/// publisher proceeds with the reveal; a 404 or any other error -/// propagates `WsError::Timeout`. The underlying rationale is -/// unchanged: a missing event without REST corroboration is still -/// a real upstream / network problem worth surfacing — never a -/// silent fallback to "broadcast the reveal anyway". -const TRACK_TX_TIMEOUT_SECS: u64 = 30; - -use crate::scanner_ws::DEFAULT_ESPLORA_WS_URL; - const COMMIT_TX_WITNESS_WEIGHT: Weight = Weight::from_wu(68); const REVEAL_TX_WITNESS_WEIGHT: Weight = Weight::from_wu(295); @@ -424,36 +394,43 @@ fn build_reveal_only_inner( } /// Broadcasts the commit and reveal transactions to the Bitcoin -/// network via the Esplora REST API and waits for the commit -/// transaction to appear in the mempool before sending the reveal. +/// network via the Esplora REST API as a sequential pair. +/// +/// Implementation: a plain +/// `client.broadcast(commit_tx).await?; client.broadcast(reveal_tx).await?;` +/// sequence. No WebSocket subscription, no inter-tx sleep, no +/// propagation watchdog — the two REST POSTs run back to back. /// -/// The propagation gap used to be papered over by a fixed 5 s -/// `PROPAGATION_WAIT_SECS` async sleep; issue #84 replaces -/// that polling wait with a short-lived WebSocket subscription to -/// `{"action":"track-tx","data":""}` against the -/// Esplora WS endpoint, returning the moment the peer reports the -/// commit txid as seen. A 30 s safety-net (`TRACK_TX_TIMEOUT_SECS`) -/// caps the WS wait; if it elapses we issue ONE REST -/// `GET /tx/{commit_txid}` against the Esplora endpoint and treat a -/// 200 as success (the tx is in mempool / a block and the WS just -/// missed the frame, a regularly-observed Mutinynet failure mode). -/// A 404 propagates the original WS timeout — the broadcast genuinely -/// did not land. This is a single REST GET, NOT a poll loop; the -/// no-polling invariant from the `CONTRIBUTING.md` "No polling — -/// events only" section is preserved. +/// Why this is race-free on our deployment topology: the node, the +/// `electrs` REST endpoint, and `bitcoind` share a single Docker +/// `bitcoin` network. `bitcoind::sendrawtransaction` only returns +/// after the tx has been accepted into the local mempool, so by the +/// time the commit POST resolves the commit UTXO is visible to the +/// same `bitcoind`'s mempool — which is the same mempool the reveal +/// POST hits a moment later via the same `electrs`. There is no +/// cross-host propagation window to bridge. /// -/// The fallback fires on the OUTER `TRACK_TX_TIMEOUT_SECS` budget -/// (exposed via `TrackTxStream::wait` in `scanner_ws.rs`) — the -/// inner per-frame `TRACK_TX_FRAME_WATCHDOG` reconnect loop in -/// `scanner_ws.rs` is untouched. +/// Why we used to wait: issue #84 replaced a fixed 5 s +/// `PROPAGATION_WAIT_SECS` sleep with a `{"action":"track-tx",...}` +/// WS subscription against the upstream Esplora WS. That made sense +/// when the upstream was the public mutinynet endpoint with real +/// cross-host propagation latency. After self-hosting our own +/// `mempool/backend:v3.3.1` we observed empirically that the backend +/// version does NOT emit any frame for `track-tx`; the WS wait always +/// timed out and the single-shot REST fallback (`GET /tx/{commit}`) +/// always confirmed the tx as already on-chain (DEV `request_log`: +/// `/api/mint` p50 ≈ 40 s of which ~30 s was watchdog; 16/16 REST +/// fallbacks in 72 h succeeded, 0 not-found, 0 errors). The wait was +/// pure latency tax for an in-cluster scenario it was never designed +/// for. Removing the subscribe + REST fallback brings `/api/mint` +/// from p50 ~40 s to ~11 s and `/api/send + /api/commit` from ~42 s +/// to ~13 s. /// -/// Order of operations is load-bearing: the `track-tx` subscription -/// MUST be established BEFORE the commit broadcast. Otherwise the -/// upstream may finish propagating the tx between -/// `client.broadcast(commit_tx)` and `subscribe_track_tx(...)`, and -/// the "tx in mempool" event would fire before any subscriber is -/// listening — wedging the wait for the full 30 s safety-net even -/// on the happy path. +/// "Events only" invariant from CONTRIBUTING.md is preserved: a +/// straight sequential broadcast is neither a poll loop nor a timed +/// sleep, so the CI "Forbid polling patterns" grep (see +/// CONTRIBUTING.md § "No polling — events only") keeps passing — +/// this PR strictly REMOVES sleeps from `publisher.rs`. pub async fn broadcast_inscription_txs( config: &EsploraConfig, commit_tx: &Transaction, @@ -463,79 +440,10 @@ pub async fn broadcast_inscription_txs( let builder = EsploraBuilder::new(&config.url); let client = EsploraAsyncClient::::from_builder(builder)?; - let commit_txid = commit_tx.compute_txid(); - let ws_url = config.ws_url.clone().unwrap_or_else(|| { - std::env::var("ESPLORA_WS_URL").unwrap_or_else(|_| DEFAULT_ESPLORA_WS_URL.to_string()) - }); - let track_tx_timeout = config - .track_tx_timeout - .unwrap_or_else(|| std::time::Duration::from_secs(TRACK_TX_TIMEOUT_SECS)); - - // Subscribe to the `track-tx` WS BEFORE broadcasting the commit - // (issue #84). The previous ordering opened a race window between - // the REST broadcast and the WS subscribe: if the peer finished - // propagating the tx in that window, the event fired before any - // listener was attached. - println!( - "Subscribing to commit tx {} via WS ({}) before broadcast...", - commit_txid, ws_url - ); - let stream = crate::scanner_ws::subscribe_track_tx(&ws_url, commit_txid).await?; - - println!("Broadcasting commit transaction..."); client.broadcast(commit_tx).await?; + let commit_txid = commit_tx.compute_txid(); println!("Commit transaction broadcast successfully: {}", commit_txid); - // Wait for the commit txid to surface in the upstream mempool - // before broadcasting the reveal. Event-driven (issue #84), - // not a fixed sleep — see the function docstring for the design. - println!( - "Waiting for commit tx {} to appear in mempool via WS (deadline {:?})...", - commit_txid, track_tx_timeout - ); - match stream.wait(track_tx_timeout).await { - Ok(()) => {} - Err(crate::scanner_ws::WsError::Timeout) => { - // Mutinynet's public WS endpoint regularly goes 30-90 s - // between frames; a 30 s WS timeout therefore does NOT - // prove the tx is not on-chain. Issue ONE REST GET to - // distinguish "WS missed the frame" (tx is in - // mempool / a block → success) from "broadcast genuinely - // failed" (404 → propagate the original timeout). - // - // Single GET, NOT a poll loop — see the - // "No polling — events only" section in CONTRIBUTING.md. - println!( - "WS timeout for {}; falling back to esplora-REST GET /tx/{}", - commit_txid, commit_txid - ); - match client.get_tx(&commit_txid).await { - Ok(Some(_)) => { - println!( - "esplora-REST fallback confirmed commit tx {} is on-chain / in mempool", - commit_txid - ); - } - Ok(None) => { - println!( - "esplora-REST fallback: commit tx {} not found (404); broadcast genuinely failed", - commit_txid - ); - return Err(Box::new(crate::scanner_ws::WsError::Timeout)); - } - Err(e) => { - println!( - "esplora-REST fallback failed for {}: {}; propagating original WS timeout", - commit_txid, e - ); - return Err(Box::new(crate::scanner_ws::WsError::Timeout)); - } - } - } - Err(other) => return Err(other.into()), - } - - println!("Broadcasting reveal transaction..."); client.broadcast(reveal_tx).await?; let reveal_txid = reveal_tx.compute_txid(); println!("Reveal transaction broadcast successfully: {}", reveal_txid); @@ -795,20 +703,7 @@ pub async fn broadcast_inscription_txs_with_persistence( let commit_txid = commit_tx.compute_txid(); let commit_txid_bytes = *commit_txid.as_byte_array(); - let ws_url = config.ws_url.clone().unwrap_or_else(|| { - std::env::var("ESPLORA_WS_URL").unwrap_or_else(|_| DEFAULT_ESPLORA_WS_URL.to_string()) - }); - let track_tx_timeout = config - .track_tx_timeout - .unwrap_or_else(|| std::time::Duration::from_secs(TRACK_TX_TIMEOUT_SECS)); - println!( - "Subscribing to commit tx {} via WS ({}) before broadcast...", - commit_txid, ws_url - ); - let stream = crate::scanner_ws::subscribe_track_tx(&ws_url, commit_txid).await?; - - println!("Broadcasting commit transaction..."); client.broadcast(commit_tx).await?; println!("Commit transaction broadcast successfully: {}", commit_txid); advance_pending_status( @@ -818,49 +713,6 @@ pub async fn broadcast_inscription_txs_with_persistence( ) .await; - println!( - "Waiting for commit tx {} to appear in mempool via WS (deadline {:?})...", - commit_txid, track_tx_timeout - ); - match stream.wait(track_tx_timeout).await { - Ok(()) => {} - Err(crate::scanner_ws::WsError::Timeout) => { - // Mutinynet's public WS endpoint regularly goes 30-90 s - // between frames; the REST fallback distinguishes "WS - // missed the frame" from a genuine broadcast failure. - // Same shape as `broadcast_inscription_txs` — see that - // function's docstring for the full rationale. - println!( - "WS timeout for {}; falling back to esplora-REST GET /tx/{}", - commit_txid, commit_txid - ); - match client.get_tx(&commit_txid).await { - Ok(Some(_)) => { - println!( - "esplora-REST fallback confirmed commit tx {} is on-chain / in mempool", - commit_txid - ); - } - Ok(None) => { - println!( - "esplora-REST fallback: commit tx {} not found (404); broadcast genuinely failed", - commit_txid - ); - return Err(Box::new(crate::scanner_ws::WsError::Timeout)); - } - Err(e) => { - println!( - "esplora-REST fallback failed for {}: {}; propagating original WS timeout", - commit_txid, e - ); - return Err(Box::new(crate::scanner_ws::WsError::Timeout)); - } - } - } - Err(other) => return Err(other.into()), - } - - println!("Broadcasting reveal transaction..."); client.broadcast(reveal_tx).await?; let reveal_txid = reveal_tx.compute_txid(); println!("Reveal transaction broadcast successfully: {}", reveal_txid); diff --git a/node/src/publisher_tests.rs b/node/src/publisher_tests.rs index 57a9faf2..4a8efc85 100644 --- a/node/src/publisher_tests.rs +++ b/node/src/publisher_tests.rs @@ -13,14 +13,10 @@ use bitcoin::hashes::Hash; use bitcoin::script::Instruction; use bitcoin::secp256k1::{Keypair, Secp256k1, SecretKey}; use bitcoin::{Address, Network, OutPoint, Txid, XOnlyPublicKey}; -use futures_util::{SinkExt, StreamExt}; use serde_json::json; use std::str::FromStr; -use std::time::Duration; use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt}; use testcontainers_modules::postgres::Postgres; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::Message as WsMessage; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -56,64 +52,10 @@ async fn setup_mock_esplora() -> (MockServer, EsploraConfig) { is_mainnet: false, network_name: "Mutinynet".to_string(), ws_url: None, - track_tx_timeout: None, }; (mock_server, config) } -/// Spin up an in-process WS server that emulates the Esplora -/// `track-tx` flow used by `broadcast_inscription_txs` (issue #84): -/// accept the subscribe frame and, depending on `mode`, either echo -/// back a `mempool: true` event for the txid the client subscribed -/// to (mode = "echo") or stay silent (mode = "silent") so the -/// publisher's 30-s safety-net fires. Returns the `ws://` URL. -async fn spawn_track_tx_ws(mode: &'static str) -> String { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let url = format!("ws://{}", addr); - tokio::spawn(async move { - loop { - let (stream, _) = match listener.accept().await { - Ok(s) => s, - Err(_) => return, - }; - let mut ws = match tokio_tungstenite::accept_async(stream).await { - Ok(w) => w, - Err(_) => continue, - }; - // Read the subscribe frame. - let first = match ws.next().await { - Some(Ok(WsMessage::Text(t))) => t, - _ => continue, - }; - let value: serde_json::Value = match serde_json::from_str(&first) { - Ok(v) => v, - Err(_) => continue, - }; - if value.get("action") == Some(&serde_json::json!("track-tx")) { - if let Some(txid_str) = value.get("data").and_then(|v| v.as_str()) { - if mode == "echo" { - // Documented mempool.space `txPosition` shape; - // see `scanner_ws::frame_signals_tx_seen`. - let frame = format!( - r#"{{"txPosition":{{"txid":"{}","position":{{"block":1,"vsize":120}}}}}}"#, - txid_str - ); - let _ = ws.send(WsMessage::Text(frame)).await; - } - } - } - // Hold the connection open until the test aborts the - // task. `std::future::pending` keeps the socket alive - // indefinitely so a slow CI runner can never let the - // helper observe a clean close before the event arrives; - // a bounded `sleep(60s)` could expire and mask a race. - std::future::pending::<()>().await; - } - }); - url -} - // ----------------------------------------------------------------------------- // Pure logic: inscription_txs // ----------------------------------------------------------------------------- @@ -127,7 +69,6 @@ fn inscription_txs_produces_taproot_commit_and_reveal_with_marker_prefix() { is_mainnet: false, network_name: "Mutinynet".to_string(), ws_url: None, - track_tx_timeout: None, }; let publisher_address = test_publisher_address(config.network()); let outpoints = vec![(fake_outpoint(0), 100_000u64)]; @@ -167,7 +108,6 @@ fn inscription_txs_embeds_commitment_data_in_reveal_script() { is_mainnet: false, network_name: "Mutinynet".to_string(), ws_url: None, - track_tx_timeout: None, }; let publisher_address = test_publisher_address(config.network()); let payload = b"Hello, zkCoins!".to_vec(); @@ -233,7 +173,6 @@ fn inscription_txs_chunks_large_commitment_data() { is_mainnet: false, network_name: "Mutinynet".to_string(), ws_url: None, - track_tx_timeout: None, }; let publisher_address = test_publisher_address(config.network()); // 600 bytes of repeating non-zero pattern (zero bytes would collide @@ -295,7 +234,6 @@ fn inscription_txs_signs_commit_input_with_taproot_keyspend() { is_mainnet: false, network_name: "Mutinynet".to_string(), ws_url: None, - track_tx_timeout: None, }; let publisher_address = test_publisher_address(config.network()); let outpoints = vec![(fake_outpoint(0), 100_000u64)]; @@ -335,7 +273,6 @@ fn inscription_txs_uses_signet_when_is_mainnet_false() { is_mainnet: false, network_name: "Mutinynet".to_string(), ws_url: None, - track_tx_timeout: None, }; assert_eq!(config.network(), Network::Signet); @@ -345,7 +282,6 @@ fn inscription_txs_uses_signet_when_is_mainnet_false() { is_mainnet: true, network_name: "Mainnet".to_string(), ws_url: None, - track_tx_timeout: None, }; assert_eq!(mainnet_config.network(), Network::Bitcoin); } @@ -433,10 +369,7 @@ async fn get_publisher_utxo_returns_empty_when_total_below_minimum() { #[tokio::test] async fn broadcast_inscription_txs_returns_both_txids_on_success() { - let (server, mut config) = setup_mock_esplora().await; - // Plug a mock WS server in so the publisher's track-tx wait - // resolves immediately instead of hitting its 30-s safety-net. - config.ws_url = Some(spawn_track_tx_ws("echo").await); + let (server, config) = setup_mock_esplora().await; let publisher_address = test_publisher_address(config.network()); let outpoints = vec![(fake_outpoint(0), 100_000u64)]; @@ -467,49 +400,6 @@ async fn broadcast_inscription_txs_returns_both_txids_on_success() { assert_eq!(got_reveal, expected_reveal_txid); } -#[tokio::test] -async fn broadcast_inscription_txs_errors_when_track_tx_event_never_arrives() { - // Silent WS mock — exercises the "broadcast genuinely failed" - // path: the short WS timeout elapses, the publisher's REST - // fallback hits the wiremock default (no `GET /tx/{txid}` route - // mounted ⇒ 404 ⇒ esplora-client returns `Ok(None)`), and the - // publisher surfaces a hard `WsError::Timeout` instead of - // silently broadcasting the reveal (issue #84 design). - let (server, mut config) = setup_mock_esplora().await; - config.ws_url = Some(spawn_track_tx_ws("silent").await); - // Override the production 30-s deadline so the test fails fast - // rather than blocking the suite for half a minute. - config.track_tx_timeout = Some(Duration::from_millis(300)); - let publisher_address = test_publisher_address(config.network()); - let outpoints = vec![(fake_outpoint(0), 100_000u64)]; - - let (commit_tx, reveal_tx, _stats) = inscription_txs( - b"Hello, zkCoins!", - &publisher_address, - outpoints, - TEST_PUBLISHER_KEY, - &config, - ); - - Mock::given(method("POST")) - .and(path("/tx")) - .respond_with( - ResponseTemplate::new(200).set_body_string(commit_tx.compute_txid().to_string()), - ) - .mount(&server) - .await; - - let err = broadcast_inscription_txs(&config, &commit_tx, &reveal_tx) - .await - .expect_err("silent WS must surface a hard error, not silent fallback"); - assert!( - err.to_string().to_lowercase().contains("timeout") - || err.to_string().to_lowercase().contains("ws"), - "error should mention the WS timeout, got: {}", - err - ); -} - #[tokio::test] async fn broadcast_inscription_txs_propagates_esplora_error() { let (server, config) = setup_mock_esplora().await; @@ -574,8 +464,7 @@ async fn create_and_broadcast_inscription_fails_when_no_utxos() { #[tokio::test] async fn create_and_broadcast_inscription_succeeds_end_to_end_with_mocked_esplora() { - let (server, mut config) = setup_mock_esplora().await; - config.ws_url = Some(spawn_track_tx_ws("echo").await); + let (server, config) = setup_mock_esplora().await; let publisher_address = test_publisher_address(config.network()); // 1) Address-UTXO lookup — return one UTXO with enough sats to cover @@ -696,7 +585,6 @@ fn build_test_pair(commitment_data: &[u8]) -> (Transaction, Transaction) { is_mainnet: false, network_name: "Mutinynet".to_string(), ws_url: None, - track_tx_timeout: None, }; let publisher_address = test_publisher_address(config.network()); let outpoints = vec![(fake_outpoint(0), 100_000u64)]; @@ -747,8 +635,7 @@ async fn seed_pending_row( #[tokio::test] async fn broadcast_persists_constructed_row_before_commit_broadcast() { let (pool, _container) = setup_phaseb_pool().await; - let (server, mut config) = setup_mock_esplora().await; - config.ws_url = Some(spawn_track_tx_ws("echo").await); + let (server, config) = setup_mock_esplora().await; let publisher_address = test_publisher_address(config.network()); let funding_txid = "3333333333333333333333333333333333333333333333333333333333333333"; @@ -803,13 +690,7 @@ async fn broadcast_persists_constructed_row_before_commit_broadcast() { #[tokio::test] async fn broadcast_advances_to_commit_broadcast_after_commit_success() { let (pool, _container) = setup_phaseb_pool().await; - let (server, mut config) = setup_mock_esplora().await; - // Silent WS so the post-commit track-tx wait times out and the - // REST fallback (no GET mounted ⇒ 404) propagates the WS timeout. - // This stops the broadcast BEFORE the reveal POST fires, leaving - // the row in `commit_broadcast`. - config.ws_url = Some(spawn_track_tx_ws("silent").await); - config.track_tx_timeout = Some(Duration::from_millis(200)); + let (server, config) = setup_mock_esplora().await; let publisher_address = test_publisher_address(config.network()); Mock::given(method("GET")) @@ -824,14 +705,26 @@ async fn broadcast_advances_to_commit_broadcast_after_commit_success() { ]))) .mount(&server) .await; - // Accept the commit POST (200) — every POST /tx hits this single - // mock. The publisher then waits for the WS event that never - // arrives, and the broadcast errors out before the reveal POST is - // attempted, so we can observe the intermediate `commit_broadcast` - // status. + // Two POST /tx mocks differentiated by explicit `with_priority` + // (lower number = higher priority in wiremock; default is 5). The + // high-priority `up_to_n_times(1)` 200 matches the commit POST; + // after it is consumed, subsequent POSTs fall through to the + // lower-priority 400 fallback (the reveal POST is rejected so the + // broadcast errors after advancing the row to `commit_broadcast`). + // The post-broadcast WS wait was removed alongside `track-tx`, so + // this layered mock is now the only way to observe the + // intermediate `commit_broadcast` status. Mock::given(method("POST")) .and(path("/tx")) .respond_with(ResponseTemplate::new(200).set_body_string("ok")) + .up_to_n_times(1) + .with_priority(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/tx")) + .respond_with(ResponseTemplate::new(400).set_body_string("simulated reveal failure")) + .with_priority(2) .mount(&server) .await; @@ -842,11 +735,11 @@ async fn broadcast_advances_to_commit_broadcast_after_commit_success() { Some(&pool), ) .await - .expect_err("WS timeout (silent mock + no REST fallback) must surface"); + .expect_err("reveal POST 400 must surface as Err"); // One row, advanced from `constructed` to `commit_broadcast` by // the commit-OK hook but stuck there because the reveal step - // never ran. + // failed. assert_eq!(count_pending_rows(&pool).await, 1); let (commit_txid_bytes,): (Vec,) = sqlx::query_as("SELECT commit_txid FROM pending_inscriptions") @@ -868,8 +761,7 @@ async fn broadcast_advances_to_reveal_broadcast_after_reveal_success() { // test exercises the publisher in isolation (no mint flow), so the // expected terminal status here is `reveal_broadcast`. let (pool, _container) = setup_phaseb_pool().await; - let (server, mut config) = setup_mock_esplora().await; - config.ws_url = Some(spawn_track_tx_ws("echo").await); + let (server, config) = setup_mock_esplora().await; let publisher_address = test_publisher_address(config.network()); Mock::given(method("GET")) @@ -1217,8 +1109,7 @@ async fn resume_tolerates_bad_inputs_error_on_double_spend() { #[tokio::test] async fn mint_handler_advances_state_synchronously_with_broadcast() { let (pool, _container) = setup_phaseb_pool().await; - let (server, mut config) = setup_mock_esplora().await; - config.ws_url = Some(spawn_track_tx_ws("echo").await); + let (server, config) = setup_mock_esplora().await; let publisher_address = test_publisher_address(config.network()); Mock::given(method("GET")) diff --git a/node/src/r2_probe.rs b/node/src/r2_probe.rs new file mode 100644 index 00000000..ae6066ef --- /dev/null +++ b/node/src/r2_probe.rs @@ -0,0 +1,359 @@ +//! R2 probe result persistence layer. +//! +//! The `probe_r2` binary (`node/src/bin/probe_r2.rs`) measures the +//! three ROADMAP step 9 budgets — warm `prove_*` wall, cold-start +//! wall, peak RSS — against the M3 Ultra reference hardware. Until +//! migration 0013 the only durable output was a JSON file on disk; +//! regression tracking meant grepping through a tree of timestamped +//! files. This module persists the same data into Postgres so the +//! operator can answer trend / regression queries in SQL. +//! +//! ## Schema overview (3 tables + 1 view) +//! +//! * [`HostInfo`] / `r2_probe_hosts` — normalised per-machine +//! identity. The `(hostname, os, arch, cpu_brand)` natural key +//! matches [`upsert_host`]'s `ON CONFLICT` clause, so re-running +//! the probe on the same box returns the same `id` instead of +//! piling up duplicate rows. +//! * [`ProbeRun`] / `r2_probe_runs` — one row per probe execution. +//! Holds every scalar measurement, the run-time context (git sha, +//! rustc version, allocator, circuit params), and the R2 budgets +//! the run was checked against. The budgets are persisted on the +//! row so a future budget tweak does NOT retroactively flip the +//! pass/fail in [`SummaryRow`]. +//! * `r2_probe_warm_calls` — one row per warm call (call_index + +//! wall_ms). Lets the operator recompute percentiles or inspect +//! outliers later. FK ON DELETE CASCADE so pruning a single run +//! row cleans up its children atomically. +//! +//! [`fetch_recent_summary`] reads from the `r2_probe_runs_summary` +//! view, which joins host + run and inlines the three budget-pass +//! booleans the admin endpoint surfaces. +//! +//! ## Callers +//! +//! The `probe_r2` binary writes via [`upsert_host`], [`insert_run`], +//! and [`insert_warm_calls`] when invoked with `--persist`, and reads +//! the last few rows via [`fetch_recent_summary`] for the console +//! trend table. The router's `r2_probe_history_handler` reads via +//! [`fetch_recent_summary`] to back the `GET +//! /api/admin/r2-probe/history` endpoint. + +use serde::Serialize; +use sqlx::PgPool; + +/// Per-machine identity persisted in `r2_probe_hosts`. The natural +/// key `(hostname, os, arch, cpu_brand)` is captured here; the +/// remaining fields (`cpu_cores`, `total_ram_gb`) are payload and +/// updated on conflict so a hardware add (more RAM, a CPU swap) is +/// reflected without manual cleanup. +#[derive(Debug, Clone)] +pub struct HostInfo { + pub hostname: String, + pub os: String, + pub arch: String, + pub cpu_brand: String, + pub cpu_cores: i32, + /// `None` when the platform path that reads RAM size is + /// unavailable. The DB column is nullable for the same reason. + pub total_ram_gb: Option, +} + +/// Best-effort detection of the host running the probe. +/// +/// Backed by the [`sysinfo`] crate, which wraps the per-platform host +/// introspection APIs (sysctl on macOS, `/proc` on Linux, Win32 on +/// Windows) behind a single Rust surface. Every leg has exactly one +/// success path — there are no subprocess- or FS-error arms that the +/// test gate can't reach on a healthy CI host, so the 100% line / +/// function coverage gate is satisfied without any `coverage(off)` +/// markers. The earlier shape shelled out to `hostname` / `sysctl` / +/// `/proc/...` directly and had to opt the per-platform helpers out +/// of the gate; that's gone now. +/// +/// Fallbacks are conservative: an unknown hostname becomes +/// `"unknown"`, an empty CPU brand becomes `"unknown"`, a zero or +/// missing total-RAM reading becomes `None` (matching the nullable +/// DB column). The row still lands so the operator can correct it +/// later. +pub fn detect() -> HostInfo { + // `RefreshKind::nothing().with_cpu(...).with_memory(...)` keeps + // the constructor from touching the (expensive) process list. The + // CPU refresh asks for frequency only — the CPU `brand` field is + // populated as a side-effect of the first CPU refresh on every + // backend (apple sysctl, linux `/proc/cpuinfo`), and we don't need + // usage / per-core stats. + let mut sys = sysinfo::System::new_with_specifics( + sysinfo::RefreshKind::nothing() + .with_cpu(sysinfo::CpuRefreshKind::nothing().with_frequency()) + .with_memory(sysinfo::MemoryRefreshKind::nothing().with_ram()), + ); + // Explicit refresh calls are belt-and-braces over the constructor + // refresh: on platforms where `new_with_specifics` is a no-op for + // a given kind (rare, but documented for some embedded targets) + // this guarantees the fields we read below are populated. + sys.refresh_cpu_all(); + sys.refresh_memory(); + + // Pull every sysinfo-sourced datum into a plain Option / scalar + // value before handing off to [`detect_impl`]. The split is + // deliberate: on a healthy host every leg below resolves to + // `Some(_)` / a non-zero number, which means the `"unknown"` / + // zero fallback paths inside `detect_impl` are unreachable from + // `detect()`'s call site on the CI runner. Threading the values + // through a separate function lets the test suite drive the + // fallback closures with synthetic `None` / empty inputs, so the + // 100 % line / function coverage gate stays green without + // platform-specific test scaffolding. + detect_impl( + sysinfo::System::host_name(), + sys.cpus().first().map(|c| c.brand().trim().to_string()), + sys.total_memory(), + std::thread::available_parallelism() + .map(|n| n.get() as i32) + .ok(), + ) +} + +/// Pure assembly of [`HostInfo`] from the four host-introspection +/// readings [`detect`] pulls out of `sysinfo` and `std`. Split out so +/// the `"unknown"` / zero fallbacks below are reachable from unit +/// tests that pass synthetic `None` / empty-string inputs — the live +/// `detect()` call on the CI runner never lands on those branches. +/// +/// * `hostname_opt` — value from `sysinfo::System::host_name()`. +/// `None` or `Some("")` collapses to the `"unknown"` fallback. +/// * `cpu_brand_opt` — value from +/// `sys.cpus().first().map(|c| c.brand().trim().to_string())`. +/// `None` or `Some("")` collapses to the `"unknown"` fallback. +/// * `total_memory_bytes` — value from `sys.total_memory()`. Divided +/// down to whole GiB; a zero reading collapses to `None` so a +/// sysinfo backend that failed to populate the field doesn't +/// persist a nonsense `0`. +/// * `cpu_cores_opt` — value from +/// `std::thread::available_parallelism().map(|n| n.get() as i32).ok()`. +/// `None` falls back to `0`. +fn detect_impl( + hostname_opt: Option, + cpu_brand_opt: Option, + total_memory_bytes: u64, + cpu_cores_opt: Option, +) -> HostInfo { + HostInfo { + hostname: hostname_opt + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()), + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + cpu_brand: cpu_brand_opt + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()), + cpu_cores: cpu_cores_opt.unwrap_or(0), + total_ram_gb: Some((total_memory_bytes / (1024 * 1024 * 1024)) as i32).filter(|&g| g > 0), + } +} + +/// Full row written to `r2_probe_runs`. Every field maps 1:1 to a +/// column; nullable columns are `Option<...>` here. +#[derive(Debug, Clone)] +pub struct ProbeRun { + pub host_id: i32, + pub git_sha: String, + pub binary_version: String, + pub rustc_version: String, + pub build_profile: String, + pub allocator: String, + pub max_in_coins: i32, + pub max_out_coins: i32, + pub inner_pad_bits: i32, + pub warm_calls_requested: i32, + pub circuit_build_wall_ms: i64, + pub prove_cold_wall_ms: i64, + pub verify_wall_ms: i64, + pub peak_rss_kb: i64, + pub prove_warm_p50_ms: Option, + pub prove_warm_p90_ms: Option, + pub prove_warm_p99_ms: Option, + pub succeeded: bool, + pub error_message: Option, + pub notes: Option, + pub tags: Vec, + pub r2_warm_budget_ms: i64, + pub r2_cold_budget_ms: i64, + pub r2_mem_budget_kb: i64, +} + +/// Materialised row returned by [`fetch_recent_summary`]. Mirrors +/// the columns of the `r2_probe_runs_summary` view. +#[derive(Debug, Clone, Serialize)] +pub struct SummaryRow { + pub id: i64, + /// RFC-3339 timestamp formatted in Postgres so we stay off the + /// chrono/time sqlx feature flags. + pub ran_at: String, + pub hostname: String, + pub cpu_brand: String, + pub git_sha: String, + pub build_profile: String, + pub allocator: String, + pub circuit_build_wall_ms: i64, + pub prove_cold_wall_ms: i64, + pub prove_warm_p50_ms: Option, + pub prove_warm_p90_ms: Option, + pub prove_warm_p99_ms: Option, + pub peak_rss_kb: i64, + pub r2_cold_pass: bool, + pub r2_warm_pass: bool, + pub r2_mem_pass: bool, + pub succeeded: bool, +} + +/// Insert or update a host row keyed on the natural identity +/// `(hostname, os, arch, cpu_brand)`. Returns the row id either way +/// — the `ON CONFLICT ... DO UPDATE` is required (over `DO NOTHING`) +/// so the `RETURNING id` clause fires on the conflict path too. +pub async fn upsert_host(pool: &PgPool, host: &HostInfo) -> sqlx::Result { + let row: (i32,) = sqlx::query_as( + "INSERT INTO r2_probe_hosts \ + (hostname, os, arch, cpu_brand, cpu_cores, total_ram_gb) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT (hostname, os, arch, cpu_brand) DO UPDATE \ + SET cpu_cores = EXCLUDED.cpu_cores, \ + total_ram_gb = EXCLUDED.total_ram_gb \ + RETURNING id", + ) + .bind(&host.hostname) + .bind(&host.os) + .bind(&host.arch) + .bind(&host.cpu_brand) + .bind(host.cpu_cores) + .bind(host.total_ram_gb) + .fetch_one(pool) + .await?; + Ok(row.0) +} + +/// Insert a single `r2_probe_runs` row and return its id. Callers +/// pass the host id obtained from a prior [`upsert_host`] call. +pub async fn insert_run(pool: &PgPool, run: &ProbeRun) -> sqlx::Result { + let row: (i64,) = sqlx::query_as( + "INSERT INTO r2_probe_runs ( \ + host_id, git_sha, binary_version, rustc_version, build_profile, allocator, \ + max_in_coins, max_out_coins, inner_pad_bits, warm_calls_requested, \ + circuit_build_wall_ms, prove_cold_wall_ms, verify_wall_ms, peak_rss_kb, \ + prove_warm_p50_ms, prove_warm_p90_ms, prove_warm_p99_ms, \ + succeeded, error_message, notes, tags, \ + r2_warm_budget_ms, r2_cold_budget_ms, r2_mem_budget_kb \ + ) VALUES ( \ + $1, $2, $3, $4, $5, $6, \ + $7, $8, $9, $10, \ + $11, $12, $13, $14, \ + $15, $16, $17, \ + $18, $19, $20, $21, \ + $22, $23, $24 \ + ) RETURNING id", + ) + .bind(run.host_id) + .bind(&run.git_sha) + .bind(&run.binary_version) + .bind(&run.rustc_version) + .bind(&run.build_profile) + .bind(&run.allocator) + .bind(run.max_in_coins) + .bind(run.max_out_coins) + .bind(run.inner_pad_bits) + .bind(run.warm_calls_requested) + .bind(run.circuit_build_wall_ms) + .bind(run.prove_cold_wall_ms) + .bind(run.verify_wall_ms) + .bind(run.peak_rss_kb) + .bind(run.prove_warm_p50_ms) + .bind(run.prove_warm_p90_ms) + .bind(run.prove_warm_p99_ms) + .bind(run.succeeded) + .bind(run.error_message.as_deref()) + .bind(run.notes.as_deref()) + .bind(&run.tags) + .bind(run.r2_warm_budget_ms) + .bind(run.r2_cold_budget_ms) + .bind(run.r2_mem_budget_kb) + .fetch_one(pool) + .await?; + Ok(row.0) +} + +/// Batch-insert per-warm-call samples. No-op (no SQL round-trip) +/// when `calls` is empty so a `--warm-calls 0` run cleanly persists +/// an empty child set. Uses `UNNEST` so the whole batch lands in a +/// single statement regardless of `calls.len()`. +pub async fn insert_warm_calls(pool: &PgPool, run_id: i64, calls: &[i64]) -> sqlx::Result<()> { + if calls.is_empty() { + return Ok(()); + } + let indices: Vec = (0..calls.len() as i32).collect(); + sqlx::query( + "INSERT INTO r2_probe_warm_calls (probe_run_id, call_index, wall_ms) \ + SELECT $1, idx, wall \ + FROM UNNEST($2::int[], $3::bigint[]) AS t(idx, wall)", + ) + .bind(run_id) + .bind(&indices) + .bind(calls) + .execute(pool) + .await?; + Ok(()) +} + +/// Fetch the most recent `limit` rows from the `r2_probe_runs_summary` +/// view, newest first. Backs the admin trend endpoint and the probe +/// binary's console trend table after `--persist`. +/// +/// `sqlx`'s tuple `FromRow` impl tops out at 16 columns; the summary +/// has 17, so this helper drives `sqlx::query` and uses `Row::get` +/// for the column extraction. The trade-off is one untyped layer +/// over named columns — exactly the shape `db.rs` uses for the +/// pending-inscription summary read. +pub async fn fetch_recent_summary(pool: &PgPool, limit: i64) -> sqlx::Result> { + use sqlx::Row; + let rows = sqlx::query( + "SELECT id, \ + to_char(ran_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS ran_at, \ + hostname, cpu_brand, git_sha, build_profile, allocator, \ + circuit_build_wall_ms, prove_cold_wall_ms, \ + prove_warm_p50_ms, prove_warm_p90_ms, prove_warm_p99_ms, \ + peak_rss_kb, \ + r2_cold_pass, r2_warm_pass, r2_mem_pass, succeeded \ + FROM r2_probe_runs_summary \ + ORDER BY ran_at DESC \ + LIMIT $1", + ) + .bind(limit) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|r| SummaryRow { + id: r.get("id"), + ran_at: r.get("ran_at"), + hostname: r.get("hostname"), + cpu_brand: r.get("cpu_brand"), + git_sha: r.get("git_sha"), + build_profile: r.get("build_profile"), + allocator: r.get("allocator"), + circuit_build_wall_ms: r.get("circuit_build_wall_ms"), + prove_cold_wall_ms: r.get("prove_cold_wall_ms"), + prove_warm_p50_ms: r.get("prove_warm_p50_ms"), + prove_warm_p90_ms: r.get("prove_warm_p90_ms"), + prove_warm_p99_ms: r.get("prove_warm_p99_ms"), + peak_rss_kb: r.get("peak_rss_kb"), + r2_cold_pass: r.get("r2_cold_pass"), + r2_warm_pass: r.get("r2_warm_pass"), + r2_mem_pass: r.get("r2_mem_pass"), + succeeded: r.get("succeeded"), + }) + .collect()) +} + +#[cfg(test)] +#[path = "r2_probe_tests.rs"] +mod tests; diff --git a/node/src/r2_probe_tests.rs b/node/src/r2_probe_tests.rs new file mode 100644 index 00000000..6b97d8b3 --- /dev/null +++ b/node/src/r2_probe_tests.rs @@ -0,0 +1,494 @@ +// Tests for the R2-probe persistence layer. +// +// Strategy mirrors `db_tests`: every test boots its own Postgres 17 +// testcontainer via `testcontainers_modules::postgres::Postgres`. The +// per-test isolation removes any cross-test ordering risk and the +// node test gate already runs single-threaded +// (`--test-threads=1`), so the per-container boot cost is amortised +// across the whole suite. + +use super::*; +use sqlx::Row; +use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt}; +use testcontainers_modules::postgres::Postgres; + +use crate::db::connect_and_migrate; + +/// Start a fresh `postgres:17` container with the full migration set +/// applied. The container handle is returned alongside the pool so +/// the caller can keep it alive for the duration of the test. +async fn setup_pool() -> (PgPool, ContainerAsync) { + let container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = container + .get_host() + .await + .expect("failed to get container host"); + let port = container + .get_host_port_ipv4(5432) + .await + .expect("failed to get container port"); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"); + (pool, container) +} + +fn sample_host(suffix: &str) -> HostInfo { + HostInfo { + hostname: format!("test-host-{suffix}"), + os: "macos".to_string(), + arch: "aarch64".to_string(), + cpu_brand: "Apple M3 Ultra".to_string(), + cpu_cores: 24, + total_ram_gb: Some(96), + } +} + +fn sample_run(host_id: i32) -> ProbeRun { + ProbeRun { + host_id, + git_sha: "deadbeefcafe".to_string(), + binary_version: "0.1.0".to_string(), + rustc_version: "rustc 1.81.0".to_string(), + build_profile: "release".to_string(), + allocator: "mimalloc".to_string(), + max_in_coins: 8, + max_out_coins: 8, + inner_pad_bits: 15, + warm_calls_requested: 5, + circuit_build_wall_ms: 9_500, + prove_cold_wall_ms: 18_000, + verify_wall_ms: 25, + peak_rss_kb: 40 * 1024 * 1024, + prove_warm_p50_ms: Some(900), + prove_warm_p90_ms: Some(1_100), + prove_warm_p99_ms: Some(1_300), + succeeded: true, + error_message: None, + notes: Some("smoke".to_string()), + tags: vec!["smoke".to_string(), "local".to_string()], + r2_warm_budget_ms: 5_000, + r2_cold_budget_ms: 30_000, + r2_mem_budget_kb: 64 * 1024 * 1024, + } +} + +#[tokio::test] +async fn detect_returns_a_host_struct() { + // `detect()` now sits on top of the `sysinfo` crate (cross-platform + // host introspection). Every field has exactly one success path and + // a conservative fallback, so the assertions below pin the actual + // observable contract — not just "the function didn't panic": + // + // * `hostname` is non-empty (either the OS-reported value or the + // `"unknown"` fallback when `sysinfo::System::host_name()` is + // `None` / empty); + // * `os` / `arch` come from `std::env::consts::{OS,ARCH}` which + // are compile-time constants and always non-empty; + // * `cpu_brand` is non-empty for the same reason (real brand or + // the `"unknown"` fallback); + // * `cpu_cores >= 1` — any host that can run this test has at + // least one schedulable thread, so `available_parallelism()` + // returning 0 would be a real bug, not a CI quirk; + // * `total_ram_gb` is either `None` (sysinfo couldn't read it) + // or `>= 1` (the `> 0` filter in `detect()` collapses the + // zero-reading case into `None`). + let info = detect(); + assert!(!info.hostname.is_empty(), "hostname must be non-empty"); + assert!(!info.os.is_empty(), "os must be non-empty"); + assert!(!info.arch.is_empty(), "arch must be non-empty"); + assert!(!info.cpu_brand.is_empty(), "cpu_brand must be non-empty"); + assert!( + info.cpu_cores >= 1, + "cpu_cores must be at least 1 on any host that runs this test, got {}", + info.cpu_cores, + ); + match info.total_ram_gb { + Some(g) => assert!( + g >= 1, + "total_ram_gb Some(_) must be >= 1 (zero is collapsed to None), got {}", + g, + ), + None => {} + } +} + +#[test] +fn detect_impl_uses_fallbacks_when_inputs_are_none() { + // Drives every `unwrap_or_else` / `unwrap_or` fallback path in + // `detect_impl`. On the CI runner the live `detect()` inputs are + // always populated (host_name() returns `Some(_)`, the CPU list is + // non-empty, available_parallelism() returns `Ok(_)`), so without + // this synthetic call those closures would never execute and the + // 100 % coverage gate would mark them as uncovered functions. + let info = detect_impl(None, None, 0, None); + assert_eq!(info.hostname, "unknown"); + assert_eq!(info.cpu_brand, "unknown"); + assert_eq!(info.cpu_cores, 0); + assert_eq!(info.total_ram_gb, None); + // `os` / `arch` come from compile-time constants, so they remain + // non-empty regardless of the synthetic inputs above. + assert!(!info.os.is_empty()); + assert!(!info.arch.is_empty()); +} + +#[test] +fn detect_impl_uses_fallback_when_inputs_are_empty_strings() { + // Exercises the `.filter(|s| !s.is_empty())` branch on both string + // fields: a `Some("")` from sysinfo must collapse to `"unknown"` + // the same way a `None` does. + let info = detect_impl(Some(String::new()), Some(String::new()), 0, None); + assert_eq!(info.hostname, "unknown"); + assert_eq!(info.cpu_brand, "unknown"); + assert_eq!(info.cpu_cores, 0); + assert_eq!(info.total_ram_gb, None); +} + +#[test] +fn detect_impl_uses_inputs_when_provided() { + // Pins the happy path: every value flows through unchanged. 96 GiB + // exact bytes (96 * 1024^3) divides to `Some(96)` after the GiB + // collapse; the `> 0` filter keeps it as `Some(_)`. + let info = detect_impl( + Some("ci-runner.local".to_string()), + Some("Apple M3 Ultra".to_string()), + 96 * 1024 * 1024 * 1024, + Some(32), + ); + assert_eq!(info.hostname, "ci-runner.local"); + assert_eq!(info.cpu_brand, "Apple M3 Ultra"); + assert_eq!(info.cpu_cores, 32); + assert_eq!(info.total_ram_gb, Some(96)); + assert!(!info.os.is_empty()); + assert!(!info.arch.is_empty()); +} + +#[tokio::test] +async fn upsert_host_returns_same_id_on_natural_key_match() { + let (pool, _container) = setup_pool().await; + let host = sample_host("alpha"); + let id1 = upsert_host(&pool, &host).await.expect("first upsert"); + let id2 = upsert_host(&pool, &host).await.expect("second upsert"); + assert_eq!(id1, id2, "same natural key must map to the same id"); + + // Confirm only one row exists. + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM r2_probe_hosts") + .fetch_one(&pool) + .await + .expect("count query"); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn upsert_host_updates_payload_fields_on_conflict() { + let (pool, _container) = setup_pool().await; + let mut host = sample_host("beta"); + host.cpu_cores = 16; + host.total_ram_gb = Some(64); + let id1 = upsert_host(&pool, &host).await.expect("first upsert"); + + // Same natural key, different payload — should update in place. + host.cpu_cores = 24; + host.total_ram_gb = Some(96); + let id2 = upsert_host(&pool, &host).await.expect("second upsert"); + assert_eq!(id1, id2); + + let row = sqlx::query("SELECT cpu_cores, total_ram_gb FROM r2_probe_hosts WHERE id = $1") + .bind(id1) + .fetch_one(&pool) + .await + .expect("select after upsert"); + let cpu_cores: i32 = row.get("cpu_cores"); + let total_ram_gb: Option = row.get("total_ram_gb"); + assert_eq!(cpu_cores, 24); + assert_eq!(total_ram_gb, Some(96)); +} + +#[tokio::test] +async fn upsert_host_distinguishes_different_natural_keys() { + let (pool, _container) = setup_pool().await; + let host_a = sample_host("alpha"); + let mut host_b = sample_host("alpha"); + host_b.cpu_brand = "Intel Xeon Platinum 8488C".to_string(); + + let id_a = upsert_host(&pool, &host_a).await.expect("upsert a"); + let id_b = upsert_host(&pool, &host_b).await.expect("upsert b"); + assert_ne!(id_a, id_b); +} + +#[tokio::test] +async fn upsert_host_accepts_null_total_ram() { + // The probe falls back to None on platforms it can't introspect; + // the row must still land. + let (pool, _container) = setup_pool().await; + let mut host = sample_host("ramless"); + host.total_ram_gb = None; + let id = upsert_host(&pool, &host).await.expect("upsert"); + let ram: Option = + sqlx::query_scalar("SELECT total_ram_gb FROM r2_probe_hosts WHERE id = $1") + .bind(id) + .fetch_one(&pool) + .await + .expect("select ram"); + assert!(ram.is_none()); +} + +#[tokio::test] +async fn insert_run_writes_full_row() { + let (pool, _container) = setup_pool().await; + let host_id = upsert_host(&pool, &sample_host("ins")).await.expect("host"); + let run_id = insert_run(&pool, &sample_run(host_id)) + .await + .expect("insert run"); + assert!(run_id > 0); + + // Spot-check a handful of fields landed correctly. + let row = sqlx::query( + "SELECT git_sha, build_profile, allocator, succeeded, \ + circuit_build_wall_ms, prove_warm_p50_ms, tags \ + FROM r2_probe_runs WHERE id = $1", + ) + .bind(run_id) + .fetch_one(&pool) + .await + .expect("select run"); + + assert_eq!(row.get::("git_sha"), "deadbeefcafe"); + assert_eq!(row.get::("build_profile"), "release"); + assert_eq!(row.get::("allocator"), "mimalloc"); + assert!(row.get::("succeeded")); + assert_eq!(row.get::("circuit_build_wall_ms"), 9_500); + assert_eq!(row.get::, _>("prove_warm_p50_ms"), Some(900)); + let tags: Vec = row.get("tags"); + assert_eq!(tags, vec!["smoke".to_string(), "local".to_string()]); +} + +#[tokio::test] +async fn insert_run_handles_failure_row() { + let (pool, _container) = setup_pool().await; + let host_id = upsert_host(&pool, &sample_host("fail")) + .await + .expect("host"); + let mut run = sample_run(host_id); + run.succeeded = false; + run.error_message = Some("prove_initial: panicked".to_string()); + run.prove_warm_p50_ms = None; + run.prove_warm_p90_ms = None; + run.prove_warm_p99_ms = None; + let run_id = insert_run(&pool, &run).await.expect("insert err run"); + + let row = sqlx::query( + "SELECT succeeded, error_message, prove_warm_p50_ms \ + FROM r2_probe_runs WHERE id = $1", + ) + .bind(run_id) + .fetch_one(&pool) + .await + .expect("select err run"); + assert!(!row.get::("succeeded")); + assert_eq!( + row.get::, _>("error_message").as_deref(), + Some("prove_initial: panicked") + ); + assert!(row.get::, _>("prove_warm_p50_ms").is_none()); +} + +#[tokio::test] +async fn insert_warm_calls_empty_is_noop() { + let (pool, _container) = setup_pool().await; + let host_id = upsert_host(&pool, &sample_host("empty")) + .await + .expect("host"); + let run_id = insert_run(&pool, &sample_run(host_id)).await.expect("run"); + insert_warm_calls(&pool, run_id, &[]) + .await + .expect("empty insert"); + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM r2_probe_warm_calls WHERE probe_run_id = $1") + .bind(run_id) + .fetch_one(&pool) + .await + .expect("count"); + assert_eq!(count, 0); +} + +#[tokio::test] +async fn insert_warm_calls_writes_indexed_rows() { + let (pool, _container) = setup_pool().await; + let host_id = upsert_host(&pool, &sample_host("warm")) + .await + .expect("host"); + let run_id = insert_run(&pool, &sample_run(host_id)).await.expect("run"); + let calls = vec![900_i64, 950, 1000, 1100, 1300]; + insert_warm_calls(&pool, run_id, &calls) + .await + .expect("warm insert"); + + let rows = sqlx::query( + "SELECT call_index, wall_ms FROM r2_probe_warm_calls \ + WHERE probe_run_id = $1 ORDER BY call_index", + ) + .bind(run_id) + .fetch_all(&pool) + .await + .expect("warm select"); + assert_eq!(rows.len(), 5); + for (i, r) in rows.iter().enumerate() { + let idx: i32 = r.get("call_index"); + let wall: i64 = r.get("wall_ms"); + assert_eq!(idx, i as i32); + assert_eq!(wall, calls[i]); + } +} + +#[tokio::test] +async fn cascade_delete_drops_warm_calls() { + let (pool, _container) = setup_pool().await; + let host_id = upsert_host(&pool, &sample_host("cascade")) + .await + .expect("host"); + let run_id = insert_run(&pool, &sample_run(host_id)).await.expect("run"); + insert_warm_calls(&pool, run_id, &[100, 200, 300]) + .await + .expect("warm"); + + sqlx::query("DELETE FROM r2_probe_runs WHERE id = $1") + .bind(run_id) + .execute(&pool) + .await + .expect("delete run"); + + let remaining: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM r2_probe_warm_calls WHERE probe_run_id = $1") + .bind(run_id) + .fetch_one(&pool) + .await + .expect("count"); + assert_eq!(remaining, 0, "child rows must cascade"); +} + +#[tokio::test] +async fn fetch_recent_summary_returns_desc_with_budget_pass() { + let (pool, _container) = setup_pool().await; + let host_id = upsert_host(&pool, &sample_host("sum")).await.expect("host"); + + // Two runs that pass every budget. + let _id_a = insert_run(&pool, &sample_run(host_id)) + .await + .expect("run a"); + let _id_b = insert_run(&pool, &sample_run(host_id)) + .await + .expect("run b"); + + // One run that explicitly fails warm + cold + mem budgets. + let mut over = sample_run(host_id); + over.prove_cold_wall_ms = 60_000; + over.prove_warm_p50_ms = Some(7_500); + over.peak_rss_kb = 80 * 1024 * 1024; + let id_over = insert_run(&pool, &over).await.expect("run over"); + + let rows = fetch_recent_summary(&pool, 10).await.expect("summary"); + assert_eq!(rows.len(), 3); + + // Newest row first — id_over was the last insert. + assert_eq!(rows[0].id, id_over); + assert!(!rows[0].r2_warm_pass); + assert!(!rows[0].r2_cold_pass); + assert!(!rows[0].r2_mem_pass); + + // The earlier two passed every budget. + assert!(rows[1].r2_warm_pass); + assert!(rows[1].r2_cold_pass); + assert!(rows[1].r2_mem_pass); + assert!(rows[2].r2_warm_pass); + assert!(rows[2].r2_cold_pass); + assert!(rows[2].r2_mem_pass); + + // Each row carries the joined host info. + assert_eq!(rows[0].hostname, "test-host-sum"); + assert_eq!(rows[0].cpu_brand, "Apple M3 Ultra"); +} + +#[tokio::test] +async fn fetch_recent_summary_respects_limit() { + let (pool, _container) = setup_pool().await; + let host_id = upsert_host(&pool, &sample_host("lim")).await.expect("host"); + for _ in 0..4 { + insert_run(&pool, &sample_run(host_id)).await.expect("run"); + } + let rows = fetch_recent_summary(&pool, 2).await.expect("summary"); + assert_eq!(rows.len(), 2); +} + +#[tokio::test] +async fn fetch_recent_summary_cold_budget_covers_build_plus_prove() { + // Regression guard for the view's `r2_cold_pass` formula. The + // cold-start budget (`BUDGET_COLD_START_MS`, ROADMAP §Step 9) is + // defined against `circuit_build_wall_ms + prove_cold_wall_ms`, + // not against `prove_cold_wall_ms` alone. An earlier revision of + // the view compared only the prove leg, which let a row with a + // long build + over-budget total slip through with `r2_cold_pass + // = true`. Run B below picks exactly that edge case so a + // regression would flip its expected `false` back to `true`. + let (pool, _container) = setup_pool().await; + let host_id = upsert_host(&pool, &sample_host("coldsum")) + .await + .expect("host"); + + // Run A: build 5_000 + prove 20_000 = 25_000 <= 30_000 budget ⇒ PASS. + let mut run_a = sample_run(host_id); + run_a.circuit_build_wall_ms = 5_000; + run_a.prove_cold_wall_ms = 20_000; + run_a.r2_cold_budget_ms = 30_000; + let id_a = insert_run(&pool, &run_a).await.expect("run a"); + + // Run B: build 15_000 + prove 25_000 = 40_000 > 30_000 budget ⇒ FAIL. + // Note: prove_cold_wall_ms (25_000) alone is <= the budget. The + // buggy old formula (prove-leg only) would mis-flag this row as + // PASS; the correct formula (build + prove) flags it FAIL. + let mut run_b = sample_run(host_id); + run_b.circuit_build_wall_ms = 15_000; + run_b.prove_cold_wall_ms = 25_000; + run_b.r2_cold_budget_ms = 30_000; + let id_b = insert_run(&pool, &run_b).await.expect("run b"); + + let rows = fetch_recent_summary(&pool, 10).await.expect("summary"); + assert_eq!(rows.len(), 2); + + // Newest first — id_b was the last insert. + assert_eq!(rows[0].id, id_b); + assert_eq!(rows[1].id, id_a); + + assert!( + !rows[0].r2_cold_pass, + "build 15_000 + prove 25_000 = 40_000 > 30_000 budget must FAIL the cold-start check", + ); + assert!( + rows[1].r2_cold_pass, + "build 5_000 + prove 20_000 = 25_000 <= 30_000 budget must PASS the cold-start check", + ); +} + +#[tokio::test] +async fn fetch_recent_summary_null_warm_marks_warm_fail() { + // A run with no warm samples must NOT silently pass the warm + // budget — the view checks `IS NOT NULL` first. + let (pool, _container) = setup_pool().await; + let host_id = upsert_host(&pool, &sample_host("nullwarm")) + .await + .expect("host"); + let mut run = sample_run(host_id); + run.prove_warm_p50_ms = None; + run.prove_warm_p90_ms = None; + run.prove_warm_p99_ms = None; + insert_run(&pool, &run).await.expect("run"); + let rows = fetch_recent_summary(&pool, 1).await.expect("summary"); + assert_eq!(rows.len(), 1); + assert!(!rows[0].r2_warm_pass); + assert!(rows[0].prove_warm_p50_ms.is_none()); +} diff --git a/node/src/router.rs b/node/src/router.rs index b9179534..ee1572c7 100644 --- a/node/src/router.rs +++ b/node/src/router.rs @@ -504,23 +504,26 @@ pub struct InfoResponse { /// Node-side feature gates exposed to clients so the app can render /// capability-driven UI without a parallel build-time env-flag set. -/// Each bool reflects a compile-time Cargo feature on the node binary, -/// except `faucet`: mint is part of the MVP and is always available, so -/// the field is hardcoded `true`. It is kept on the struct for API -/// back-compat with wallet clients that introspect `/api/info`. +/// Each bool reflects a compile-time Cargo feature on the node binary. +/// +/// Only opt-in features appear here. Permanent MVP endpoints (mint, +/// username resolve) are always available and intentionally have no +/// capability bit — clients must not gate their UI on flags that +/// would always be `true`. #[derive(Serialize, Deserialize)] pub struct Capabilities { pub address_list: bool, - /// Always `true`. Mint is permanently part of the MVP binary; the - /// field is retained only so existing wallet clients deserialising - /// `/api/info` don't break. - pub faucet: bool, - pub usernames: bool, + /// Username *claim* (write path). Gated by the `username-claim` + /// Cargo feature; off in hosted DEV + PRD images. Wallet clients + /// hide the claim input when this is `false`. Always present in + /// the response so the app does not have to sniff build flags. + pub username_claim: bool, pub lnurl: bool, } // --- Username & LNURL types --- +#[cfg(feature = "username-claim")] #[derive(Deserialize)] pub struct ClaimUsernameRequest { username: String, @@ -1573,6 +1576,76 @@ async fn get_inscription_handler( } } +// ---- Admin: R2 probe history -------------------------------------------- +// +// The `probe_r2` binary persists its results into `r2_probe_runs` (see +// `r2_probe.rs` + migration 0013). This endpoint surfaces the most +// recent `limit` rows of the convenience view so the operator can ask +// "did the last few probe runs hit budget?" against a deployed node +// without shelling into the database. +// +// Closed test env (`feedback_zkcoins_closed_test_env`): the endpoint +// is unauthenticated like every other route. The path lives under an +// `/api/admin/` prefix so it is visibly separate from the user-facing +// surface and never accidentally documented as a public contract. +// Read-only — the handler never writes. + +/// `?limit=` query for `GET /api/admin/r2-probe/history`. Capped at +/// 200 to bound the response size and the underlying DB scan. +#[derive(Deserialize)] +pub(crate) struct R2ProbeHistoryQuery { + pub limit: Option, +} + +/// Default page size when `?limit` is omitted. +pub(crate) const R2_PROBE_HISTORY_DEFAULT_LIMIT: i64 = 50; +/// Hard cap on the `?limit` parameter — clamps oversized requests +/// down to a sane scan budget. +pub(crate) const R2_PROBE_HISTORY_MAX_LIMIT: i64 = 200; + +/// Normalise a caller-supplied `?limit` into the +/// `[1, R2_PROBE_HISTORY_MAX_LIMIT]` window. Negative / zero / +/// missing inputs collapse to the default; anything above the cap +/// is clamped down. Extracted so the clamp logic is unit-testable +/// without spinning up a Postgres container. +pub(crate) fn clamp_r2_probe_history_limit(raw: Option) -> i64 { + match raw { + Some(n) if n <= 0 => R2_PROBE_HISTORY_DEFAULT_LIMIT, + Some(n) if n > R2_PROBE_HISTORY_MAX_LIMIT => R2_PROBE_HISTORY_MAX_LIMIT, + Some(n) => n, + None => R2_PROBE_HISTORY_DEFAULT_LIMIT, + } +} + +/// `GET /api/admin/r2-probe/history?limit=` — operator-facing +/// trend view over the `r2_probe_runs_summary` view. Returns the +/// `limit` most recent runs newest first as a JSON array. Read-only: +/// no write path exists for this resource through HTTP. +/// +/// The endpoint is intentionally unauthenticated — the node sits in +/// a closed test environment where the entire request surface is +/// fair game for the operator. Per +/// `feedback_zkcoins_no_privacy_promise` the server makes no +/// privacy claim; the probe rows are operational telemetry and any +/// future hardening goes alongside the wider auth story. +async fn r2_probe_history_handler( + State(state): State, + axum::extract::Query(query): axum::extract::Query, +) -> axum::response::Response { + let limit = clamp_r2_probe_history_limit(query.limit); + match crate::r2_probe::fetch_recent_summary(&state.pool, limit).await { + Ok(rows) => (StatusCode::OK, Json(rows)).into_response(), + Err(e) => { + tracing::warn!("r2_probe_history_handler: db error: {}", e); + handler_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Database error while reading R2 probe history", + ) + .into_response() + } + } +} + /// JSON body returned by `GET /health/ready`. `failures` is empty on a /// fully ready node; each failing dependency contributes one stable /// short tag (`"db"`, `"esplora"`) so a Kuma monitor parses the cause @@ -1698,10 +1771,7 @@ async fn info_handler() -> impl IntoResponse { network: NETWORK_CONFIG.network_name.clone(), capabilities: Capabilities { address_list: cfg!(feature = "address-list"), - // Hardcoded — mint is permanent MVP; field is back-compat only. - faucet: true, - // Hardcoded — usernames are permanent MVP; field is back-compat only. - usernames: true, + username_claim: cfg!(feature = "username-claim"), lnurl: cfg!(feature = "lnurl"), }, username_domain: USERNAME_DOMAIN.clone(), @@ -1755,6 +1825,7 @@ async fn root_handler() -> impl IntoResponse { // --- Username & LNURL handlers --- +#[cfg(feature = "username-claim")] async fn claim_username_handler( State(state): State, Json(request): Json, @@ -2118,11 +2189,14 @@ pub(crate) fn create_router(state: AppState) -> Router { .route("/api/commit", post(commit_handler)) .route("/api/mint", post(mint_handler)) .route("/api/inscriptions/:txid", get(get_inscription_handler)) - .route("/api/username/claim", post(claim_username_handler)) .route( "/api/username/resolve/:username", get(resolve_username_handler), - ); + ) + // Operator-facing R2 probe trend (see `r2_probe_history_handler` + // doc-comment). Grouped under `/api/admin/` so it is visibly + // separate from the user-facing surface. + .route("/api/admin/r2-probe/history", get(r2_probe_history_handler)); // Gated routes — only compiled in when their Cargo feature is enabled. // With a feature off, the handler does not exist in the binary and the @@ -2131,6 +2205,9 @@ pub(crate) fn create_router(state: AppState) -> Router { #[cfg(feature = "address-list")] let app = app.route("/api/address", get(get_address_handler)); + #[cfg(feature = "username-claim")] + let app = app.route("/api/username/claim", post(claim_username_handler)); + #[cfg(feature = "lnurl")] let app = app .route("/.well-known/lnurlp/:username", get(lnurlp_handler)) diff --git a/node/src/router_tests.rs b/node/src/router_tests.rs index 8db2c776..12a3a880 100644 --- a/node/src/router_tests.rs +++ b/node/src/router_tests.rs @@ -63,7 +63,6 @@ fn test_state() -> AppState { is_mainnet: false, network_name: "Mutinynet".to_string(), ws_url: None, - track_tx_timeout: None, }), phase2_reached: Arc::new(tokio::sync::Notify::new()), phase3_release_lock: Arc::new(tokio::sync::Mutex::new(())), @@ -141,10 +140,10 @@ async fn info_returns_network_name_capabilities_and_username_domain() { info.capabilities.address_list, cfg!(feature = "address-list") ); - // Mint is permanent MVP — `faucet` is hardcoded `true`, not cfg-derived. - assert!(info.capabilities.faucet); - // Usernames are permanent MVP — `usernames` is hardcoded `true`. - assert!(info.capabilities.usernames); + assert_eq!( + info.capabilities.username_claim, + cfg!(feature = "username-claim") + ); assert_eq!(info.capabilities.lnurl, cfg!(feature = "lnurl")); // The lazy_static defaults to "zkcoins.app" (PRD) when USERNAME_DOMAIN is unset @@ -166,7 +165,7 @@ async fn info_serialization_format_is_stable() { assert!(v["username_domain"].is_string()); let caps = &v["capabilities"]; - for key in ["address_list", "faucet", "usernames", "lnurl"] { + for key in ["address_list", "username_claim", "lnurl"] { assert!(caps[key].is_boolean(), "capability `{key}` must be bool"); } } @@ -425,6 +424,7 @@ async fn resolve_minting_address_by_hex_prefix() { // --- POST /api/username/claim --- +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_empty_body_returns_422() { let req = Request::post("/api/username/claim") @@ -436,6 +436,7 @@ async fn claim_username_empty_body_returns_422() { assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); } +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_no_content_type_returns_415() { let req = Request::post("/api/username/claim") @@ -886,6 +887,7 @@ fn send_signature_rejects_wrong_signature() { // --- POST /api/username/claim with valid Schnorr signature --- +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_with_valid_signature() { use bitcoin::secp256k1::{Keypair, SecretKey}; @@ -985,6 +987,7 @@ async fn claim_username_with_valid_signature() { /// form (`"alice"`) and sends the user-typed form (`"Alice"`) is /// accepted and persisted under `"alice"`. Guards the case-mismatch /// squat fix from PR #76's prod-readiness review. +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_mixed_case_input_normalised_before_hashing() { use bitcoin::secp256k1::{Keypair, SecretKey}; @@ -1076,6 +1079,7 @@ async fn claim_username_mixed_case_input_normalised_before_hashing() { /// node, because the node hashes the normalised form. Without /// this, the case-mismatch squat is reachable: attacker signs `"Bob"`, /// node persists `"bob"`, the legitimate `bob` owner is locked out. +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_raw_case_signature_rejected() { use bitcoin::secp256k1::{Keypair, SecretKey}; @@ -1138,6 +1142,7 @@ async fn claim_username_raw_case_signature_rejected() { /// the in-memory mirror is pre-seeded via `insert_for_test`, the /// signature is valid, and the handler short-circuits before the /// `db::claim_username` call. +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_precheck_conflict_returns_409() { use bitcoin::secp256k1::{Keypair, SecretKey}; @@ -1206,6 +1211,7 @@ async fn claim_username_precheck_conflict_returns_409() { ); } +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_wrong_pubkey() { use bitcoin::secp256k1::{Keypair, SecretKey}; @@ -1257,6 +1263,7 @@ async fn claim_username_wrong_pubkey() { ); } +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_expired_timestamp() { use bitcoin::secp256k1::{Keypair, SecretKey}; @@ -1311,6 +1318,7 @@ async fn claim_username_expired_timestamp() { /// `UsernameStore::validate` rejects names outside `[a-z0-9._-]{1,64}`. /// Drives the handler's first early-return arm (the `validate` `Err` /// branch), so no DB round-trip and no signature work is needed. +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_invalid_format_returns_422() { let body = serde_json::json!({ @@ -1342,6 +1350,7 @@ async fn claim_username_invalid_format_returns_422() { } /// Non-hex address payload triggers the `hex::decode` early-return arm. +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_invalid_address_hex_returns_422() { let body = serde_json::json!({ @@ -1373,6 +1382,7 @@ async fn claim_username_invalid_address_hex_returns_422() { } /// Valid hex address but not 32 bytes triggers the length-check arm. +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_wrong_address_length_returns_422() { let body = serde_json::json!({ @@ -1406,6 +1416,7 @@ async fn claim_username_wrong_address_length_returns_422() { /// Address matches `sha256(pubkey)` and the timestamp is fresh, so the /// handler reaches the signature-hex decode step before bailing on the /// non-hex `signature` field. +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_invalid_signature_hex_returns_422() { use bitcoin::secp256k1::SecretKey; @@ -1447,6 +1458,7 @@ async fn claim_username_invalid_signature_hex_returns_422() { /// Signature is valid hex but the wrong length for a BIP-340 Schnorr /// signature (64 bytes), so `SchnorrSignature::from_slice` rejects it. +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_invalid_signature_format_returns_422() { use bitcoin::secp256k1::SecretKey; @@ -1491,6 +1503,7 @@ async fn claim_username_invalid_signature_format_returns_422() { /// after the in-memory `precheck` passes. The handler must map that /// onto a 503. Mirrors `claim_propagates_db_error_when_pool_is_dead` /// from `username_tests.rs`, but exercises the handler's error arm. +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_db_error_returns_503() { use bitcoin::secp256k1::{Keypair, SecretKey}; @@ -1546,6 +1559,7 @@ async fn claim_username_db_error_returns_503() { /// string. Mirrors `claim_falls_back_to_validation_when_sql_layer_catches_race` /// from `username_tests.rs`, but exercises the handler's `!inserted` /// arm rather than the `UsernameStore::claim` wrapper. +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_sql_race_returns_409() { use bitcoin::secp256k1::{Keypair, SecretKey}; @@ -2308,7 +2322,6 @@ async fn send_with_insufficient_funds_returns_422_with_error_string() { is_mainnet: false, network_name: "Mutinynet".to_string(), ws_url: None, - track_tx_timeout: None, }), phase2_reached: Arc::new(tokio::sync::Notify::new()), phase3_release_lock: Arc::new(tokio::sync::Mutex::new(())), @@ -2541,7 +2554,6 @@ async fn commit_with_valid_signature_fails_broadcast_returns_503() { is_mainnet: false, network_name: "Mutinynet".to_string(), ws_url: None, - track_tx_timeout: None, }); let secret_bytes = include_bytes!("../minting_secret.bin"); @@ -3448,7 +3460,6 @@ fn ready_state(pool: Arc, esplora_url: String) -> AppState { is_mainnet: false, network_name: "Mutinynet".to_string(), ws_url: None, - track_tx_timeout: None, }); state } @@ -3583,7 +3594,6 @@ async fn health_publisher_returns_200_with_utxo_count_and_total_sats_when_esplor is_mainnet: false, network_name: "Mutinynet".to_string(), ws_url: None, - track_tx_timeout: None, }); let req = Request::get("/health/publisher") @@ -3698,7 +3708,6 @@ fn mint_test_state() -> AppState { is_mainnet: false, network_name: "Mutinynet".to_string(), ws_url: None, - track_tx_timeout: None, }), phase2_reached: Arc::new(tokio::sync::Notify::new()), phase3_release_lock: Arc::new(tokio::sync::Mutex::new(())), @@ -3997,15 +4006,13 @@ async fn mint_happy_path_broadcasts_and_returns_proof_id() { .await; // 3. Wire the AppState to the live pool + wiremock URL. - let ws_url = mint_broadcast_mock_ws().await; let mut state = mint_test_state(); state.pool = Arc::clone(&pool); state.esplora_config = Arc::new(crate::publisher::EsploraConfig { url: mock_server.uri(), is_mainnet: false, network_name: "Mutinynet".to_string(), - ws_url: Some(ws_url), - track_tx_timeout: None, + ws_url: None, }); let recipient_bytes = [9u8; 32]; @@ -4129,61 +4136,6 @@ async fn mint_with_nonzero_num_pubkeys_covers_prev_pubkey_arm() { /// Spin up the wiremock Esplora + matching publisher Taproot UTXO mock /// used by the mint happy-path test. Returned `MockServer` is kept /// alive by the caller; dropping it tears down the HTTP listener. -/// Spin up an in-process WS server that emulates the mempool.space -/// `track-tx` flow used by `publisher::broadcast_inscription_txs` -/// (issue #84): accept the subscribe frame and echo a documented -/// `txPosition` event for the txid the client subscribed to, so -/// the publisher's `wait_for_tx_in_mempool` resolves immediately. -/// Returns the `ws://` URL. -async fn mint_broadcast_mock_ws() -> String { - use futures_util::{SinkExt, StreamExt}; - use tokio::net::TcpListener; - use tokio_tungstenite::tungstenite::Message as WsMessage; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let url = format!("ws://{}", addr); - tokio::spawn(async move { - loop { - let (stream, _) = match listener.accept().await { - Ok(s) => s, - Err(_) => return, - }; - // Spawn per-connection so the accept loop continues - // immediately and tests issuing multiple sequential mints - // (each with its own WS connect) are not serialised behind - // the previous connection's 60s keepalive sleep. - tokio::spawn(async move { - let mut ws = match tokio_tungstenite::accept_async(stream).await { - Ok(w) => w, - Err(_) => return, - }; - let first = match ws.next().await { - Some(Ok(WsMessage::Text(t))) => t, - _ => return, - }; - let value: serde_json::Value = match serde_json::from_str(&first) { - Ok(v) => v, - Err(_) => return, - }; - if value.get("action") == Some(&serde_json::json!("track-tx")) { - if let Some(txid_str) = value.get("data").and_then(|v| v.as_str()) { - // Documented mempool.space `txPosition` shape; - // see `scanner_ws::frame_signals_tx_seen`. - let frame = format!( - r#"{{"txPosition":{{"txid":"{}","position":{{"block":1,"vsize":120}}}}}}"#, - txid_str - ); - let _ = ws.send(WsMessage::Text(frame)).await; - } - } - let _ = tokio::time::sleep(std::time::Duration::from_secs(60)).await; - }); - } - }); - url -} - async fn mint_broadcast_mock_server() -> wiremock::MockServer { use bitcoin::Network; use bitcoin::{ @@ -4248,8 +4200,6 @@ async fn mint_broadcast_mock_server() -> wiremock::MockServer { #[tokio::test] async fn mint_pending_inscriptions_persist_failure_returns_503() { let mock_server = mint_broadcast_mock_server().await; - let ws_url = mint_broadcast_mock_ws().await; - let mut state = mint_test_state(); // dead_pool stays in place from mint_test_state; only swap the // Esplora URL so the broadcast succeeds. @@ -4257,8 +4207,7 @@ async fn mint_pending_inscriptions_persist_failure_returns_503() { url: mock_server.uri(), is_mainnet: false, network_name: "Mutinynet".to_string(), - ws_url: Some(ws_url), - track_tx_timeout: None, + ws_url: None, }); let recipient = "0x".to_string() + &hex::encode([4u8; 32]); @@ -4336,16 +4285,13 @@ async fn mint_commit_mint_tx_failure_returns_503() { .unwrap(); let mock_server = mint_broadcast_mock_server().await; - let ws_url = mint_broadcast_mock_ws().await; - let mut state = mint_test_state(); state.pool = Arc::clone(&pool); state.esplora_config = Arc::new(crate::publisher::EsploraConfig { url: mock_server.uri(), is_mainnet: false, network_name: "Mutinynet".to_string(), - ws_url: Some(ws_url), - track_tx_timeout: None, + ws_url: None, }); let recipient_bytes = [12u8; 32]; @@ -4404,7 +4350,6 @@ async fn mint_receive_coin_failure_logs_and_returns_ok() { ); let mock_server = mint_broadcast_mock_server().await; - let ws_url = mint_broadcast_mock_ws().await; let recipient_bytes = [6u8; 32]; let recipient = zkcoins_program::hash::digest_from_bytes(&recipient_bytes); @@ -4415,8 +4360,7 @@ async fn mint_receive_coin_failure_logs_and_returns_ok() { url: mock_server.uri(), is_mainnet: false, network_name: "Mutinynet".to_string(), - ws_url: Some(ws_url), - track_tx_timeout: None, + ws_url: None, }); // Predict the coin identifier that `prepare_mint` will assign to @@ -4568,13 +4512,11 @@ async fn mint_retry_after_broadcast_failure_succeeds() { // ---- Second mint: working Esplora → 200 ----------------------------- let mock_server = mint_broadcast_mock_server().await; - let ws_url = mint_broadcast_mock_ws().await; state.esplora_config = Arc::new(crate::publisher::EsploraConfig { url: mock_server.uri(), is_mainnet: false, network_name: "Mutinynet".to_string(), - ws_url: Some(ws_url), - track_tx_timeout: None, + ws_url: None, }); let cloned_state_second = state.clone(); let body2 = serde_json::json!({ @@ -4813,16 +4755,13 @@ async fn mint_handler_advances_state_synchronously_with_broadcast() { ); let mock_server = mint_broadcast_mock_server().await; - let ws_url = mint_broadcast_mock_ws().await; - let mut state = mint_test_state(); state.pool = Arc::clone(&pool); state.esplora_config = Arc::new(crate::publisher::EsploraConfig { url: mock_server.uri(), is_mainnet: false, network_name: "Mutinynet".to_string(), - ws_url: Some(ws_url), - track_tx_timeout: None, + ws_url: None, }); // Sanity: the SMT starts empty so derive_num_pubkeys_from_smt @@ -4990,16 +4929,13 @@ async fn mint_handler_atomic_tx_rollback_leaves_state_and_row_consistent() { .unwrap(); let mock_server = mint_broadcast_mock_server().await; - let ws_url = mint_broadcast_mock_ws().await; - let mut state = mint_test_state(); state.pool = Arc::clone(&pool); state.esplora_config = Arc::new(crate::publisher::EsploraConfig { url: mock_server.uri(), is_mainnet: false, network_name: "Mutinynet".to_string(), - ws_url: Some(ws_url), - track_tx_timeout: None, + ws_url: None, }); let recipient_bytes = [11u8; 32]; @@ -5138,16 +5074,13 @@ async fn mint_handler_in_process_state_advance_collision_returns_503() { ); let mock_server = mint_broadcast_mock_server().await; - let ws_url = mint_broadcast_mock_ws().await; - let mut state = mint_test_state(); state.pool = Arc::clone(&pool); state.esplora_config = Arc::new(crate::publisher::EsploraConfig { url: mock_server.uri(), is_mainnet: false, network_name: "Mutinynet".to_string(), - ws_url: Some(ws_url), - track_tx_timeout: None, + ws_url: None, }); // Hold the state-advance release lock so the handler will block @@ -5342,16 +5275,13 @@ async fn mint_handler_two_sequential_mints_with_different_recipients_advance_cle ); let mock_server = mint_broadcast_mock_server().await; - let ws_url = mint_broadcast_mock_ws().await; - let mut state = mint_test_state(); state.pool = Arc::clone(&pool); state.esplora_config = Arc::new(crate::publisher::EsploraConfig { url: mock_server.uri(), is_mainnet: false, network_name: "Mutinynet".to_string(), - ws_url: Some(ws_url), - track_tx_timeout: None, + ws_url: None, }); // First mint: recipient A. @@ -5576,6 +5506,7 @@ mod inscriptions_endpoint_tests { // and asserts the row landed. // ======================================================================= +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_precheck_reject_persists_log_row() { use crate::db::connect_and_migrate; @@ -5661,6 +5592,7 @@ async fn claim_username_precheck_reject_persists_log_row() { /// arm at router.rs line 1767. The fire-and-forget spawn calls /// `insert_username_claim_log` — we DROP the table out from under it /// so the insert fails and the eprintln line runs. +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_username_log_spawn_handles_insert_error() { use crate::db::connect_and_migrate; @@ -5731,3 +5663,204 @@ async fn claim_username_log_spawn_handles_insert_error() { // Give the fire-and-forget spawn time to hit the eprintln path. tokio::time::sleep(std::time::Duration::from_millis(150)).await; } + +// --- GET /api/admin/r2-probe/history --- +// +// The handler reads from the `r2_probe_runs_summary` view. The happy- +// path tests below boot a real Postgres 17 testcontainer because the +// view + tables only exist after migration; the dead_pool path stays +// in `r2_probe_history_db_error_returns_500`. + +#[tokio::test] +async fn clamp_r2_probe_history_limit_handles_default_and_clamps() { + assert_eq!( + clamp_r2_probe_history_limit(None), + R2_PROBE_HISTORY_DEFAULT_LIMIT + ); + assert_eq!( + clamp_r2_probe_history_limit(Some(0)), + R2_PROBE_HISTORY_DEFAULT_LIMIT + ); + assert_eq!( + clamp_r2_probe_history_limit(Some(-5)), + R2_PROBE_HISTORY_DEFAULT_LIMIT + ); + assert_eq!(clamp_r2_probe_history_limit(Some(7)), 7); + assert_eq!( + clamp_r2_probe_history_limit(Some(10_000)), + R2_PROBE_HISTORY_MAX_LIMIT + ); + assert_eq!( + clamp_r2_probe_history_limit(Some(R2_PROBE_HISTORY_MAX_LIMIT)), + R2_PROBE_HISTORY_MAX_LIMIT + ); +} + +#[tokio::test] +async fn r2_probe_history_db_error_returns_500() { + // The default test_state() uses a dead PgPool whose connect + // attempts time out fast — exercises the handler's error arm. + let req = Request::get("/api/admin/r2-probe/history") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request(req).await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + let resp: SendCoinResponse = serde_json::from_str(&body).expect("valid JSON"); + assert!(!resp.success); + assert_eq!( + resp.error.as_deref(), + Some("Database error while reading R2 probe history") + ); +} + +#[tokio::test] +async fn r2_probe_history_empty_returns_empty_array() { + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container.get_host().await.expect("host"); + let port = pg_container.get_host_port_ipv4(5432).await.expect("port"); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + + let state = live_test_state(pool); + let req = Request::get("/api/admin/r2-probe/history") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request_with_state(state, req).await; + assert_eq!(status, StatusCode::OK); + let arr: Vec = serde_json::from_str(&body).expect("valid JSON"); + assert!(arr.is_empty()); +} + +#[tokio::test] +async fn r2_probe_history_returns_rows_with_pass_flags() { + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container.get_host().await.expect("host"); + let port = pg_container.get_host_port_ipv4(5432).await.expect("port"); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + + // Seed two runs: one within budget, one over warm budget. + let host_info = crate::r2_probe::HostInfo { + hostname: "router-test-host".to_string(), + os: "macos".to_string(), + arch: "aarch64".to_string(), + cpu_brand: "Apple M3 Ultra".to_string(), + cpu_cores: 24, + total_ram_gb: Some(96), + }; + let host_id = crate::r2_probe::upsert_host(&pool, &host_info) + .await + .expect("host"); + let mut run = crate::r2_probe::ProbeRun { + host_id, + git_sha: "abc123".to_string(), + binary_version: "0.1.0".to_string(), + rustc_version: "rustc 1.81.0".to_string(), + build_profile: "release".to_string(), + allocator: "mimalloc".to_string(), + max_in_coins: 8, + max_out_coins: 8, + inner_pad_bits: 15, + warm_calls_requested: 3, + circuit_build_wall_ms: 8_000, + prove_cold_wall_ms: 18_000, + verify_wall_ms: 30, + peak_rss_kb: 40 * 1024 * 1024, + prove_warm_p50_ms: Some(800), + prove_warm_p90_ms: Some(1_000), + prove_warm_p99_ms: Some(1_300), + succeeded: true, + error_message: None, + notes: None, + tags: vec!["router-test".to_string()], + r2_warm_budget_ms: 5_000, + r2_cold_budget_ms: 30_000, + r2_mem_budget_kb: 64 * 1024 * 1024, + }; + crate::r2_probe::insert_run(&pool, &run) + .await + .expect("run 1"); + + // Second run blows past the warm budget. + run.prove_warm_p50_ms = Some(7_000); + crate::r2_probe::insert_run(&pool, &run) + .await + .expect("run 2"); + + let state = live_test_state(pool); + let req = Request::get("/api/admin/r2-probe/history?limit=10") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request_with_state(state, req).await; + assert_eq!(status, StatusCode::OK); + + let arr: Vec = serde_json::from_str(&body).expect("valid JSON"); + assert_eq!(arr.len(), 2); + + // Newest first — the warm-fail row landed last. + assert_eq!(arr[0]["r2_warm_pass"].as_bool(), Some(false)); + assert_eq!(arr[1]["r2_warm_pass"].as_bool(), Some(true)); + // Cold + mem budgets pass for both. + assert_eq!(arr[0]["r2_cold_pass"].as_bool(), Some(true)); + assert_eq!(arr[1]["r2_cold_pass"].as_bool(), Some(true)); + assert_eq!(arr[0]["r2_mem_pass"].as_bool(), Some(true)); + assert_eq!(arr[1]["r2_mem_pass"].as_bool(), Some(true)); + // Joined host info surfaces in the response. + assert_eq!(arr[0]["hostname"].as_str(), Some("router-test-host")); + assert_eq!(arr[0]["cpu_brand"].as_str(), Some("Apple M3 Ultra")); +} + +#[tokio::test] +async fn r2_probe_history_limit_clamped_to_max() { + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let pg_container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("failed to start postgres container"); + let host = pg_container.get_host().await.expect("host"); + let port = pg_container.get_host_port_ipv4(5432).await.expect("port"); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new( + crate::db::connect_and_migrate(&url) + .await + .expect("connect_and_migrate failed"), + ); + + let state = live_test_state(pool); + // Caller asks for 10_000 — the clamp keeps us at 200. With zero + // rows seeded the response body is still empty, but the path + // reaches `fetch_recent_summary` (the clamp lives in the handler, + // not the SQL layer). + let req = Request::get("/api/admin/r2-probe/history?limit=10000") + .body(Body::empty()) + .unwrap(); + let (status, body) = send_request_with_state(state, req).await; + assert_eq!(status, StatusCode::OK); + let arr: Vec = serde_json::from_str(&body).expect("valid JSON"); + assert!(arr.is_empty()); +} diff --git a/node/src/scanner_ws.rs b/node/src/scanner_ws.rs index 01dc5cc7..c5d35b1a 100644 --- a/node/src/scanner_ws.rs +++ b/node/src/scanner_ws.rs @@ -69,7 +69,7 @@ use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; use tokio_tungstenite::tungstenite::Message as WsMessage; -pub use crate::scanner_ws_parse::{frame_signals_tx_seen, parse_ws_frame}; +pub use crate::scanner_ws_parse::parse_ws_frame; /// Default endpoint for Mutinynet's mempool.space-compatible WebSocket /// API. Overridable via `ESPLORA_WS_URL` for self-host operators and @@ -143,19 +143,10 @@ pub const DEFAULT_ESPLORA_HTTP_URL: &str = "https://mutinynet.com/api"; /// Issue #84 review (round 4) MAJOR 1. pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); -/// Initial backoff between failed `track-tx` reconnect attempts inside -/// `wait_for_tx_inner_resilient`. Doubles up to `TRACK_TX_RECONNECT_BACKOFF_MAX`. -/// Issue #84 review (round 4) MAJOR 2: prevents a tight handshake-spin -/// loop against an immediate-close peer; the outer 30 s `track-tx` -/// timeout still bounds total work. -const TRACK_TX_RECONNECT_BACKOFF_MIN: Duration = Duration::from_millis(50); - -/// Cap on the inner `track-tx` reconnect backoff. -const TRACK_TX_RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(1); - -/// Errors surfaced by the per-broadcast `subscribe_track_tx` + -/// `TrackTxStream::wait` two-phase helper used by -/// `publisher::broadcast_inscription_txs`. +/// Errors surfaced by the block-tip scanner's connect/subscribe/drain +/// cycle. The publisher's commit→reveal broadcast pair no longer uses +/// any of these — it talks REST-only and runs both broadcasts back to +/// back without an inter-tx wait (see `publisher::broadcast_inscription_txs`). #[derive(Debug)] pub enum WsError { /// `tokio_tungstenite::connect_async` returned an error. @@ -165,8 +156,6 @@ pub enum WsError { /// The peer closed the socket or surfaced an error mid-stream /// before the expected event arrived. Stream(String), - /// The safety-net deadline elapsed without the expected event. - Timeout, } impl std::fmt::Display for WsError { @@ -175,7 +164,6 @@ impl std::fmt::Display for WsError { WsError::Connect(e) => write!(f, "WS connect failed: {}", e), WsError::Subscribe(e) => write!(f, "WS subscribe failed: {}", e), WsError::Stream(e) => write!(f, "WS stream error: {}", e), - WsError::Timeout => write!(f, "WS timeout (no expected event in window)"), } } } @@ -574,196 +562,6 @@ async fn anchor_on_current_tip( Ok(()) } -/// Per-frame watchdog used by the inner `track-tx` wait loop. The -/// outer 30 s `TRACK_TX_TIMEOUT_SECS` budget is owned by the publisher; -/// this inner watchdog detects a half-open peer that swallows frames -/// without delivering any event, so we can reconnect-and-re-subscribe -/// within the outer envelope rather than sitting for the full 30 s on -/// a wedged socket. -const TRACK_TX_FRAME_WATCHDOG: Duration = Duration::from_secs(10); - -/// A live `track-tx` subscription against the Esplora WS. Returned by -/// [`subscribe_track_tx`]. Calling [`TrackTxStream::wait`] drains the -/// subscription until the peer reports the tracked txid as seen, or -/// until `timeout` elapses (whichever comes first). -/// -/// The split between `subscribe_track_tx` and `wait` is load-bearing -/// (issue #84): the publisher MUST establish the subscription BEFORE -/// broadcasting the commit transaction, otherwise the upstream may -/// propagate the tx between the broadcast and the subscribe and the -/// "tx in mempool" event would fire before we are listening. With the -/// split, the subscribe handshake is complete before the broadcast -/// races against it. -pub struct TrackTxStream { - ws: tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - url: String, - txid: bitcoin::Txid, - txid_str: String, -} - -impl std::fmt::Debug for TrackTxStream { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("TrackTxStream") - .field("url", &self.url) - .field("txid", &self.txid) - .finish_non_exhaustive() - } -} - -impl TrackTxStream { - /// Drain the subscription until the peer reports the tracked - /// txid, or the outer `timeout` elapses. The implementation also - /// runs a per-frame watchdog ([`TRACK_TX_FRAME_WATCHDOG`]) so a - /// silent half-open peer triggers a forced reconnect within the - /// outer budget rather than wedging the full window. - /// - /// On reconnect we re-open the WS and re-send the `track-tx` - /// subscribe frame, then continue waiting against the remaining - /// outer budget. This keeps the publisher's contract simple: a - /// missing event surfaces as `WsError::Timeout` exactly when the - /// caller's deadline elapses, regardless of how many half-open - /// reconnects happened in between. - pub async fn wait(self, timeout: Duration) -> Result<(), WsError> { - tokio::time::timeout(timeout, wait_for_tx_inner_resilient(self)) - .await - .map_err(|_| WsError::Timeout)? - } -} - -/// Open a short-lived WS to `url`, subscribe to `track-tx` for -/// `txid`, and return the live stream WITHOUT yet waiting for an -/// event. The caller is expected to drive the actual wait via -/// [`TrackTxStream::wait`] after performing whatever side-effect the -/// subscription is gating (in our case: broadcasting the commit -/// transaction on the Esplora REST endpoint). -/// -/// Splitting the two-phase API away from the old all-in-one -/// `wait_for_tx_in_mempool` plugs the issue #84 race: with the -/// single-call helper, the publisher used to broadcast the commit -/// BEFORE the subscribe completed, so the "tx in mempool" event -/// could fire before any listener was attached. -pub async fn subscribe_track_tx(url: &str, txid: bitcoin::Txid) -> Result { - let mut ws = connect_with_timeout(url).await?; - - let txid_str = txid.to_string(); - let subscribe = serde_json::json!({ - "action": "track-tx", - "data": txid_str, - }) - .to_string(); - ws.send(WsMessage::Text(subscribe)) - .await - .map_err(|e| WsError::Subscribe(e.to_string()))?; - - Ok(TrackTxStream { - ws, - url: url.to_string(), - txid, - txid_str, - }) -} - -/// Inner loop with per-frame watchdog + transparent reconnect. On a -/// per-frame timeout (`TRACK_TX_FRAME_WATCHDOG`), tear the current WS -/// down and re-subscribe; continue draining until the outer caller's -/// deadline elapses (which it does via `tokio::time::timeout` wrapping -/// this future in `TrackTxStream::wait`). -/// -/// Issue #84 review (round 4) MAJOR 2: a peer that accepts and -/// immediately closes (or drops every frame) used to make this loop -/// tight-spin a fresh TCP+TLS handshake per iteration. We now apply -/// an exponential backoff between failed reconnects (50 ms → 1 s) -/// and reset it to 50 ms on the next successful connect+subscribe so -/// a single transient drop does not penalise subsequent good runs. -/// The outer 30 s `tokio::time::timeout` continues to bound total -/// work, so the backoff can never starve the publisher. -async fn wait_for_tx_inner_resilient(stream: TrackTxStream) -> Result<(), WsError> { - let TrackTxStream { - mut ws, - url, - txid, - txid_str, - } = stream; - - // Per-reconnect backoff. Doubles per consecutive failure, capped - // at `TRACK_TX_RECONNECT_BACKOFF_MAX`. Reset to MIN whenever the - // current session yields any frame from the peer ("good run"). - let mut reconnect_backoff = TRACK_TX_RECONNECT_BACKOFF_MIN; - - loop { - let next = tokio::time::timeout(TRACK_TX_FRAME_WATCHDOG, ws.next()).await; - match next { - Ok(Some(Ok(WsMessage::Text(text)))) => { - if frame_signals_tx_seen(&text, &txid_str) { - return Ok(()); - } - // Non-matching text frame (heartbeat, position update - // for some other tx, mempool stats). Keep draining. - // The peer is delivering frames → this is a "good - // run", so reset the reconnect backoff. - reconnect_backoff = TRACK_TX_RECONNECT_BACKOFF_MIN; - } - Ok(Some(Ok(WsMessage::Close(_)))) | Ok(None) => { - // Peer closed the socket before delivering the event. - // Reconnect and re-subscribe; the outer timeout caps - // how long we keep trying. - eprintln!( - "scanner_ws: track-tx peer closed before event for {}; reconnecting after {:?}", - txid, reconnect_backoff - ); - tokio::time::sleep(reconnect_backoff).await; // scanner-polling-ok: reconnect-backoff between failed track-tx sessions (issue #84 round-4 MAJOR 2) - ws = reconnect_track_tx(&url, &txid_str).await?; - reconnect_backoff = (reconnect_backoff * 2).min(TRACK_TX_RECONNECT_BACKOFF_MAX); - } - Ok(Some(Ok(_))) => { - // Binary / ping / pong / raw frame — tungstenite - // handles ping/pong internally and the others are not - // emitted by Esplora for this subscription. Ignore, - // but treat as evidence of a live peer. - reconnect_backoff = TRACK_TX_RECONNECT_BACKOFF_MIN; - } - Ok(Some(Err(e))) => { - return Err(WsError::Stream(e.to_string())); - } - Err(_) => { - // Per-frame watchdog elapsed. Treat as half-open and - // reconnect within the outer caller's budget. - eprintln!( - "scanner_ws: track-tx frame watchdog ({:?}) elapsed for {}; reconnecting after {:?}", - TRACK_TX_FRAME_WATCHDOG, txid, reconnect_backoff - ); - tokio::time::sleep(reconnect_backoff).await; // scanner-polling-ok: reconnect-backoff between failed track-tx sessions (issue #84 round-4 MAJOR 2) - ws = reconnect_track_tx(&url, &txid_str).await?; - reconnect_backoff = (reconnect_backoff * 2).min(TRACK_TX_RECONNECT_BACKOFF_MAX); - } - } - } -} - -/// Helper used by the inner wait loop: tear down the current ws (the -/// drop happens by reassignment in the caller) and open a fresh -/// connection with the same `track-tx` subscription frame. -async fn reconnect_track_tx( - url: &str, - txid_str: &str, -) -> Result< - tokio_tungstenite::WebSocketStream>, - WsError, -> { - let mut ws = connect_with_timeout(url).await?; - let subscribe = serde_json::json!({ - "action": "track-tx", - "data": txid_str, - }) - .to_string(); - ws.send(WsMessage::Text(subscribe)) - .await - .map_err(|e| WsError::Subscribe(e.to_string()))?; - Ok(ws) -} - #[cfg(test)] #[path = "scanner_ws_tests.rs"] mod tests; diff --git a/node/src/scanner_ws_parse.rs b/node/src/scanner_ws_parse.rs index 56597a5b..6399fe03 100644 --- a/node/src/scanner_ws_parse.rs +++ b/node/src/scanner_ws_parse.rs @@ -49,61 +49,6 @@ pub fn parse_ws_frame(text: &str) -> Vec { Vec::new() } -/// Return true when the frame reports the tracked txid in one of the -/// documented mempool.space `track-tx` response shapes: -/// -/// - `{"tx": {"txid": "", ...}}` (initial tx-detected event; -/// value is the full transaction object, which carries `txid`) -/// - `{"txPosition": {"txid": "", ...}}` (mempool position -/// update; value is `{txid, position, accelerationPositions}`) -/// - `{"txConfirmed": ""}` (tx confirmed in a new block; -/// value is the txid string directly) -/// -/// Critically, the subscribe-echo shape -/// `{"action":"track-tx","data":""}` MUST NOT match — upstreams -/// that echo the subscribe frame back would otherwise resolve the -/// wait immediately, before the tx had actually propagated. The unit -/// test `frame_signals_tx_seen_does_not_match_subscribe_echo` -/// enforces this. -pub fn frame_signals_tx_seen(text: &str, txid: &str) -> bool { - let value: serde_json::Value = match serde_json::from_str(text) { - Ok(v) => v, - Err(_) => return false, - }; - - // `{"txConfirmed": ""}` — direct string value. - if value - .get("txConfirmed") - .and_then(|v| v.as_str()) - .is_some_and(|s| s == txid) - { - return true; - } - - // `{"txPosition": {"txid": "", ...}}` - if value - .get("txPosition") - .and_then(|v| v.get("txid")) - .and_then(|v| v.as_str()) - .is_some_and(|s| s == txid) - { - return true; - } - - // `{"tx": {"txid": "", ...}}` — the full transaction object - // carries `txid` as a nested field. - if value - .get("tx") - .and_then(|v| v.get("txid")) - .and_then(|v| v.as_str()) - .is_some_and(|s| s == txid) - { - return true; - } - - false -} - #[cfg(test)] #[path = "scanner_ws_parse_tests.rs"] mod tests; diff --git a/node/src/scanner_ws_parse_tests.rs b/node/src/scanner_ws_parse_tests.rs index a837e6b9..01e8da44 100644 --- a/node/src/scanner_ws_parse_tests.rs +++ b/node/src/scanner_ws_parse_tests.rs @@ -67,69 +67,3 @@ fn parse_ws_frame_returns_empty_when_block_id_is_invalid_hex() { let frame = r#"{"block":{"id":"not-a-real-hash"}}"#; assert!(parse_ws_frame(frame).is_empty()); } - -#[test] -fn frame_signals_tx_seen_matches_documented_mempool_shapes() { - let txid_hex = "1111111111111111111111111111111111111111111111111111111111111111"; - - // `{"txConfirmed": ""}` — value is the txid string directly. - assert!(frame_signals_tx_seen( - &format!(r#"{{"txConfirmed":"{}"}}"#, txid_hex), - txid_hex - )); - // `{"txPosition": {"txid": "", "position": {...}}}` - assert!(frame_signals_tx_seen( - &format!( - r#"{{"txPosition":{{"txid":"{}","position":{{"block":1,"vsize":120}}}}}}"#, - txid_hex - ), - txid_hex - )); - // `{"tx": {"txid": "", ...}}` — full tx detection event. - assert!(frame_signals_tx_seen( - &format!( - r#"{{"tx":{{"txid":"{}","fee":100,"vsize":200}}}}"#, - txid_hex - ), - txid_hex - )); - - // Different txid — must not match. - let other = "2222222222222222222222222222222222222222222222222222222222222222"; - assert!(!frame_signals_tx_seen( - &format!(r#"{{"txConfirmed":"{}"}}"#, other), - txid_hex - )); - assert!(!frame_signals_tx_seen( - &format!(r#"{{"txPosition":{{"txid":"{}"}}}}"#, other), - txid_hex - )); - - // Malformed JSON - assert!(!frame_signals_tx_seen("garbage", txid_hex)); -} - -/// Regression for issue #84 review (round 2, MINOR 5): an upstream -/// that echoed the subscribe frame back to the client used to satisfy -/// the wildcard `json_contains_string` matcher, which would have -/// resolved the wait before the tx had actually propagated. The -/// matcher now restricts itself to the documented response shapes -/// (`txConfirmed`, `txPosition`, `tx`) and explicitly does NOT match -/// the subscribe-echo frame. -#[test] -fn frame_signals_tx_seen_does_not_match_subscribe_echo() { - let txid_hex = "1111111111111111111111111111111111111111111111111111111111111111"; - let echo = format!(r#"{{"action":"track-tx","data":"{}"}}"#, txid_hex); - assert!( - !frame_signals_tx_seen(&echo, txid_hex), - "subscribe-echo frame must NOT trigger the matcher" - ); - - // Also: an unrelated frame that just happens to mention the txid - // in a non-documented field must not match. - let unrelated = format!(r#"{{"someOtherKey":{{"txid":"{}"}}}}"#, txid_hex); - assert!( - !frame_signals_tx_seen(&unrelated, txid_hex), - "non-documented shape mentioning the txid must not match" - ); -} diff --git a/node/src/scanner_ws_tests.rs b/node/src/scanner_ws_tests.rs index d766a378..b19bbe97 100644 --- a/node/src/scanner_ws_tests.rs +++ b/node/src/scanner_ws_tests.rs @@ -1,18 +1,20 @@ //! Tests for `scanner_ws.rs`. //! -//! The connect-subscribe-drain loop and the `wait_for_tx_in_mempool` -//! helper are exercised against an in-process WebSocket server -//! constructed with `tokio_tungstenite::accept_async` — no real -//! network hop, no upstream dependency, no flakiness from public -//! Mutinynet outages. +//! The connect-subscribe-drain loop for block-tip events is +//! exercised against an in-process WebSocket server constructed with +//! `tokio_tungstenite::accept_async` — no real network hop, no +//! upstream dependency, no flakiness from public Mutinynet outages. //! -//! Pure parsers (`parse_ws_frame`, `frame_signals_tx_seen`) live in -//! `scanner_ws_parse.rs` and are unit-tested in -//! `scanner_ws_parse_tests.rs` so they stay inside the 100% coverage -//! gate (issue #84 round-4 MINOR 6). +//! The publisher's old per-broadcast `track-tx` WS subscription was +//! removed (see `publisher::broadcast_inscription_txs`); these tests +//! cover the remaining block-tip flow only. +//! +//! Pure parsers (`parse_ws_frame`) live in `scanner_ws_parse.rs` and +//! are unit-tested in `scanner_ws_parse_tests.rs` so they stay inside +//! the 100% coverage gate (issue #84 round-4 MINOR 6). use super::*; -use bitcoin::{BlockHash, Txid}; +use bitcoin::BlockHash; use futures_util::{SinkExt, StreamExt}; use std::str::FromStr; use std::time::Duration; @@ -286,96 +288,6 @@ async fn run_scanner_ws_force_reconnects_on_liveness_timeout() { handle.abort(); } -// ----------------------------------------------------------------------------- -// subscribe_track_tx / TrackTxStream::wait (two-phase API, issue #84 -// round-2 MAJOR 1: subscribe MUST precede the commit broadcast) -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn subscribe_track_tx_then_wait_returns_when_peer_emits_txid() { - let txid = - Txid::from_str("1111111111111111111111111111111111111111111111111111111111111111").unwrap(); - let txid_str = txid.to_string(); - - let url = { - let txid_for_handler = txid_str.clone(); - spawn_ws_server(move |mut ws| async move { - // Expect the `track-tx` subscribe frame. - let first = ws.next().await.unwrap().unwrap(); - let text = match first { - WsMessage::Text(t) => t, - other => panic!("expected text frame, got {:?}", other), - }; - let value: serde_json::Value = serde_json::from_str(&text).unwrap(); - assert_eq!(value.get("action"), Some(&serde_json::json!("track-tx"))); - assert_eq!( - value.get("data"), - Some(&serde_json::json!(txid_for_handler)) - ); - - // Send the documented mempool.space `txPosition` shape. - let frame = format!( - r#"{{"txPosition":{{"txid":"{}","position":{{"block":1,"vsize":120}}}}}}"#, - txid_for_handler - ); - ws.send(WsMessage::Text(frame)).await.unwrap(); - // Hold forever until the test aborts. - std::future::pending::<()>().await; - }) - .await - }; - - let stream = subscribe_track_tx(&url, txid) - .await - .expect("subscribe should succeed"); - stream - .wait(Duration::from_secs(5)) - .await - .expect("track-tx event should resolve the wait"); -} - -#[tokio::test] -async fn track_tx_wait_returns_timeout_when_event_never_arrives() { - let txid = - Txid::from_str("2222222222222222222222222222222222222222222222222222222222222222").unwrap(); - let url = spawn_ws_server(|mut ws| async move { - // Consume the subscribe frame but never echo the event. - let _ = ws.next().await; - // Hold forever until the test aborts. - std::future::pending::<()>().await; - }) - .await; - - let stream = subscribe_track_tx(&url, txid) - .await - .expect("subscribe should succeed"); - let err = stream - .wait(Duration::from_millis(300)) - .await - .expect_err("must surface Timeout when no event arrives"); - assert!( - matches!(err, WsError::Timeout), - "unexpected error: {:?}", - err - ); -} - -#[tokio::test] -async fn subscribe_track_tx_returns_connect_error_on_bad_url() { - let txid = - Txid::from_str("3333333333333333333333333333333333333333333333333333333333333333").unwrap(); - // 127.0.0.1:1 is reserved (tcpmux) and refused on macOS / Linux - // CI runners — produces an immediate connect error. - let err = subscribe_track_tx("ws://127.0.0.1:1", txid) - .await - .expect_err("connect to closed port must fail"); - assert!( - matches!(err, WsError::Connect(_)), - "expected Connect, got: {:?}", - err - ); -} - // ----------------------------------------------------------------------------- // Smoke — `from_env` // ----------------------------------------------------------------------------- diff --git a/node/src/username.rs b/node/src/username.rs index 06e0752f..3ed827e6 100644 --- a/node/src/username.rs +++ b/node/src/username.rs @@ -4,7 +4,9 @@ use sqlx::PgPool; use std::collections::HashMap; use crate::db; -use zkcoins_program::hash::{digest_from_bytes, digest_to_bytes}; +use zkcoins_program::hash::digest_from_bytes; +#[cfg(feature = "username-claim")] +use zkcoins_program::hash::digest_to_bytes; #[derive(Serialize, Deserialize, Debug, Default)] pub struct UsernameStore { @@ -40,6 +42,7 @@ impl UsernameStore { /// on the exact byte string, ruling out a case-mismatch squat. /// /// Returns the normalized (lowercased) name on success. + #[cfg(feature = "username-claim")] pub(crate) fn validate(username: &str) -> Result { let normalized = username.to_lowercase(); if normalized.is_empty() || normalized.len() > 64 { @@ -66,6 +69,7 @@ impl UsernameStore { /// `ClaimUsernameError` — keeps the handler's error mapping a flat /// `Result<(), &'static str>` with no unreachable `Db` arm; that /// would otherwise read as dead code under the 100 % coverage gate. + #[cfg(feature = "username-claim")] pub(crate) fn precheck(&self, normalized: &str, address: &Address) -> Result<(), &'static str> { if self.usernames.contains_key(normalized) { return Err("Username already taken"); @@ -80,6 +84,7 @@ impl UsernameStore { /// has reported `rows_affected == 1`. Held under the same short /// sync guard as `precheck` would be — no `.await` inside, no /// `mem::take`, the store is never observable as empty. + #[cfg(feature = "username-claim")] pub(crate) fn commit_after_db(&mut self, normalized: String, address: Address) { self.usernames.insert(normalized, address); } @@ -102,6 +107,7 @@ impl UsernameStore { /// `claim_username_handler` calls the steps directly because it /// must not hold a `std::sync::Mutex` guard across the async DB /// round-trip. + #[cfg(feature = "username-claim")] pub async fn claim( &mut self, pool: &PgPool, @@ -161,6 +167,7 @@ impl UsernameStore { /// Error type for `UsernameStore::claim`. Wraps the validation error /// strings (returned to the API caller as a 4xx body) and any database /// error from the underlying `db::claim_username` upsert. +#[cfg(feature = "username-claim")] #[derive(Debug)] pub enum ClaimUsernameError { /// Caller-fixable input rejection (charset, length, duplicate). @@ -170,6 +177,7 @@ pub enum ClaimUsernameError { Db(sqlx::Error), } +#[cfg(feature = "username-claim")] impl std::fmt::Display for ClaimUsernameError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -179,6 +187,7 @@ impl std::fmt::Display for ClaimUsernameError { } } +#[cfg(feature = "username-claim")] impl std::error::Error for ClaimUsernameError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { @@ -188,6 +197,7 @@ impl std::error::Error for ClaimUsernameError { } } +#[cfg(feature = "username-claim")] impl From for ClaimUsernameError { fn from(e: sqlx::Error) -> Self { ClaimUsernameError::Db(e) diff --git a/node/src/username_tests.rs b/node/src/username_tests.rs index 4060d7f4..7bed2e63 100644 --- a/node/src/username_tests.rs +++ b/node/src/username_tests.rs @@ -43,6 +43,7 @@ async fn setup_pool() -> (PgPool, ContainerAsync) { (pool, container) } +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_and_resolve_persists_via_pg() { let (pool, _container) = setup_pool().await; @@ -65,6 +66,7 @@ async fn claim_and_resolve_persists_via_pg() { assert_eq!(reloaded.get_username(&address), Some("alice")); } +#[cfg(feature = "username-claim")] #[tokio::test] async fn duplicate_username_rejected_with_validation() { let (pool, _container) = setup_pool().await; @@ -78,6 +80,7 @@ async fn duplicate_username_rejected_with_validation() { assert!(format!("{}", err).contains("Username already taken")); } +#[cfg(feature = "username-claim")] #[tokio::test] async fn duplicate_address_rejected_with_validation() { let (pool, _container) = setup_pool().await; @@ -92,6 +95,7 @@ async fn duplicate_address_rejected_with_validation() { assert!(format!("{}", err).contains("Address already has a username")); } +#[cfg(feature = "username-claim")] #[tokio::test] async fn invalid_username_rejected() { let (pool, _container) = setup_pool().await; @@ -102,6 +106,7 @@ async fn invalid_username_rejected() { assert!(store.claim(&pool, &"a".repeat(65), addr(4)).await.is_err()); } +#[cfg(feature = "username-claim")] #[tokio::test] async fn valid_usernames_accepted() { let (pool, _container) = setup_pool().await; @@ -112,6 +117,11 @@ async fn valid_usernames_accepted() { store.claim(&pool, "dave.btc", addr(4)).await.unwrap(); } +// Setup seeds via `store.claim`, so this case-insensitivity test only +// runs with the claim path compiled in. The pure resolve mechanics +// are covered without claim by the `get_username_*` and `load_from_pg_*` +// cases below plus `db_tests::resolve_*`. +#[cfg(feature = "username-claim")] #[tokio::test] async fn resolve_is_case_insensitive() { let (pool, _container) = setup_pool().await; @@ -140,6 +150,30 @@ async fn load_from_pg_returns_empty_initially() { assert_eq!(store.get_username(&addr(1)), None); } +// Direct-SQL seed of the `usernames` table, then bootstrap a store via +// `load_from_pg`. Exercises the row-conversion + `usernames.insert(...)` +// path in `load_from_pg` without going through `UsernameStore::claim`, +// so the coverage gate still hits this branch when the `username-claim` +// Cargo feature is off (the read path is permanent MVP and must stay +// covered independently of the write path). +#[tokio::test] +async fn load_from_pg_returns_seeded_rows() { + let (pool, _container) = setup_pool().await; + let raw = [7u8; 32]; + let expected = digest_from_bytes(&raw); + sqlx::query("INSERT INTO usernames (name, address) VALUES ($1, $2)") + .bind("alice") + .bind(raw.as_slice()) + .execute(&pool) + .await + .expect("seed row"); + + let store = UsernameStore::load_from_pg(&pool).await.expect("load ok"); + assert_eq!(store.resolve("alice"), Some(expected)); + assert_eq!(store.get_username(&expected), Some("alice")); +} + +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_propagates_db_error_when_pool_is_dead() { // Lazy pool that never connects → claim returns Db error. @@ -222,6 +256,7 @@ async fn load_from_pg_rejects_wrong_address_length() { assert!(std::error::Error::source(&err).is_none()); } +#[cfg(feature = "username-claim")] #[test] fn validation_error_display_passes_through_message() { let err = ClaimUsernameError::Validation("Username must be 1-64 characters"); @@ -237,6 +272,7 @@ fn validation_error_display_passes_through_message() { /// must surface the same "Username already taken" Validation error /// as the in-memory pre-check would have produced. This is the /// branch that wraps the SQL-layer race fallback in `username.rs`. +#[cfg(feature = "username-claim")] #[tokio::test] async fn claim_falls_back_to_validation_when_sql_layer_catches_race() { let (pool, _container) = setup_pool().await; diff --git a/node/tests/api_remote.rs b/node/tests/api_remote.rs index 0326ee0e..041311ea 100644 --- a/node/tests/api_remote.rs +++ b/node/tests/api_remote.rs @@ -142,22 +142,26 @@ macro_rules! feature_skip { // --------------------------------------------------------------------------- // Capability detection // -// Mint (`/api/mint`) and the username routes (`/api/username/claim`, -// `/api/username/resolve/:u`) are part of the MVP and are always -// present, so they are no longer gated here. The remaining post-MVP -// routes (`address-list`, `lnurl`) are still optional: the default -// deploy ships without them and the axum fallback answers 404 instead -// of the per-handler error codes. We fetch `/api/info` once per gated -// test, deserialise the well-known `Capabilities` shape, and skip the -// rest of the test if the relevant feature flag is `false`. +// Mint (`/api/mint`) and username *resolve* (`/api/username/resolve/:u`) +// are permanent MVP endpoints — always registered, never gated. They +// have no capability bit on `/api/info` (only opt-in features do), so +// tests against those routes do not consult `fetch_capabilities`. +// +// The optional, feature-gated routes (`address-list`, `username-claim` +// write path, `lnurl`) are off in the default deploy: the axum fallback +// answers 404 instead of the per-handler error codes. We fetch +// `/api/info` once per gated test, deserialise the well-known +// `Capabilities` shape, and skip the rest of the test if the relevant +// feature flag is `false`. // // `ZKCOINS_FORCE_DISABLE_FEATURES` (comma-separated list, e.g. // `address_list,lnurl`) overrides any flag returned by the node // to `false`. This is the local dry-run hook — point the suite at the // live DEV node, force features off, and confirm that every gated // test prints `SKIP …` instead of hitting a disabled-on-paper but -// actually-running endpoint. Forcing `faucet` or `usernames` off is a -// no-op (the routes are always registered) and the flags are ignored. +// actually-running endpoint. Unknown flags (including the retired +// `faucet` / `usernames` permanent-MVP names) are ignored with a +// warning. // --------------------------------------------------------------------------- async fn fetch_capabilities(client: &reqwest::Client) -> Capabilities { @@ -185,11 +189,8 @@ async fn fetch_capabilities(client: &reqwest::Client) -> Capabilities { address_list: body["capabilities"]["address_list"].as_bool().expect( "/api/info capabilities.address_list must be a bool — missing field is a contract regression", ), - faucet: body["capabilities"]["faucet"].as_bool().expect( - "/api/info capabilities.faucet must be a bool — missing field is a contract regression", - ), - usernames: body["capabilities"]["usernames"].as_bool().expect( - "/api/info capabilities.usernames must be a bool — missing field is a contract regression", + username_claim: body["capabilities"]["username_claim"].as_bool().expect( + "/api/info capabilities.username_claim must be a bool — missing field is a contract regression", ), lnurl: body["capabilities"]["lnurl"].as_bool().expect( "/api/info capabilities.lnurl must be a bool — missing field is a contract regression", @@ -199,20 +200,7 @@ async fn fetch_capabilities(client: &reqwest::Client) -> Capabilities { for flag in force.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) { match flag { "address_list" | "address-list" => caps.address_list = false, - "faucet" => { - // Mint is permanent MVP. The route is always - // registered, so forcing it "off" cannot disable - // it — log + ignore to keep callers honest. - eprintln!( - "ZKCOINS_FORCE_DISABLE_FEATURES: `faucet` is permanent MVP — ignored" - ); - } - "usernames" => { - // Usernames are permanent MVP — same shape as `faucet`. - eprintln!( - "ZKCOINS_FORCE_DISABLE_FEATURES: `usernames` is permanent MVP — ignored" - ); - } + "username_claim" | "username-claim" => caps.username_claim = false, "lnurl" => caps.lnurl = false, other => { eprintln!( @@ -424,7 +412,7 @@ async fn info_returns_well_formed_response() { body["username_domain"] ); - for cap in ["address_list", "faucet", "usernames", "lnurl"] { + for cap in ["address_list", "username_claim", "lnurl"] { assert!( body["capabilities"][cap].is_boolean(), "capability `{cap}` must be a bool, got {:?}", @@ -926,6 +914,10 @@ async fn commit_bad_message_hex_returns_422_or_404() { #[tokio::test] async fn claim_username_pk_mismatch_returns_401() { let client = http_client(); + let caps = fetch_capabilities(&client).await; + if !caps.username_claim { + feature_skip!("username_claim", "claim_username_pk_mismatch_returns_401"); + } let alice = TestWallet::new(); let mallory = TestWallet::new(); let username = format!("mallory_{}", random_suffix()); @@ -952,6 +944,10 @@ async fn claim_username_pk_mismatch_returns_401() { #[tokio::test] async fn claim_username_bad_signature_returns_401() { let client = http_client(); + let caps = fetch_capabilities(&client).await; + if !caps.username_claim { + feature_skip!("username_claim", "claim_username_bad_signature_returns_401"); + } let alice = TestWallet::new(); let username = format!("alice_{}", random_suffix()); let body = json!({ @@ -973,6 +969,13 @@ async fn claim_username_bad_signature_returns_401() { #[tokio::test] async fn claim_username_stale_timestamp_returns_401() { let client = http_client(); + let caps = fetch_capabilities(&client).await; + if !caps.username_claim { + feature_skip!( + "username_claim", + "claim_username_stale_timestamp_returns_401" + ); + } let alice = TestWallet::new(); let username = format!("alice_{}", random_suffix()); let stale_ts = unix_now().saturating_sub(600); @@ -1247,9 +1250,13 @@ async fn send_commit_roundtrip_moves_balance() { async fn username_claim_resolve_lnurlp_roundtrip() { let client = http_client(); let caps = fetch_capabilities(&client).await; - // Claim + resolve are permanent MVP. The LNURLp leg still depends - // on the `lnurl` Cargo feature — if it's off we skip the whole - // cascade because the trailing well-known probe cannot succeed. + // The cascade hits three gated/permanent endpoints: claim (gated + // on `username_claim`), resolve (permanent MVP), and the LNURLp + // well-known leg (gated on `lnurl`). Skip if either gated feature + // is off — the trailing probe cannot succeed without both. + if !caps.username_claim { + feature_skip!("username_claim", "username_claim_resolve_lnurlp_roundtrip"); + } if !caps.lnurl { feature_skip!("lnurl", "username_claim_resolve_lnurlp_roundtrip"); } @@ -1611,11 +1618,11 @@ async fn commit_response_carries_state_hash_and_coins_root() { async fn balance_response_carries_username_after_claim() { let client = http_client(); let caps = fetch_capabilities(&client).await; - // `usernames` is permanent MVP per `fetch_capabilities`, so the - // skip path is unreachable in practice — keep the gate honest in - // case a future feature trim disables it. - if !caps.usernames { - feature_skip!("usernames", "balance_response_carries_username_after_claim"); + if !caps.username_claim { + feature_skip!( + "username_claim", + "balance_response_carries_username_after_claim" + ); } let alice = TestWallet::new(); @@ -1668,8 +1675,8 @@ async fn balance_response_carries_username_after_claim() { async fn claim_response_carries_address() { let client = http_client(); let caps = fetch_capabilities(&client).await; - if !caps.usernames { - feature_skip!("usernames", "claim_response_carries_address"); + if !caps.username_claim { + feature_skip!("username_claim", "claim_response_carries_address"); } let alice = TestWallet::new(); let username = format!("u_{}", random_suffix());