diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..d66e0494
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+# Keep the Docker build context lean: everything below is rebuilt inside the
+# image or is local dev/runtime state that must never enter the build.
+target/
+.git/
+deploy/local-e2e/data/
+deploy/local-e2e/coverage-data/
+**/*.log
diff --git a/.github/coverage-baseline.md b/.github/coverage-baseline.md
new file mode 100644
index 00000000..c6753767
--- /dev/null
+++ b/.github/coverage-baseline.md
@@ -0,0 +1,283 @@
+# Coverage baseline (G16)
+
+This file is the **measured** coverage floor for the `Tests + Coverage Gate`
+job in `.github/workflows/ci.yaml` and the live copy in
+`.github/workflows/pull-request.yaml`. It exists so the gate cannot silently
+claim 100 % while carving production modules out of the measurement
+(plan.md §5a.1 / v1.2-delta G16 / audit issue #1).
+
+## Rules
+
+1. **Production modules are measured.** The only `--ignore-filename-regex`
+ entries allowed without a written justification *in this file* are
+ pure test infrastructure and crate entrypoints:
+ - `*_tests.rs` — co-located unit-test modules
+ - `test_db.rs` — shared Postgres test helper
+ - `bin/.*\.rs$` — binary entrypoints
+ - `main.rs` / `lib.rs` — crate surface (not production logic under test)
+2. **Prover-circuit packages are excluded — with justification.**
+ `program-plonky2/` and `script-plonky2/` are the only non-trivial
+ carve-out. Their correctness is secured by:
+ - the §1.7.9 circuit-digest generator (committed digests verified
+ in the heavy CI job),
+ - the Plonky2 prove-driven suite (mint / send / receive flows),
+ - the D-05 differential test against the reference implementation.
+ Line-coverage over circuit gadgets and gate tables does not add
+ signal comparable to those checks; counting those packages would
+ drown the floor in structurally un-executable paths. This is the
+ only package-level exclusion and must stay justified here if it
+ remains.
+3. **No silent carve-outs.** If a production file must be excluded, the
+ reason is documented here — never only encoded in a regex.
+4. **Floor does not sink.** CI enforces integer floors of the measured
+ totals via `--fail-under-lines` / `--fail-under-functions`. Raising
+ the floor toward 100 % is follow-up work; lowering it is a regression
+ that needs an explicit decision and an update to this file.
+
+## CI trigger note
+
+The heavy gate runs on every non-draft PR. Drafts stay quiet unless they
+carry `ci` or `ci:full` — those labels start the same suite without
+leaving draft. Ready-for-review is not a CI switch.
+
+## Measurement record
+
+Latest honest run (self-hosted gate, 1802 tests passed, 0 failed):
+
+| Field | Value |
+|---|---|
+| Date | 2026-08-20 |
+| Branch | `feat/mtp-ready` |
+| Commit | `1e07489ef68cc6f0ff93b3bed17b9996cd32cdaa` (`1e07489`) |
+| Scope | `-p node -p shared --all-features` |
+| Ignore regex | `_tests\.rs$\|test_db\.rs$\|bin/.*\.rs$\|main\.rs$\|lib\.rs$\|program-plonky2/\|script-plonky2/` |
+| Nextest filter | `not binary(api_remote)` |
+| Notes | `scan.rs` inline `#[cfg(test)]` module carries `coverage(off)` |
+
+Previous measurement (kept for history). **Lines:** 75.26 %
+(31577 / 41959) on `6656fdd` before the `scan.rs` test module was
+excluded; the new Lines total is 75.20 % (31476 / 41859).
+**Functions:** 76.02 % (2638 / 3470) → 75.94 % (2626 / 3458) for the
+same reason: inline tests were inflating the function count (see
+methodology 2026-08-07). `--fail-under-functions 76` fails 75.94 %
+as a real integer miss, not a column mix-up.
+
+**Rule-4 decision (2026-08-20, second re-measure):** keep lines at
+75 and lower functions 76 → 75, the integer floor of 75.94 %. This
+is an explicit sink after excluding inline tests from the corpus,
+not a greenwash: all 1802 tests passed.
+
+| Field | Value |
+|---|---|
+| Date | 2026-08-02 |
+| Branch | `feat/v1-spec-rebuild` |
+| Commit | `18131ebeb4f0e717f2baba6856b0680ed3637fba` (`18131eb`) |
+| Scope | `-p node -p shared --all-features` |
+| Lines | 77.28% (39142 / 50651) |
+| Functions | 77.82% (3038 / 3904) |
+
+## CI floor (what the gate enforces)
+
+| Metric | Measured | CI `--fail-under-*` (integer floor) |
+|---|---|---|
+| **Lines** | **75.20%** (31476 / 41859) | **75** |
+| **Functions** | **75.94%** (2626 / 3458) | **75** |
+
+llvm-cov's table is Regions / Functions / Lines (not Lines first).
+A regression under 75 % lines or 75 % functions fails the gate. There
+is no 100 % fiction: the integers sit just under the honest measurement.
+
+## Previously illegitimate ignore list (removed)
+
+These patterns used to hide production code from a false 100 % claim
+and are **no longer** in `--ignore-filename-regex`:
+
+| Pattern | Why it was wrong |
+|---|---|
+| `publisher.rs` | Core Bitcoin inscription publisher |
+| `flow.rs` | Mint/send/commit job flow bodies |
+| `job_dispatcher.rs` | Background job state machine |
+| `runtime.rs` | Process bootstrap / readiness |
+| `scanner_runtime.rs` | Scanner orchestration |
+| `scanner_ws.rs` | Chain-tip WebSocket path |
+| `shared/src/.*` | Protocol types + commitment helpers |
+
+## Weakest 25 files (from the 2026-08-02 measurement)
+
+Generated from commit `18131eb`, not from the 2026-08-20 re-measure.
+Closing these is follow-up; the gate only prevents regression below
+the floor.
+
+| File | Lines % | Functions % | Lines (instrumented) |
+|---|---|---|---|
+| `node/src/v1/publish.rs` | 0.0 | 0.0 | 110 |
+| `shared/src/spec_v1/error.rs` | 5.9 | 33.3 | 221 |
+| `node/src/flow.rs` | 11.3 | 9.4 | 451 |
+| `node/src/v1/db_decrypt_index.rs` | 20.5 | 18.8 | 146 |
+| `node/src/v1/incoming.rs` | 25.0 | 29.5 | 816 |
+| `node/src/runtime.rs` | 33.3 | 41.5 | 891 |
+| `node/src/kernel/service.rs` | 38.4 | 48.3 | 521 |
+| `node/src/job_dispatcher.rs` | 46.1 | 53.5 | 3184 |
+| `node/src/v1/scan.rs` | 47.0 | 45.5 | 585 |
+| `node/src/v1/sdr.rs` | 50.2 | 29.2 | 727 |
+| `node/src/transport/grpc/convert.rs` | 52.1 | 62.1 | 1263 |
+| `node/src/v1/mode.rs` | 56.0 | 50.0 | 268 |
+| `node/src/v1/delivery.rs` | 57.1 | 53.6 | 1558 |
+| `node/src/v1/signature.rs` | 60.1 | 56.0 | 2421 |
+| `node/src/v1/recovery.rs` | 67.0 | 78.6 | 798 |
+| `shared/src/spec_v1/datastructures.rs` | 68.0 | 37.5 | 75 |
+| `node/src/v1/receive.rs` | 71.0 | 55.3 | 2585 |
+| `node/src/v1/nostr/relay.rs` | 76.3 | 86.9 | 1120 |
+| `node/src/v1/self_heal.rs` | 76.3 | 68.2 | 465 |
+| `node/src/v1/reconstitute.rs` | 76.4 | 74.3 | 533 |
+| `node/src/v1/nostr/kinds/delivery.rs` | 76.5 | 81.5 | 319 |
+| `node/src/publisher.rs` | 79.1 | 84.4 | 535 |
+| `node/src/esplora_bound.rs` | 79.2 | 66.7 | 48 |
+| `node/src/v1/attest.rs` | 79.9 | 73.9 | 1351 |
+| `node/src/v1/nostr/profile.rs` | 80.2 | 80.3 | 983 |
+
+## How to re-measure
+
+```bash
+export PUBLISHER_KEY=0000000000000000000000000000000000000000000000000000000000000001
+export IS_MAINNET=false
+export ESPLORA_URL=http://127.0.0.1:1/api
+export ESPLORA_WS_URL=ws://127.0.0.1:1/api/v1/ws
+export USERNAME_DOMAIN=test.zkcoins.local
+export RUSTFLAGS="--cfg coverage_nightly"
+IGNORE='_tests\.rs$|test_db\.rs$|bin/.*\.rs$|main\.rs$|lib\.rs$|program-plonky2/|script-plonky2/'
+
+cargo llvm-cov nextest --release -p node -p shared --all-features \
+ --ignore-filename-regex "$IGNORE" \
+ --fail-under-lines 0 --fail-under-functions 0 \
+ --test-threads 8 \
+ -E 'not binary(api_remote)'
+
+cargo llvm-cov report --release --json --ignore-filename-regex "$IGNORE"
+```
+
+After a higher measurement, raise the `--fail-under-*` integers in
+`ci.yaml` **and** `pull-request.yaml` and update the tables above in
+the same PR. Never lower them to greenwash a drop; a re-measure of a
+grown corpus belongs in the tables above with the old numbers kept.
+
+## Shared crate: reachable code covered, residual is provably unreachable
+
+As of 2026-08-06 the `shared` crate's own files (`spec_v1/*`, `commitment.rs`)
+are covered at ~99% lines; `error`, `network_params`, `trees`, `datastructures`,
+`nflog` are at 100%. The remaining uncovered lines are **provably-unreachable
+defensive code** — not test gaps. They are documented here (rule 3) rather than
+silently ignored, and are NOT worth artificial tests:
+
+- `commitment.rs:71` — `Err(_)` after `Message::from_digest_slice(msg_hash)`; the
+ input is always exactly 32 bytes, so the conversion cannot fail.
+- `spec_v1/encoding.rs:41` — `ByteStringTooLong` needs a ~72 PB slice (not allocatable).
+- `spec_v1/hashes.rs:406-407` — `NameTooLong` via `u32::try_from` needs a >4 GiB local-part.
+- `spec_v1/bootstrap_manifest.rs:611-612` — `fixture_sk` rehash second iteration needs a
+ SHA-256 digest outside `[1,n)` (~2⁻¹²⁸).
+- `spec_v1/coinhist.rs:155` — `Absent` is never stored in `leaves`; no public path reaches it.
+- `spec_v1/bundle.rs:444-453,485,497,605-608,615-616` — defensive arms after a preceding
+ `validate_*` / bounds check already guarantees the non-divergent branch.
+- `spec_v1/accumulator.rs:427,1006`, `spec_v1/serialize.rs:141` — implicit else-region of an
+ `if let` whose predecessor assert guarantees no divergence / block whose only content is a
+ terminating `return` (llvm-cov closing-brace region artifact).
+- `spec_v1/nflog_boundary.rs:51,107,369,380,773,860` — test-fixture module (`test-fixtures`
+ feature) defensive `assert!`/overflow guards on inputs the suite never violates.
+
+Reaching a literal 100% would require `#[cfg_attr(coverage_nightly, coverage(off))]` on these
+functions (the established mechanism in this repo) — deferred, since annotating single defensive
+arms inside otherwise-covered functions would over-exclude their covered lines.
+
+## Node crate: unit + integration coverage (2026-08-07)
+
+A large part of the `node` crate is integration code (Scanner/bitcoind RPC, PgPool, the async
+job-dispatcher) that a pure unit test cannot reach — it only runs against the live stack. That code
+IS exercised by the end-to-end journey, but a normal `cargo llvm-cov nextest` run does not instrument
+the journey, so it was counted as uncovered. The **integration-coverage pipeline** closes this:
+
+- `deploy/local-e2e/collect-integration-coverage.sh` builds the node image with coverage
+ instrumentation scoped to **workspace crates only** (`RUSTC_WORKSPACE_WRAPPER`, so the external
+ `plonky2` prover is NOT instrumented and stays fast; the circuit workspace crates carry crate-level
+ `#![cfg_attr(coverage_nightly, coverage(off))]`), runs the journey 1→9 against it, flushes coverage
+ on SIGTERM (a `coverage-flush`-feature handler calling `__llvm_profile_write_file`), and merges the
+ resulting `integration.lcov` with the unit-test `unit.lcov`.
+- Reproduce: bring the dev stack (`zkcoins-local`) down first (port 18443), `source` env.local.sh,
+ `export COMPOSE_PROJECT_NAME=zkcoins-local-coverage`, `brew install lcov`, then run the script.
+
+**Measured node-src line coverage (updated 2026-08-07, wave 4):**
+
+| Source | Coverage |
+|---|---|
+| Unit tests only | 79.51% |
+| Journey/integration only | 23.97%¹ |
+| **Combined (unit ∪ integration)** | **85.35%** (44294 / 51894) |
+
+Wave-4 unit gains: `v1/attest.rs` 82 → **88.01%**, `v1/nostr/profile.rs` 83 → **89.51%** (each with an
+adversarial-review pass, all error-branch assertions pinned to the exact variant/message), `v1/recovery.rs`
+§4.5 +11 error-branch tests.
+
+**Methodology fix applied (2026-08-07): inline test modules excluded from coverage.** All 46 inline
+`#[cfg(test)] mod tests` blocks in node-src now carry `#[cfg_attr(coverage_nightly, coverage(off))]`
+(same mechanism the shared/script/program crates already use). This measures honest **production**
+coverage. Counter-intuitively this *lowered* the reported number: the inline test modules were ~95%
+covered (tests run their own code) and were inflating the figure, not deflating it.
+
+**Honest production node-src line coverage (2026-08-07, test modules excluded):**
+
+| Source | Coverage |
+|---|---|
+| Unit tests only | 68.81% |
+| Journey/integration only | 40.33% |
+| **Combined (unit ∪ integration)** | **78.63%** (24257 / 30848) |
+
+The earlier 85.35% figure counted test code and was inflated. Production coverage is 78.63% combined.
+The remaining ~21% is dominated by **integration-only** production code (Scanner/bitcoind RPC, the async
+job dispatcher, the Blossom/Nostr network path, the C-prover) with no test hook — e.g. `recovery.rs`
+production is 35.5%, `signature.rs` 52.5%, the rest being Scanner/network/prover paths. Their *correctness*
+is covered by the green journey + the reorg matrix (§3.9) + the fail-closed gates; their *lines* are only
+reachable via the live stack. Path to higher honest production coverage: (1) the remaining unit-testable
+pure/DB branches; (2) live-stack fault injection for the integration error branches; (3) documented
+`coverage(off)` for the provably-only-live defensive arms (never over-excluding unit-reachable code).
+
+¹ integration-only measured against the *union* instrumented-line base (larger denominator than
+Update 9's integ-only-base 47.8%); the combined figure is the honest, comparable metric.
+
+Wave-2 unit gains: `v1/sdr.rs` 50.2 → **93.09%** (+49 tests), `v1/db_decrypt_index.rs` 20.5 → **99.79%** (+12).
+Wave-3 unit gains (each behind an adversarial codex-reviewer pass): `v1/signature.rs` 60.1 → **74.85%**
+(6 review-found error-branch gaps closed), `flow.rs` 18 → **65.33%** (admit-validators + a real production
+bug fixed: `validate_send_request` reported "Missing signature" for an absent timestamp, against the
+router.rs contract), `kernel/service.rs` 38 → **51.79%** (chain-less getters/builders/fail-closed reads;
+async/DB paths deferred), `self_heal_tests.rs` (+2 error-branch tests).
+
+**Latest measurement (2026-08-07, node HEAD `efb2781`, fault-injection journey stages):**
+
+| Source | Coverage |
+|---|---|
+| Unit tests only | 69.0% (21226/30770) |
+| Journey/integration only | 42.5% (11073/26024) |
+| **Combined (unit ∪ integration, node-only, official lcov merge)** | **77.5%** (23833/30770) |
+
+Progress vs. the prior measurement: integration-only 40.3% → 42.5% — the newly covered
+bitcoind/postgres fault-recovery paths from journey stages `fault-bitcoind` and
+`fault-postgres` (gated behind `ZKCOINS_JOURNEY_FAULTS=1`). 1721 unit tests green; journey
+stages 1–9 plus both fault stages green.
+
+Methodology note: only the **lines** figure above is meaningful. The lcov `functions` merge
+figure is an artifact — it adds together the denominators of two distinct binaries (the
+host unit-test binary and the Linux integration binary), which do not share a function set.
+
+**The path to 100% is four layers** (largest combined-uncovered blocks first):
+1. **Unit-testable pure logic** (sequential codex lanes; parallel lanes collide via the nested
+ `node/node/src` path): `signature.rs` 79% (BIP-340 pure), `kernel/service.rs` 67%, `v1/attest.rs`
+ 82%, `v1/nostr/profile.rs` 83%, `self_heal.rs`, `reconstitute.rs`.
+2. **Fault-injection integration tests** for the integration-dominated files the happy-path journey
+ only partially hits: `job_dispatcher.rs` 57%, `v1/recovery.rs` 70%, `v1/receive.rs` 73%,
+ `runtime.rs` 65%, `v1/incoming.rs` 67% (make regtest bitcoind / the DB fail on purpose).
+3. **Journey extension** for paths neither unit nor journey reaches today: `flow.rs` **18%**,
+ `v1/attest_verify.rs` **39%** (Scanner-backed `verify_balance_attestation` — the journey has no
+ attest-verify leg).
+4. **Documented `coverage(off)`** for provably-unreachable defensive code (same mechanism as the
+ `shared` crate — never over-excluding covered lines).
+
+Known-flaky historically: `router::tests::health_publisher_*` (esplora-dependent) — this wave's
+1606-test run passed all 1606 (14 skipped), flaky tests included.
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 8af83984..63def38c 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -1,37 +1,40 @@
name: CI
on:
+ # `workflow_dispatch` stays available: a run can still be started by
+ # hand from the Actions tab when a specific answer is wanted.
+ workflow_dispatch:
+
# CI runs on every pull request regardless of target branch. This
# makes the default safe for stacked PRs (PR-A → PR-B → PR-C where
# each PR's base is the previous PR's branch) and any other workflow
# that opens a PR against a non-`develop` branch — previously such
# PRs were silently skipped because `branches: [develop]` filtered
# them out, and the only fix was to hand-edit ci.yaml on each new
- # feature stack. Letting every PR trigger CI is cheap (the heavy
- # M3 Ultra jobs are still gated behind the `ci:full` label below)
- # and matches what most repos default to.
+ # feature stack. Letting every PR trigger CI matches what most
+ # repos default to. The heavy M3 Ultra gate (`test-and-coverage`)
+ # now runs on every non-draft PR (CI endgame / G16 — see
+ # `.github/coverage-baseline.md`); drafts still skip via each
+ # job's `if:` guard.
#
# `push: develop` is intentionally absent. Every commit reaching
# `develop` is already covered by the open Release PR (`Release:
# develop -> main`, created by auto-release-pr.yaml) — that PR's
- # `synchronize` event runs CI on the new HEAD, and because the
- # Release PR carries the `ci:full` label the heavy gate runs too.
- # Adding `on: push: branches: [develop]` would queue a second
- # workflow instance on the same SHA, doubling self-hosted-runner
- # load on a check the Release PR's `synchronize` already provides.
+ # `synchronize` event runs CI on the new HEAD, and the heavy gate
+ # runs because the Release PR is non-draft. Adding
+ # `on: push: branches: [develop]` would queue a second workflow
+ # instance on the same SHA, doubling self-hosted-runner load on a
+ # check the Release PR's `synchronize` already provides.
# (Under the PR-number grouping in the concurrency block below the
# two runs would land in DIFFERENT groups — push keyed by
# `refs/heads/develop`, PR keyed by the Release PR's number — so
# the block would not deduplicate them.)
#
- # `ready_for_review` is added so the workflow fires the moment a
- # draft PR is marked ready — drafts themselves skip CI via the
- # `if:` guard on each job (saves self-hosted-runner time while
- # work is still in progress).
- #
- # `labeled` / `unlabeled` are added so toggling the `ci:full` label
- # triggers (or removes) the heavy self-hosted-runner gate on demand
- # — see the `test-and-coverage` job below.
+ # Drafts skip the jobs unless they carry `ci` or `ci:full`. That
+ # is how CI starts on a draft — never by flipping ready-for-review
+ # just to wake the suite. `labeled` / `unlabeled` re-fire so adding
+ # `ci` / `ci:full` on a draft actually starts the jobs.
+ # `ready_for_review` still fires when a finished PR leaves draft.
pull_request:
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
@@ -49,10 +52,10 @@ concurrency:
#
# Label events (`labeled` / `unlabeled`) get their own isolated
# group keyed by `run_id`, so toggling a label on a PR does NOT
- # cancel an in-flight 60-90-min Heavy run on the same PR — most
- # label toggles are unrelated (`bug`, `priority/*`, …) and killing
- # the Heavy run for them would be a footgun. Trade-off: removing
- # `ci:full` mid-run does NOT auto-stop a Heavy run that is already
+ # cancel an in-flight Heavy run on the same PR — most label
+ # toggles are unrelated (`bug`, `priority/*`, …) and killing the
+ # Heavy run for them would be a footgun. Trade-off: a label toggle
+ # mid-run does NOT auto-stop a Heavy run that is already
# executing; cancel it manually with `gh run cancel` if you really
# need to free an agent.
group: >-
@@ -72,32 +75,31 @@ env:
# Job topology — a two-tier test-gating model:
#
# * Tier 1 — `lint-and-build` — GitHub-hosted Linux, the DEFAULT.
-# Runs on every non-draft PR and every push with no label required.
-# Catches cross-platform compile bitrot and lint regressions
-# cheaply. Runs in PARALLEL with the heavy gate below — it does not
-# gate it via `needs:`. Each job carries its own draft/label `if:`
-# guard, so a lint failure does not block the heavy gate from
-# starting (deliberate: parallel feedback. Trade-off: on a lint
-# failure the m3-ultra runner time is spent regardless).
+# Runs on every non-draft PR (no push trigger; see on: above). Catches cross-platform
+# compile bitrot and lint regressions cheaply. Runs in PARALLEL with
+# the heavy gate below — it does not gate it via `needs:`. Each job
+# carries its own draft `if:` guard, so a lint failure does not
+# block the heavy gate from starting (deliberate: parallel
+# feedback. Trade-off: on a lint failure the m3-ultra runner time
+# is spent regardless).
#
# * Tier 2 — `test-and-coverage` — the authoritative test + coverage
-# gate, opt-in via the `ci:full` label. Single heavy job (~60-90 min
-# on the shared self-hosted M3 Ultra runner pool). It runs the FULL
-# node + shared nextest suite under llvm-cov instrumentation: the Postgres
-# `db_tests`, the Plonky2-heavy mint/send/receive prover flows, and
-# the 100% line + function coverage gate, all in one binary run.
-# Gated behind `ci:full` so we don't burn runner time on every
-# speculative PR — apply the label when the PR is ready for the
-# authoritative gate. Both auto-promote PRs (staging -> develop and
-# develop -> main) get the label applied automatically by
-# auto-release-pr-staging.yaml / auto-release-pr.yaml.
+# gate. Runs on every non-draft PR (CI endgame / G16 expansion —
+# previously label-gated behind `ci:full`; see
+# `.github/coverage-baseline.md`). Single heavy job on the shared
+# self-hosted M3 Ultra runner pool. It runs the FULL node + shared
+# nextest suite under llvm-cov instrumentation: the Postgres
+# `db_tests`, the Plonky2-heavy mint/send/receive prover flows, the
+# measured coverage floor, plus the release-mode prover package and
+# the four `#[ignore]` prove flows (Task #9), all in one job.
+# Drafts skip unless they carry `ci` or `ci:full`.
#
# There is no third "subset" tier: a test either runs in the default
-# Lint & Build (compile/lint) or comes in with `ci:full` (the full
-# suite). The previous narrow per-area subset jobs (and their
-# per-area opt-in labels) were removed — the heavy gate is a strict
-# superset of everything they selected, so they added a maintenance
-# burden (filter drift) without extending coverage.
+# Lint & Build (compile/lint) or in the heavy gate (the full suite).
+# The previous narrow per-area subset jobs (and their per-area opt-in
+# labels) were removed — the heavy gate is a strict superset of
+# everything they selected, so they added a maintenance burden
+# (filter drift) without extending coverage.
#
# Why test + coverage are merged into one job: the previous topology
# had a `node-tests` job and a separate `coverage` job, both
@@ -105,12 +107,12 @@ env:
# in `cargo llvm-cov nextest`). That doubled wall-clock and m3-ultra
# agent usage on every Release PR for no signal benefit — llvm-cov
# under nextest produces both test execution AND coverage data in a
-# single binary run. Merging them keeps the 100% lines + functions
-# gate intact (same ignore regex, same `not binary(api_remote)`
+# single binary run. Merging them keeps the measured coverage floor
+# intact (same ignore regex as baseline, same `not binary(api_remote)`
# exclusion) while running the heavy suite once per PR.
#
# The documented hardware target is the M3 Ultra (CONTRIBUTING.md
-# § "Working on the Plonky2 Migration"). On `ubuntu-latest` the
+# § "Hardware target"). On `ubuntu-latest` the
# full suite repeatedly hit the 75-min timeout (issue #30); on the
# M3 Ultra it is ~60-90 min for a Rust change. Moving the gate
# into CI rather than the developer's laptop unblocks the developer
@@ -120,22 +122,36 @@ env:
jobs:
lint-and-build:
name: Lint & Build
- # Skip on draft PRs. The heavy `test-and-coverage` gate carries
- # the same draft/push guard plus the `ci:full` label check on its
- # own `if:`, so it runs in parallel with this job rather than
- # gating behind it via `needs:`.
- if: github.event_name == 'push' || github.event.pull_request.draft == false
+ # Skip drafts that have no CI-start label. `ci` and `ci:full`
+ # start this job on a draft (same suite as a ready PR). Ready
+ # is not a CI switch.
+ if: >
+ github.event_name != 'pull_request' ||
+ github.event.pull_request.draft == false ||
+ contains(toJson(github.event.pull_request.labels.*.name), '"ci"') ||
+ contains(toJson(github.event.pull_request.labels.*.name), '"ci:full"')
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- - name: Install Rust 1.81.0
- uses: dtolnay/rust-toolchain@master
- with:
- toolchain: "1.81.0"
- components: rustfmt, clippy
+ # The repo pins its toolchain in `rust-toolchain` (a dated nightly, with
+ # `rustfmt` and `clippy` in `components`) and every other job uses it.
+ # This step used to install 1.81.0 explicitly and export
+ # `RUSTUP_TOOLCHAIN`, which overrides the file — so formatting and lints
+ # were checked on a toolchain seventeen releases older than the one that
+ # builds and tests the same tree. That is not a conservative choice, it
+ # is a different compiler: a clippy suggestion can name an API the build
+ # toolchain has and the lint toolchain does not, and a lint that only
+ # exists in one of them is either invisible or unfixable. Installing the
+ # pinned toolchain instead keeps a single pin with nothing to drift.
+ - name: Install the pinned toolchain (rust-toolchain)
+ run: |
+ rustup show active-toolchain || rustup toolchain install
+ cargo --version
+ cargo fmt --version
+ cargo clippy --version
- name: Cache cargo registry and build
uses: actions/cache@v4
@@ -148,34 +164,49 @@ jobs:
restore-keys: |
${{ runner.os }}-cargo-
+ # kernel-proto/build.rs invokes `protoc` (via tonic-build / prost-build)
+ # to compile the kernel.v1 contract. ubuntu-latest ships no
+ # protobuf-compiler by default — without this step, fmt is fine but
+ # clippy/build fail with "Could not find `protoc`".
+ - name: Install protoc (kernel-proto build.rs)
+ run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
+
- name: Check formatting
run: cargo fmt --all --check
+ # `--all-targets` on every clippy step: without it clippy lints the
+ # library targets only, so tests, benches and feature-gated fixture
+ # modules — several thousand lines that decide whether a green run
+ # means anything — were never linted at all. Two real lints were
+ # hiding in `shared/src/spec_v1/nflog_boundary.rs`, which is behind
+ # the `test-fixtures` feature and therefore invisible to a plain
+ # `-p shared` run.
- name: Run clippy (node + shared, MVP feature set)
- run: cargo clippy -p node -p shared -- -D warnings
+ run: cargo clippy -p node -p shared --all-targets -- -D warnings
- name: Run clippy (node, all features)
- run: cargo clippy -p node --all-features -- -D warnings
-
- - name: Run clippy (program + prover libs)
- run: cargo clippy -p zkcoins-program-plonky2 -p zkcoins-prover-plonky2 --lib -- -D warnings
-
- # Issue #84: the chain-tip wait path and the publisher's
- # commit→reveal propagation wait must be event-driven (WS /
- # ZMQ), not polled. The grep below fails the build if a
- # `tokio::time::{sleep,sleep_until,interval}` or
- # `std::thread::sleep` call sneaks back into the scanner /
- # publisher modules without the documented opt-out marker. See
- # CONTRIBUTING.md § "No polling — events only" for the per-line
- # `scanner-polling-ok:` escape hatch and the rationale for each
- # currently-grandfathered occurrence. The marker is a plain
- # comment token (not an `#[allow(...)]` attribute) so future
- # contributors cannot mistake it for a real lint suppression
- # (issue #84 round-4 MINOR 4).
+ run: cargo clippy -p node --all-features --all-targets -- -D warnings
+
+ - name: Run clippy (program + prover)
+ run: cargo clippy -p zkcoins-program-plonky2 -p zkcoins-prover-plonky2 --all-targets -- -D warnings
+
+ # Issue #84: chain-tip advance and publisher commit→reveal waits must
+ # be event-driven (bitcoind ZMQ / block signals), not silent polls.
+ # The deleted Esplora WS scanner modules are gone; the live hotpaths
+ # are the v1 bitcoind scan loop in `main.rs` and `publisher.rs`.
+ # The grep fails the build if a `tokio::time::{sleep,sleep_until,
+ # interval}` or `std::thread::sleep` call appears there without the
+ # same-line opt-out marker. See CONTRIBUTING.md § "No polling —
+ # events only" for `scanner-polling-ok:` and the rationale for each
+ # grandfathered occurrence. The marker is a plain comment token
+ # (not an `#[allow(...)]` attribute) so contributors cannot mistake
+ # it for a real lint suppression (issue #84 round-4 MINOR 4).
+ # Follow-up: replace main.rs scan_to_tip idle sleep with bitcoind
+ # block-signal subscription (event-driven tip advance).
- name: Forbid polling patterns in scanner/publisher
run: |
set -e
- FOUND=$(grep -rEn 'tokio::time::(sleep|sleep_until|interval)|std::thread::sleep' node/src/scanner.rs node/src/scanner_runtime.rs node/src/scanner_ws.rs node/src/scanner_ws_parse.rs node/src/publisher.rs 2>/dev/null | grep -v 'scanner-polling-ok:' || true)
+ FOUND=$(grep -rEn 'tokio::time::(sleep|sleep_until|interval)|std::thread::sleep' node/src/main.rs node/src/publisher.rs 2>/dev/null | grep -v 'scanner-polling-ok:' || true)
if [ -n "$FOUND" ]; then
echo "::error::Polling pattern (tokio::time::sleep|sleep_until|interval or std::thread::sleep) detected in event-driven hot paths. See issue #84."
echo "$FOUND"
@@ -190,23 +221,32 @@ jobs:
run: cargo build -p node --all-features
test-and-coverage:
- name: Tests + Coverage Gate (M3 Ultra, 100% lines + functions)
+ name: Tests + Coverage Gate (M3 Ultra, measured coverage floor)
# Authoritative heavy gate: runs the full nextest suite under
# llvm-cov instrumentation, producing both test execution AND
# coverage data in a single binary run. Replaces the previous
# `node-tests` + `coverage` pair (the two jobs ran the same
# nextest suite — see the file header for the merge rationale).
+ # Floor values and ignore rules: `.github/coverage-baseline.md`.
#
- # Gated behind the `ci:full` label so we don't burn runner time
- # on every speculative PR. Both auto-promote PRs get the label
- # applied automatically: staging -> develop by
- # auto-release-pr-staging.yaml and develop -> main by
- # auto-release-pr.yaml.
- if: >-
- (github.event_name == 'push' || github.event.pull_request.draft == false)
- && contains(github.event.pull_request.labels.*.name, 'ci:full')
- runs-on: [self-hosted, m3-ultra]
- timeout-minutes: 120
+ # Same start rule as `lint-and-build`: every non-draft PR, plus
+ # drafts that carry `ci` or `ci:full`. `ci:full` is no longer a
+ # scope gate — it is only a start-on-draft alias.
+ if: >
+ github.event_name != 'pull_request' ||
+ github.event.pull_request.draft == false ||
+ contains(toJson(github.event.pull_request.labels.*.name), '"ci"') ||
+ contains(toJson(github.event.pull_request.labels.*.name), '"ci:full"')
+ # The registered self-hosted M3 Ultra machines advertise
+ # the `zkcoins-node` pool label, not `m3-ultra` — the old `m3-ultra`
+ # requirement matched no online runner, so this gate queued forever. Target
+ # the label the runners actually carry.
+ runs-on: [self-hosted, zkcoins-node]
+ # 180 min: llvm-cov nextest (~60-90 min) + release prover package
+ # + four `#[ignore]` prove flows (a representative multi-input
+ # send prove alone is ~290 s locally) + circuit-digest verify.
+ # Was 120 min when the job stopped at coverage + digests only.
+ timeout-minutes: 180
env:
# All three chain-shaping env vars are required by the node
# bootstrap (see `lib::build_network_config_from_env`). CI uses
@@ -254,19 +294,6 @@ jobs:
# server only reads SCCACHE_CACHE_SIZE at start, so the install
# step below restarts it when the running cap differs.
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 this job: the `lint-and-build` job runs stable
- # 1.81.0 and would reject `feature(coverage_attribute)`, so the
- # cfg has no effect there.
- RUSTFLAGS: "--cfg coverage_nightly"
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -274,12 +301,25 @@ jobs:
- name: Prepend ~/.cargo/bin to PATH (use rustup proxy, not Homebrew Rust)
run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- # Point `testcontainers` at the Colima socket under the runner
- # user's home (see the `DOCKER_HOST` comment in the job env block
- # above). Set in a step so the path resolves from `$HOME` at
- # runtime instead of being hard-coded.
- - name: Set DOCKER_HOST for Colima socket
- run: echo "DOCKER_HOST=unix://$HOME/.colima/default/docker.sock" >> "$GITHUB_ENV"
+ # The `testcontainers` workload runs in a dedicated, resource-
+ # capped Colima profile (`ci`) instead of the host's default
+ # Docker profile. Runner hosts may share their default profile
+ # with unrelated workloads; a separate profile guarantees the
+ # CI Postgres containers can never contend with (or be starved
+ # by) anything else on the host. The profile is created once per
+ # host (see the runner provisioning docs); this step only boots
+ # it if it is not already running. `--activate=false` keeps the
+ # host's global Docker context untouched — the job talks to the
+ # profile exclusively via the explicit DOCKER_HOST below.
+ - name: Ensure dedicated CI Docker profile is running
+ run: colima status ci >/dev/null 2>&1 || colima start ci --activate=false
+
+ # Point `testcontainers` at the `ci` profile's socket (see the
+ # `DOCKER_HOST` comment in the job env block above). Set in a
+ # step so the path resolves from `$HOME` at runtime instead of
+ # being hard-coded.
+ - name: Set DOCKER_HOST for CI Colima profile socket
+ run: echo "DOCKER_HOST=unix://$HOME/.colima/ci/docker.sock" >> "$GITHUB_ENV"
# `sccache` (compile cache) and `cargo-nextest` (test runner)
# are installed once per runner via Homebrew. Idempotent: no-op
@@ -287,10 +327,15 @@ jobs:
# already running with a different cap than the requested
# SCCACHE_CACHE_SIZE, stop it so the next --start-server picks up
# the new env value; the on-disk cache files survive the restart.
- - name: Ensure sccache + cargo-nextest are installed
+ - name: Ensure sccache + cargo-nextest + protoc are installed
run: |
command -v sccache >/dev/null || brew install sccache
command -v cargo-nextest >/dev/null || brew install cargo-nextest
+ # kernel-proto/build.rs invokes protoc; the self-hosted runner has
+ # no protobuf-compiler unless we install it (idempotent, like the
+ # tools above). The ubuntu lint job installs it via apt separately.
+ command -v protoc >/dev/null || brew install protobuf
+ protoc --version
if ! sccache --show-stats 2>/dev/null | grep -qE "Max cache size +50 GiB"; then
sccache --stop-server >/dev/null 2>&1 || true
fi
@@ -305,36 +350,43 @@ jobs:
- name: Verify Docker is reachable (testcontainers dependency)
run: docker info > /dev/null
+ # Host-wide proving lease: `compliance_circuit` refuses to
+ # build without ZKCOINS_PROVER_LEASE_PATH. The flock file lives
+ # under $HOME so concurrent self-hosted agents on the same host
+ # serialise the RAM-heavy circuit (a job-local RUNNER_TEMP path
+ # would not). The file is created on first open.
+ - name: Point the proving lease at the host-wide lock file
+ run: echo "ZKCOINS_PROVER_LEASE_PATH=${HOME}/zkcoins-prover.lease" >> "$GITHUB_ENV"
+
# `cargo llvm-cov nextest` is the nextest-aware coverage
# subcommand: collects llvm-cov data while driving the suite
- # through nextest, so the 100% line/function gate and the test
+ # through nextest, so the measured coverage floor and the test
# execution share a single binary run. This is the merge of
# the previous `node-tests` + `coverage` pair — the previous
# `node-tests` job ran the same nextest invocation without the
# `cargo llvm-cov` wrapper, which produced no extra signal.
#
- # `-p node -p shared --all-features` matches the previous
- # `node-tests` test set exactly (the previous `coverage` job
- # was scoped to `-p node` because the coverage GATE is only
- # measured against the `node` crate; the merge keeps that gate
- # scope while widening the EXECUTED set to `-p node -p shared`
- # so the shared crate's `commitment::tests::*` keep running in
- # the heavy gate — they were part of `node-tests` before).
- # The `shared/src/commitment.rs` entry in --ignore-filename-regex
- # keeps the coverage gate strictness identical to the previous
- # `-p node`-scoped gate: the shared crate's source files are
- # excluded from the 100% measurement, only the `node` crate is
- # gated. `--all-features` likewise mirrors the previous
- # `node-tests` invocation so opt-in feature-gated code paths
- # still execute.
+ # Scope: `-p node -p shared --all-features`. Production modules
+ # (publisher, runtime, flow, job_dispatcher, scanners, shared)
+ # are measured. Legitimate ignores only: test infra + crate
+ # entrypoints + Plonky2 circuit packages. Floor integers and
+ # full justification: `.github/coverage-baseline.md`.
#
# The `api_remote` integration test (node/tests/api_remote.rs)
# is excluded: it targets the live DEV node and belongs in
# the post-deploy `api-e2e` job in deploy-dev.yaml, not the
- # hermetic gate. The MVP coverage scope is measured by the
- # rest of the suite, which covers the in-process axum handlers
- # via oneshot().
- - name: Run llvm-cov nextest (MVP scope, 100% line + function gate)
+ # hermetic gate. The coverage scope is measured by the rest of
+ # the suite, which covers the in-process axum handlers via
+ # oneshot().
+ - name: Run llvm-cov nextest (measured coverage floor)
+ # Activate `coverage_nightly` only here: llvm-cov does not
+ # auto-set the cfg, and without it `coverage(off)` is inert.
+ # Later prove steps must not inherit it — the cfg also
+ # compiles `spawn_coverage_flush_signal_handler`, which
+ # references `__llvm_profile_write_file` that only the
+ # llvm-cov profiler runtime provides.
+ env:
+ RUSTFLAGS: "--cfg coverage_nightly"
# `--test-threads=8` (issue #181 Opt A): the heavy gate is
# the largest wall consumer on M3 Ultra (~60-90 min at
# --test-threads=1). 8 outer threads × Rayon-pinned cores
@@ -343,29 +395,64 @@ jobs:
# Per-test schema isolation (#182) + cross-process file lock
# around the shared container (`test_db::init_shared_pg`)
# make the suite parallel-safe under llvm-cov.
+ #
+ # Floor: integer under measured totals (75.20% lines /
+ # 75.94% functions). See `.github/coverage-baseline.md`.
run: |
cargo llvm-cov nextest --release -p node -p shared --all-features --show-missing-lines \
- --ignore-filename-regex 'main\.rs|lib\.rs|publisher\.rs|runtime\.rs|scanner_runtime\.rs|scanner_ws\.rs|flow\.rs|job_dispatcher\.rs|_tests\.rs$|test_db\.rs$|bin/.*\.rs$|shared/src/.*\.rs$' \
- --fail-under-lines 100 \
- --fail-under-functions 100 \
+ --ignore-filename-regex '_tests\.rs$|test_db\.rs$|bin/.*\.rs$|main\.rs$|lib\.rs$|program-plonky2/|script-plonky2/' \
+ --fail-under-lines 75 \
+ --fail-under-functions 75 \
--test-threads 8 \
-E 'not binary(api_remote)'
+ # Task #9: release-mode prover package suite. These prove tests
+ # are multi-minute and never ran in any gate before; the M3 Ultra
+ # job is the right home (GitHub-hosted `lint-and-build` would
+ # time out). Local verification of a representative multi-input
+ # send prove in this package: ~290 s, green.
+ - name: Run zkcoins-prover-plonky2 release tests
+ run: cargo nextest run -p zkcoins-prover-plonky2 --release
+
+ # `#[ignore]` prove flows in node+shared that the llvm-cov
+ # nextest step never selects. Real Plonky2 proves — serial
+ # (`--test-threads 1`) because the host-wide proving lease
+ # allows one C residency; parallel waiters hit the 1800 s
+ # flock timeout. Same `api_remote` exclusion as llvm-cov
+ # (that binary talks to the live DEV node).
+ - name: Run ignored prove flows (node + shared)
+ run: >
+ cargo nextest run -p node -p shared --all-features --release
+ --run-ignored ignored-only
+ --test-threads 1
+ -E 'not binary(api_remote)'
+
+ # §1.7.9 circuit-digest generator: builds real C / C_balance for every
+ # network and verifies the committed generated_circuit_digests.txt
+ # matches. Multi-minute; kept out of default cargo test via #[ignore],
+ # but a test that cannot run cannot fail — this heavy job is the one
+ # that actually runs it. Does NOT set REGEN_CIRCUIT_DIGESTS
+ # (verify-only; no file rewrite).
+ - name: Verify live circuit digests vs committed generated_circuit_digests.txt
+ run: |
+ cargo test -p zkcoins-prover-plonky2 --release --test generated_circuit_digests_test \
+ generate_circuit_digests -- --ignored --nocapture
+
# 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.
+ # files still below 100% line / function (gap list; the gate
+ # itself fails under the measured floor — see baseline).
+ # `--show-missing-lines` on the gate step sometimes elides this
+ # section depending on the llvm-cov build, so this step makes
+ # the detail deterministic: whenever the gate fails, the
+ # operator sees which file/line/function is uncovered without
+ # having to reproduce locally.
- name: Show missing coverage on gate failure
if: failure()
run: |
- IGNORE='main\.rs|lib\.rs|publisher\.rs|runtime\.rs|scanner_runtime\.rs|scanner_ws\.rs|flow\.rs|job_dispatcher\.rs|_tests\.rs$|test_db\.rs$|bin/.*\.rs$|shared/src/.*\.rs$'
+ IGNORE='_tests\.rs$|test_db\.rs$|bin/.*\.rs$|main\.rs$|lib\.rs$|program-plonky2/|script-plonky2/'
echo "--- llvm-cov report: --show-missing-lines (text) ---"
cargo llvm-cov report --release --show-missing-lines \
@@ -431,8 +518,7 @@ jobs:
# timeout, OOM, runner crash — still fire the alert. `if: failure()`
# evaluates against the whole `needs:` group: any listed job
# transitioning to `failure` triggers it, while skipped jobs
- # (`test-and-coverage` on a non-ci:full PR, or all jobs on a draft
- # PR) and manual cancellation stay silent.
+ # (drafts without ci / ci:full) and manual cancellation stay silent.
notify-failure:
name: Telegram alert on failure
needs: [lint-and-build, test-and-coverage]
diff --git a/.github/workflows/deploy-dev.yaml b/.github/workflows/deploy-dev.yaml
index 32ce85b8..7be3d732 100644
--- a/.github/workflows/deploy-dev.yaml
+++ b/.github/workflows/deploy-dev.yaml
@@ -155,7 +155,7 @@ jobs:
api-e2e:
name: API E2E against DEV
needs: build-and-deploy
- runs-on: [self-hosted, m3-ultra]
+ runs-on: [self-hosted, zkcoins-node]
timeout-minutes: 30
env:
RUSTC_WRAPPER: sccache
diff --git a/.github/workflows/deploy-prd.yaml b/.github/workflows/deploy-prd.yaml
index 4ad217a5..5d93944e 100644
--- a/.github/workflows/deploy-prd.yaml
+++ b/.github/workflows/deploy-prd.yaml
@@ -109,7 +109,7 @@ jobs:
api-e2e:
name: API E2E against PRD (non-mutating subset)
needs: build-and-deploy
- runs-on: [self-hosted, m3-ultra]
+ runs-on: [self-hosted, zkcoins-node]
timeout-minutes: 30
env:
RUSTC_WRAPPER: sccache
diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml
new file mode 100644
index 00000000..899b7645
--- /dev/null
+++ b/.github/workflows/pull-request.yaml
@@ -0,0 +1,543 @@
+name: CI
+
+# Live workflow path while `.github/workflows/ci.yaml` is
+# `disabled_manually` on zk-coins/node. Same jobs and the same
+# draft-start labels (`ci` / `ci:full`). Re-enable `ci.yaml` and
+# delete this file in one follow-up if that disable is lifted.
+
+on:
+ # `workflow_dispatch` stays available: a run can still be started by
+ # hand from the Actions tab when a specific answer is wanted.
+ workflow_dispatch:
+
+ # CI runs on every pull request regardless of target branch. This
+ # makes the default safe for stacked PRs (PR-A → PR-B → PR-C where
+ # each PR's base is the previous PR's branch) and any other workflow
+ # that opens a PR against a non-`develop` branch — previously such
+ # PRs were silently skipped because `branches: [develop]` filtered
+ # them out, and the only fix was to hand-edit ci.yaml on each new
+ # feature stack. Letting every PR trigger CI matches what most
+ # repos default to. The heavy M3 Ultra gate (`test-and-coverage`)
+ # now runs on every non-draft PR (CI endgame / G16 — see
+ # `.github/coverage-baseline.md`); drafts still skip via each
+ # job's `if:` guard.
+ #
+ # `push: develop` is intentionally absent. Every commit reaching
+ # `develop` is already covered by the open Release PR (`Release:
+ # develop -> main`, created by auto-release-pr.yaml) — that PR's
+ # `synchronize` event runs CI on the new HEAD, and the heavy gate
+ # runs because the Release PR is non-draft. Adding
+ # `on: push: branches: [develop]` would queue a second workflow
+ # instance on the same SHA, doubling self-hosted-runner load on a
+ # check the Release PR's `synchronize` already provides.
+ # (Under the PR-number grouping in the concurrency block below the
+ # two runs would land in DIFFERENT groups — push keyed by
+ # `refs/heads/develop`, PR keyed by the Release PR's number — so
+ # the block would not deduplicate them.)
+ #
+ # Drafts skip the jobs unless they carry `ci` or `ci:full`. That
+ # is how CI starts on a draft — never by flipping ready-for-review
+ # just to wake the suite. `labeled` / `unlabeled` re-fire so adding
+ # `ci` / `ci:full` on a draft actually starts the jobs.
+ # `ready_for_review` still fires when a finished PR leaves draft.
+ pull_request:
+ types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
+
+concurrency:
+ # Group by PR number so a new push to the same PR cancels the
+ # in-flight Heavy run on the outdated commit. The self-hosted
+ # M3 Ultra runner pool is shared with every other open PR —
+ # letting an obsolete 60-90-min run finish wastes a slot another
+ # PR could use. Grouping by SHA (the previous approach) put every
+ # commit in its own group, so `cancel-in-progress: true` never
+ # fired and back-to-back pushes queued sequentially.
+ # Falls back to `github.ref` for push/dispatch events (where there
+ # is no `pull_request.number`), so e.g. a `workflow_dispatch` on
+ # the same ref serializes too.
+ #
+ # Label events (`labeled` / `unlabeled`) get their own isolated
+ # group keyed by `run_id`, so toggling a label on a PR does NOT
+ # cancel an in-flight Heavy run on the same PR — most label
+ # toggles are unrelated (`bug`, `priority/*`, …) and killing the
+ # Heavy run for them would be a footgun. Trade-off: a label toggle
+ # mid-run does NOT auto-stop a Heavy run that is already
+ # executing; cancel it manually with `gh run cancel` if you really
+ # need to free an agent.
+ group: >-
+ ${{
+ (github.event.action == 'labeled' || github.event.action == 'unlabeled')
+ && format('ci-{0}-label-{1}', github.workflow, github.run_id)
+ || format('ci-{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)
+ }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+env:
+ CARGO_TERM_COLOR: always
+
+# Job topology — a two-tier test-gating model:
+#
+# * Tier 1 — `lint-and-build` — GitHub-hosted Linux, the DEFAULT.
+# Runs on every non-draft PR (no push trigger; see on: above). Catches cross-platform
+# compile bitrot and lint regressions cheaply. Runs in PARALLEL with
+# the heavy gate below — it does not gate it via `needs:`. Each job
+# carries its own draft `if:` guard, so a lint failure does not
+# block the heavy gate from starting (deliberate: parallel
+# feedback. Trade-off: on a lint failure the m3-ultra runner time
+# is spent regardless).
+#
+# * Tier 2 — `test-and-coverage` — the authoritative test + coverage
+# gate. Runs on every non-draft PR (CI endgame / G16 expansion —
+# previously label-gated behind `ci:full`; see
+# `.github/coverage-baseline.md`). Single heavy job on the shared
+# self-hosted M3 Ultra runner pool. It runs the FULL node + shared
+# nextest suite under llvm-cov instrumentation: the Postgres
+# `db_tests`, the Plonky2-heavy mint/send/receive prover flows, the
+# measured coverage floor, plus the release-mode prover package and
+# the four `#[ignore]` prove flows (Task #9), all in one job.
+# Drafts skip unless they carry `ci` or `ci:full`.
+#
+# There is no third "subset" tier: a test either runs in the default
+# Lint & Build (compile/lint) or in the heavy gate (the full suite).
+# The previous narrow per-area subset jobs (and their per-area opt-in
+# labels) were removed — the heavy gate is a strict superset of
+# everything they selected, so they added a maintenance burden
+# (filter drift) without extending coverage.
+#
+# Why test + coverage are merged into one job: the previous topology
+# had a `node-tests` job and a separate `coverage` job, both
+# running the SAME nextest suite (`coverage` simply wrapped nextest
+# in `cargo llvm-cov nextest`). That doubled wall-clock and m3-ultra
+# agent usage on every Release PR for no signal benefit — llvm-cov
+# under nextest produces both test execution AND coverage data in a
+# single binary run. Merging them keeps the measured coverage floor
+# intact (same ignore regex as baseline, same `not binary(api_remote)`
+# exclusion) while running the heavy suite once per PR.
+#
+# The documented hardware target is the M3 Ultra (CONTRIBUTING.md
+# § "Hardware target"). On `ubuntu-latest` the
+# full suite repeatedly hit the 75-min timeout (issue #30); on the
+# M3 Ultra it is ~60-90 min for a Rust change. Moving the gate
+# into CI rather than the developer's laptop unblocks the developer
+# on push (issue #40).
+#
+# Runner ops: see scripts/ci-runner/README.md.
+jobs:
+ lint-and-build:
+ name: Lint & Build
+ # Skip drafts that have no CI-start label. `ci` and `ci:full`
+ # start this job on a draft (same suite as a ready PR). Ready
+ # is not a CI switch.
+ if: >
+ github.event_name != 'pull_request' ||
+ github.event.pull_request.draft == false ||
+ contains(toJson(github.event.pull_request.labels.*.name), '"ci"') ||
+ contains(toJson(github.event.pull_request.labels.*.name), '"ci:full"')
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # The repo pins its toolchain in `rust-toolchain` (a dated nightly, with
+ # `rustfmt` and `clippy` in `components`) and every other job uses it.
+ # This step used to install 1.81.0 explicitly and export
+ # `RUSTUP_TOOLCHAIN`, which overrides the file — so formatting and lints
+ # were checked on a toolchain seventeen releases older than the one that
+ # builds and tests the same tree. That is not a conservative choice, it
+ # is a different compiler: a clippy suggestion can name an API the build
+ # toolchain has and the lint toolchain does not, and a lint that only
+ # exists in one of them is either invisible or unfixable. Installing the
+ # pinned toolchain instead keeps a single pin with nothing to drift.
+ - name: Install the pinned toolchain (rust-toolchain)
+ run: |
+ rustup show active-toolchain || rustup toolchain install
+ cargo --version
+ cargo fmt --version
+ cargo clippy --version
+
+ - name: Cache cargo registry and build
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ target
+ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-cargo-
+
+ # kernel-proto/build.rs invokes `protoc` (via tonic-build / prost-build)
+ # to compile the kernel.v1 contract. ubuntu-latest ships no
+ # protobuf-compiler by default — without this step, fmt is fine but
+ # clippy/build fail with "Could not find `protoc`".
+ - name: Install protoc (kernel-proto build.rs)
+ run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
+
+ - name: Check formatting
+ run: cargo fmt --all --check
+
+ # `--all-targets` on every clippy step: without it clippy lints the
+ # library targets only, so tests, benches and feature-gated fixture
+ # modules — several thousand lines that decide whether a green run
+ # means anything — were never linted at all. Two real lints were
+ # hiding in `shared/src/spec_v1/nflog_boundary.rs`, which is behind
+ # the `test-fixtures` feature and therefore invisible to a plain
+ # `-p shared` run.
+ - name: Run clippy (node + shared, MVP feature set)
+ run: cargo clippy -p node -p shared --all-targets -- -D warnings
+
+ - name: Run clippy (node, all features)
+ run: cargo clippy -p node --all-features --all-targets -- -D warnings
+
+ - name: Run clippy (program + prover)
+ run: cargo clippy -p zkcoins-program-plonky2 -p zkcoins-prover-plonky2 --all-targets -- -D warnings
+
+ # Issue #84: chain-tip advance and publisher commit→reveal waits must
+ # be event-driven (bitcoind ZMQ / block signals), not silent polls.
+ # The deleted Esplora WS scanner modules are gone; the live hotpaths
+ # are the v1 bitcoind scan loop in `main.rs` and `publisher.rs`.
+ # The grep fails the build if a `tokio::time::{sleep,sleep_until,
+ # interval}` or `std::thread::sleep` call appears there without the
+ # same-line opt-out marker. See CONTRIBUTING.md § "No polling —
+ # events only" for `scanner-polling-ok:` and the rationale for each
+ # grandfathered occurrence. The marker is a plain comment token
+ # (not an `#[allow(...)]` attribute) so contributors cannot mistake
+ # it for a real lint suppression (issue #84 round-4 MINOR 4).
+ # Follow-up: replace main.rs scan_to_tip idle sleep with bitcoind
+ # block-signal subscription (event-driven tip advance).
+ - name: Forbid polling patterns in scanner/publisher
+ run: |
+ set -e
+ FOUND=$(grep -rEn 'tokio::time::(sleep|sleep_until|interval)|std::thread::sleep' node/src/main.rs node/src/publisher.rs 2>/dev/null | grep -v 'scanner-polling-ok:' || true)
+ if [ -n "$FOUND" ]; then
+ echo "::error::Polling pattern (tokio::time::sleep|sleep_until|interval or std::thread::sleep) detected in event-driven hot paths. See issue #84."
+ echo "$FOUND"
+ exit 1
+ fi
+ echo "Scanner/publisher polling check: OK"
+
+ - name: Build node (MVP feature set — the DEV + PRD image)
+ run: cargo build -p node
+
+ - name: Build node (all features — self-host opt-in build)
+ run: cargo build -p node --all-features
+
+ test-and-coverage:
+ name: Tests + Coverage Gate (M3 Ultra, measured coverage floor)
+ # Authoritative heavy gate: runs the full nextest suite under
+ # llvm-cov instrumentation, producing both test execution AND
+ # coverage data in a single binary run. Replaces the previous
+ # `node-tests` + `coverage` pair (the two jobs ran the same
+ # nextest suite — see the file header for the merge rationale).
+ # Floor values and ignore rules: `.github/coverage-baseline.md`.
+ #
+ # Same start rule as `lint-and-build`: every non-draft PR, plus
+ # drafts that carry `ci` or `ci:full`. `ci:full` is no longer a
+ # scope gate — it is only a start-on-draft alias.
+ if: >
+ github.event_name != 'pull_request' ||
+ github.event.pull_request.draft == false ||
+ contains(toJson(github.event.pull_request.labels.*.name), '"ci"') ||
+ contains(toJson(github.event.pull_request.labels.*.name), '"ci:full"')
+ # The registered self-hosted M3 Ultra machines advertise
+ # the `zkcoins-node` pool label, not `m3-ultra` — the old `m3-ultra`
+ # requirement matched no online runner, so this gate queued forever. Target
+ # the label the runners actually carry.
+ runs-on: [self-hosted, zkcoins-node]
+ # 180 min: llvm-cov nextest (~60-90 min) + release prover package
+ # + four `#[ignore]` prove flows (a representative multi-input
+ # send prove alone is ~290 s locally) + circuit-digest verify.
+ # Was 120 min when the job stopped at coverage + digests only.
+ timeout-minutes: 180
+ env:
+ # All three chain-shaping env vars are required by the node
+ # bootstrap (see `lib::build_network_config_from_env`). CI uses
+ # `127.0.0.1:1` endpoints so any test that exercises the commit
+ # pipeline / scanner WS fails fast instead of reaching a public
+ # third-party host (a previous Mutinynet-flavoured silent
+ # fallback used to add >60 s per test).
+ IS_MAINNET: "false"
+ ESPLORA_URL: http://127.0.0.1:1/api
+ ESPLORA_WS_URL: ws://127.0.0.1:1/api/v1/ws
+ # `USERNAME_DOMAIN` is required by the node bootstrap (no
+ # default — see node/src/main.rs and issue #95). The test value
+ # is irrelevant for the `info_returns_*` assertions (they only
+ # check non-empty + shape).
+ USERNAME_DOMAIN: test.zkcoins.local
+ # `PUBLISHER_KEY` is required on every network (no default —
+ # see `node/src/lib.rs`); the value is a syntactically valid
+ # 32-byte hex placeholder, NOT a secret. MUST match
+ # `node/src/router_tests.rs` — the test mocks derive the
+ # wiremock'd publisher address from this key. The previous
+ # `1234567890abcdef…` fallback was a publicly-known test key that
+ # drainer bots swept within minutes of any on-chain top-up; the
+ # fallback was removed network-wide. The `0000…0001` value here
+ # is chosen so a future grep for the burned `1234…` key returns
+ # empty across the repo + CI config; it MUST NEVER be reused on
+ # any chain that holds value.
+ PUBLISHER_KEY: "0000000000000000000000000000000000000000000000000000000000000001"
+ # The full suite includes the `db_tests`, which use the
+ # `testcontainers` crate to spin up a real Postgres 17 per test
+ # against the local Docker daemon. The self-hosted runner runs
+ # Colima (not Docker Desktop), whose socket lives under the
+ # runner user's home directory; `DOCKER_HOST` is set in a step
+ # below so the Colima socket path resolves from `$HOME` at
+ # runtime.
+ # `sccache` wraps `rustc` and caches compiled crates across CI
+ # runs. The M3 Ultra runner agents are self-hosted, so the cache
+ # lives on local disk and survives between jobs.
+ RUSTC_WRAPPER: sccache
+ # Bump the cache cap above sccache's 10-GiB default. The cache is
+ # user-level (~/Library/Caches/Mozilla.sccache) and shared by
+ # every m3-ultra agent on the host; with 3+ parallel agents the
+ # 10-GiB default thrashed (writes from one agent evicted hits
+ # another had not consumed yet). 50 GiB fits the current working
+ # set with room to grow; the host has >600 GiB free disk. The
+ # server only reads SCCACHE_CACHE_SIZE at start, so the install
+ # step below restarts it when the running cap differs.
+ SCCACHE_CACHE_SIZE: "50G"
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Prepend ~/.cargo/bin to PATH (use rustup proxy, not Homebrew Rust)
+ run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
+
+ # The `testcontainers` workload runs in a dedicated, resource-
+ # capped Colima profile (`ci`) instead of the host's default
+ # Docker profile. Runner hosts may share their default profile
+ # with unrelated workloads; a separate profile guarantees the
+ # CI Postgres containers can never contend with (or be starved
+ # by) anything else on the host. The profile is created once per
+ # host (see the runner provisioning docs); this step only boots
+ # it if it is not already running. `--activate=false` keeps the
+ # host's global Docker context untouched — the job talks to the
+ # profile exclusively via the explicit DOCKER_HOST below.
+ - name: Ensure dedicated CI Docker profile is running
+ run: colima status ci >/dev/null 2>&1 || colima start ci --activate=false
+
+ # Point `testcontainers` at the `ci` profile's socket (see the
+ # `DOCKER_HOST` comment in the job env block above). Set in a
+ # step so the path resolves from `$HOME` at runtime instead of
+ # being hard-coded.
+ - name: Set DOCKER_HOST for CI Colima profile socket
+ run: echo "DOCKER_HOST=unix://$HOME/.colima/ci/docker.sock" >> "$GITHUB_ENV"
+
+ # `sccache` (compile cache) and `cargo-nextest` (test runner)
+ # are installed once per runner via Homebrew. Idempotent: no-op
+ # on a warm runner where both tools already exist. If a server is
+ # already running with a different cap than the requested
+ # SCCACHE_CACHE_SIZE, stop it so the next --start-server picks up
+ # the new env value; the on-disk cache files survive the restart.
+ - name: Ensure sccache + cargo-nextest + protoc are installed
+ run: |
+ command -v sccache >/dev/null || brew install sccache
+ command -v cargo-nextest >/dev/null || brew install cargo-nextest
+ # kernel-proto/build.rs invokes protoc; the self-hosted runner has
+ # no protobuf-compiler unless we install it (idempotent, like the
+ # tools above). The ubuntu lint job installs it via apt separately.
+ command -v protoc >/dev/null || brew install protobuf
+ protoc --version
+ if ! sccache --show-stats 2>/dev/null | grep -qE "Max cache size +50 GiB"; then
+ sccache --stop-server >/dev/null 2>&1 || true
+ fi
+ sccache --start-server >/dev/null 2>&1 || true
+ sccache --show-stats
+
+ # The full suite's `db_tests` use testcontainers to spin up a
+ # real Postgres 17 per test, so Docker (via Colima) must be
+ # reachable on PATH. Fail fast with a readable error if it ever
+ # goes away, instead of letting the suite die minutes into the
+ # run with a hard-to-read bollard error.
+ - name: Verify Docker is reachable (testcontainers dependency)
+ run: docker info > /dev/null
+
+ # Host-wide proving lease: `compliance_circuit` refuses to
+ # build without ZKCOINS_PROVER_LEASE_PATH. The flock file lives
+ # under $HOME so concurrent self-hosted agents on the same host
+ # serialise the RAM-heavy circuit (a job-local RUNNER_TEMP path
+ # would not). The file is created on first open.
+ - name: Point the proving lease at the host-wide lock file
+ run: echo "ZKCOINS_PROVER_LEASE_PATH=${HOME}/zkcoins-prover.lease" >> "$GITHUB_ENV"
+
+ # `cargo llvm-cov nextest` is the nextest-aware coverage
+ # subcommand: collects llvm-cov data while driving the suite
+ # through nextest, so the measured coverage floor and the test
+ # execution share a single binary run. This is the merge of
+ # the previous `node-tests` + `coverage` pair — the previous
+ # `node-tests` job ran the same nextest invocation without the
+ # `cargo llvm-cov` wrapper, which produced no extra signal.
+ #
+ # Scope: `-p node -p shared --all-features`. Production modules
+ # (publisher, runtime, flow, job_dispatcher, scanners, shared)
+ # are measured. Legitimate ignores only: test infra + crate
+ # entrypoints + Plonky2 circuit packages. Floor integers and
+ # full justification: `.github/coverage-baseline.md`.
+ #
+ # The `api_remote` integration test (node/tests/api_remote.rs)
+ # is excluded: it targets the live DEV node and belongs in
+ # the post-deploy `api-e2e` job in deploy-dev.yaml, not the
+ # hermetic gate. The coverage scope is measured by the rest of
+ # the suite, which covers the in-process axum handlers via
+ # oneshot().
+ - name: Run llvm-cov nextest (measured coverage floor)
+ # Activate `coverage_nightly` only here: llvm-cov does not
+ # auto-set the cfg, and without it `coverage(off)` is inert.
+ # Later prove steps must not inherit it — the cfg also
+ # compiles `spawn_coverage_flush_signal_handler`, which
+ # references `__llvm_profile_write_file` that only the
+ # llvm-cov profiler runtime provides.
+ env:
+ RUSTFLAGS: "--cfg coverage_nightly"
+ # `--test-threads=8` (issue #181 Opt A): the heavy gate is
+ # the largest wall consumer on M3 Ultra (~60-90 min at
+ # --test-threads=1). 8 outer threads × Rayon-pinned cores
+ # exploits the runner's 24 cores without over-subscribing —
+ # Plonky2 prove tests already saturate Rayon internally.
+ # Per-test schema isolation (#182) + cross-process file lock
+ # around the shared container (`test_db::init_shared_pg`)
+ # make the suite parallel-safe under llvm-cov.
+ #
+ # Floor: integer under measured totals (75.20% lines /
+ # 75.94% functions). See `.github/coverage-baseline.md`.
+ run: |
+ cargo llvm-cov nextest --release -p node -p shared --all-features --show-missing-lines \
+ --ignore-filename-regex '_tests\.rs$|test_db\.rs$|bin/.*\.rs$|main\.rs$|lib\.rs$|program-plonky2/|script-plonky2/' \
+ --fail-under-lines 75 \
+ --fail-under-functions 75 \
+ --test-threads 8 \
+ -E 'not binary(api_remote)'
+
+ # Task #9: release-mode prover package suite. These prove tests
+ # are multi-minute and never ran in any gate before; the M3 Ultra
+ # job is the right home (GitHub-hosted `lint-and-build` would
+ # time out). Local verification of a representative multi-input
+ # send prove in this package: ~290 s, green.
+ - name: Run zkcoins-prover-plonky2 release tests
+ run: cargo nextest run -p zkcoins-prover-plonky2 --release
+
+ # `#[ignore]` prove flows in node+shared that the llvm-cov
+ # nextest step never selects. Real Plonky2 proves — serial
+ # (`--test-threads 1`) because the host-wide proving lease
+ # allows one C residency; parallel waiters hit the 1800 s
+ # flock timeout. Same `api_remote` exclusion as llvm-cov
+ # (that binary talks to the live DEV node).
+ - name: Run ignored prove flows (node + shared)
+ run: >
+ cargo nextest run -p node -p shared --all-features --release
+ --run-ignored ignored-only
+ --test-threads 1
+ -E 'not binary(api_remote)'
+
+ # §1.7.9 circuit-digest generator: builds real C / C_balance for every
+ # network and verifies the committed generated_circuit_digests.txt
+ # matches. Multi-minute; kept out of default cargo test via #[ignore],
+ # but a test that cannot run cannot fail — this heavy job is the one
+ # that actually runs it. Does NOT set REGEN_CIRCUIT_DIGESTS
+ # (verify-only; no file rewrite).
+ - name: Verify live circuit digests vs committed generated_circuit_digests.txt
+ run: |
+ cargo test -p zkcoins-prover-plonky2 --release --test generated_circuit_digests_test \
+ generate_circuit_digests -- --ignored --nocapture
+
+ # 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 still below 100% line / function (gap list; the gate
+ # itself fails under the measured floor — see baseline).
+ # `--show-missing-lines` on the gate step sometimes elides this
+ # section depending on the llvm-cov build, so this step makes
+ # the detail deterministic: whenever the gate fails, the
+ # operator sees which file/line/function is uncovered without
+ # having to reproduce locally.
+ - name: Show missing coverage on gate failure
+ if: failure()
+ run: |
+ IGNORE='_tests\.rs$|test_db\.rs$|bin/.*\.rs$|main\.rs$|lib\.rs$|program-plonky2/|script-plonky2/'
+
+ echo "--- llvm-cov report: --show-missing-lines (text) ---"
+ cargo llvm-cov report --release --show-missing-lines \
+ --ignore-filename-regex "$IGNORE" || true
+
+ echo "--- llvm-cov report: per-file json (filter < 100%) ---"
+ cargo llvm-cov report --release --json \
+ --ignore-filename-regex "$IGNORE" \
+ | 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)"
+
+ # Per-function coverage list: emits one line per uncovered
+ # function with file + name + line so the operator sees the
+ # exact `pub fn foo at router.rs:1234` without having to
+ # cross-reference the line ranges manually.
+ echo "--- llvm-cov report: uncovered functions (per-symbol) ---"
+ cargo llvm-cov report --release --json \
+ --ignore-filename-regex "$IGNORE" \
+ | jq -r '.data[0].functions[]
+ | select(.count == 0)
+ | "\(.filenames[0]):\(.regions[0][0])\t\(.name)"' \
+ | sort -u || echo "(per-function extraction failed)"
+
+ # Full HTML report — uploaded as an artifact below so the
+ # operator can browse the per-line coverage in a browser
+ # without re-running llvm-cov locally (heavy gate is
+ # ~50 min on M3 Ultra).
+ echo "--- llvm-cov report: generating HTML for artifact ---"
+ cargo llvm-cov report --release --html \
+ --output-dir target/llvm-cov-html \
+ --ignore-filename-regex "$IGNORE" \
+ || echo "(HTML generation failed)"
+
+ - name: Upload coverage HTML report on gate failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: llvm-cov-html-${{ github.run_id }}-${{ github.run_attempt }}
+ path: target/llvm-cov-html
+ if-no-files-found: warn
+ retention-days: 14
+
+ # Tear down the shared test container created by
+ # `test_db::setup_pool` via testcontainers' `ReuseDirective::
+ # Always` (see `node/src/test_db.rs`). The reuse flag tells
+ # testcontainers NOT to drop the container at process exit so
+ # every `cargo nextest` test process can attach to the same
+ # daemon-side container — but that means nobody removes it
+ # either. Always-on cleanup so a stale container from one PR run
+ # cannot bleed into the next on the same self-hosted runner.
+ - name: Tear down shared test Postgres container
+ if: always()
+ run: docker rm -f zkcoins-test-shared-pg 2>/dev/null || true
+
+ - name: sccache stats (post-build)
+ if: always()
+ run: sccache --show-stats
+
+ # Telegram alert on workflow failure for the heavy gate. Modelled
+ # as a separate job (not an inline step) so job-level failures —
+ # timeout, OOM, runner crash — still fire the alert. `if: failure()`
+ # evaluates against the whole `needs:` group: any listed job
+ # transitioning to `failure` triggers it, while skipped jobs
+ # (drafts without ci / ci:full) and manual cancellation stay silent.
+ notify-failure:
+ name: Telegram alert on failure
+ needs: [lint-and-build, test-and-coverage]
+ if: failure()
+ runs-on: ubuntu-latest
+ steps:
+ - name: Send Telegram alert
+ env:
+ TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
+ TG_CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
+ run: |
+ TEXT=$'❌ '"${{ github.workflow }}"$' failed\nRepo: '"${{ github.repository }}"$'\nBranch: '"${{ github.ref_name }}"$'\nRun: '"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+ curl -sS -X POST "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
+ --data-urlencode "chat_id=${TG_CHAT}" \
+ --data-urlencode "text=${TEXT}" \
+ -d "parse_mode=HTML" \
+ -d "disable_web_page_preview=true"
diff --git a/.gitignore b/.gitignore
index 07d8e1cb..3ccd7fe1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,11 @@ target/
# accidentally-tracked tmp file
.tmp
+
+# rustc internal-compiler-error dumps (written into the workspace root on an
+# ICE; never part of the tree).
+rustc-ice-*.txt
+
+# Host-mounted LLVM profiles and derived E2E coverage reports.
+deploy/local-e2e/coverage-data/
+*.profraw
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 444d24b5..97c77403 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -3,7 +3,7 @@
This guide covers how to set up, build, test, and ship changes to the zkCoins
backend. It is intentionally limited to **developer setup, coding standards, and
the PR flow** — protocol design, roadmap, and migration research live in the
-[docs site](https://docs.zkcoins.app) and the
+[docs site](https://docs.zkcoins.com) and the
[research repo](https://github.com/zk-coins/research).
## Trust model — run your own node
@@ -22,20 +22,44 @@ When in doubt about whether a feature belongs in the wallet, SDK, or node: if it
## Quick Start
+A bare `cargo run -p node` is **not** startable: the binary fails closed without
+Postgres migrations, kernel gRPC bind, chain-identity ops, Stage-3 v1 pins,
+bitcoind RPC, a verified **BMF1** bootstrap manifest
+(`ZKCOINS_V1_BOOTSTRAP_MANIFEST_PATH`), and related env. Use the local stack.
+
+**Layout prerequisite:** `deploy/local-e2e/up.sh` and `compose.yaml` build the
+public REST edge from the **sibling** checkout `../api` (`build.context: ../api`;
+preflight dies if `../api/Dockerfile` is missing). A node-only clone is not
+enough — clone `api` next to `node` under the same parent directory:
+
```bash
+# Required sibling layout (compose build.context: ../api):
+# /
+# api/ ← https://github.com/zk-coins/api
+# node/ ← this repo
+mkdir -p zk-coins && cd zk-coins
+git clone https://github.com/zk-coins/api.git
git clone https://github.com/zk-coins/node.git
cd node
-USERNAME_DOMAIN=test.zkcoins.local cargo run -p node
-# Node starts on http://0.0.0.0:4242
+# Full unmocked stack (postgres, bitcoind regtest, nostr-relay, node, api):
+# see deploy/local-e2e/README.md and docs/local-stack.md
+cp deploy/local-e2e/env.example.sh deploy/local-e2e/env.local.sh
+# Edit env.local.sh (PUBLISHER_KEY, bootstrap pubkey/priv, params id, …)
+# Generate/sign BMF1 via up.sh / gen_bootstrap_manifest (required at boot)
+bash -c 'set -a && source deploy/local-e2e/env.local.sh && set +a && ./deploy/local-e2e/up.sh'
```
+Kernel gRPC listens on `KERNEL_GRPC_ADDR` (compose publishes **50051**). Residual
+HTTP on `0.0.0.0:4242` is legacy and not the §7.8 surface — public REST is the
+sibling **api** service.
+
## Prerequisites
| Tool | Version | Purpose |
|---|---|---|
| Rust | nightly (pinned via `rust-toolchain`) | Required for Plonky2 (`feature(specialization)`) |
-| Docker | any recent | `db_tests` spin up a `postgres:17` testcontainer |
-| Bitcoin node | — | Blockchain scanning (or use an Esplora-compatible API) |
+| Docker | any recent | `db_tests` spin up a `postgres:17` testcontainer; `deploy/local-e2e` full stack |
+| Bitcoin node | bitcoind (regtest via compose) | Stage-3 NfLog scan + AggregateStateNullifierV3 publish (RPC + cookie) |
## Setup
@@ -75,6 +99,94 @@ forward-only (no `down` migrations in the MVP).
cargo test -p node db -- --test-threads=8
```
+That command is a **subset** — useful and correct for DB work. It is not the
+full `node` + `shared` suite. The line for the full suite is below.
+
+### Running tests
+
+**Full hermetic suite.** CI's authoritative heavy gate (`test-and-coverage` in
+`pull-request.yaml`, mirrored in `ci.yaml`) runs on **every non-draft PR**
+(no `ci:full` label required — see [CI/CD](#cicd)). It drives `node` +
+`shared` under `cargo llvm-cov nextest`,
+then the release-mode prover package and ignored prove flows. Locally, mirror
+the hermetic `node` + `shared` selection with:
+
+```bash
+cargo nextest run -p node -p shared --all-features --test-threads 8 -E 'not binary(api_remote)'
+```
+
+`-E 'not binary(api_remote)'` drops the `api_remote` integration target
+(`node/tests/api_remote.rs`). That suite talks to the live DEV node and does
+not belong in a hermetic run; the CI workflow excludes it with the same
+expression for the same reason (post-deploy coverage lives in
+`deploy-dev.yaml` / `deploy-prd.yaml`).
+
+`cargo nextest` is not a built-in Cargo subcommand. Install it the way the
+self-hosted CI runners do, or from crates.io:
+
+```bash
+brew install cargo-nextest
+# or:
+cargo install cargo-nextest --locked
+```
+
+**Why nextest is required here — not a preference.**
+`stack-policy` records the stack mode as a **process-wide, monotonic claim**
+(`PROCESS_STACK_MODE` via `set_process_stack_mode`): a process must not
+dual-boot Legacy and V1, and a conflicting re-set **panics on purpose**. The
+test-only reset (`clear_process_stack_mode_for_test`) is gated on
+`#[cfg(test)]` of the **defining** crate, so dependents such as `node` cannot
+clear the claim from their own test binaries.
+
+Under plain `cargo test`, every case shares one process. A Legacy case and a
+V1 case collide; the mutex poisons (`PoisonError`), and every later test in
+that process fails — a cascade from a single intentional panic, not a broken
+tree. `cargo nextest` gives each test its own process, so the collision
+cannot occur. That is why the CI gate uses nextest rather than `cargo test`.
+If you run `cargo test -p node -p shared --all-features` and see a large red
+swath, read it as this process-wide claim issue first.
+
+**When `cargo test` is still the right tool.** Targeted subsets remain valid
+and preferred for day-to-day work, for example the DB filter above or a
+single integration binary:
+
+```bash
+cargo test -p node db -- --test-threads=8
+cargo test -p node --test openapi_smoke
+```
+
+The boundary is stack modes: as soon as a run includes cases that claim
+**both** Legacy and V1, it needs nextest (process-per-test isolation).
+Single-mode or non-claiming subsets can stay on `cargo test`.
+
+**Prove path outside `-p node -p shared`.** The recommended local command above
+scopes only to the `node` and `shared` packages. Heavy prove-flow tests live
+in `zkcoins-prover-plonky2` (`script-plonky2/`) and are not selected by that
+run. Include the package explicitly when you need those flows:
+
+```bash
+cargo nextest run -p zkcoins-prover-plonky2 --release
+```
+
+A local run without `zkcoins-prover-plonky2` is **not** a complete verification
+of the prove path. The CI heavy gate **does** run that package in release mode
+after the llvm-cov nextest step (see `.github/workflows/pull-request.yaml`,
+mirrored in `.github/workflows/ci.yaml`).
+
+**`#[ignore]` prove flows.** Several multi-minute prove paths are marked
+`#[ignore]` so the default hermetic nextest stays fast. The CI heavy gate
+runs them explicitly (node + shared) and also verifies live circuit
+digests against the committed file. `--test-threads 1` is required:
+the host-wide proving lease allows one `C` residency, and parallel
+waiters hit the 1800 s flock timeout. `-E 'not binary(api_remote)'`
+is the same live-DEV exclusion as the hermetic llvm-cov step. Locally:
+
+```bash
+cargo nextest run -p node -p shared --all-features --release \
+ --run-ignored ignored-only --test-threads 1 \
+ -E 'not binary(api_remote)'
+```
+
## Code style
### Rust
@@ -113,36 +225,79 @@ let block = fetch_block(hash).unwrap();
### No polling — events only
-Bitcoin / Esplora signals on the node's hot path are **subscribed to, never
-polled**. The scanner consumes block events from the Esplora-compatible
-WebSocket stream (`scanner_ws.rs`, `ESPLORA_WS_URL`); the publisher broadcasts
-commit and reveal transactions back-to-back and never sleeps or polls between
-them. (History: a 30-s tip-poll once gated `/api/mint` and `/api/send`
-visibility by up to a full block-time — issue [#84](https://github.com/zk-coins/node/issues/84).)
+Bitcoin tip advance on the node's hot path should be **event-driven** (bitcoind
+block signals / ZMQ), not a silent sleep-loop. The legacy Esplora WebSocket
+scanner modules are gone; Stage-3 scan is bitcoind RPC via `main.rs`
+(`scan_to_tip`). The publisher still broadcasts commit and reveal back-to-back
+without sleeping between them. (History: a 30-s tip-poll once gated mint/send
+visibility by up to a full block-time — issue
+[#84](https://github.com/zk-coins/node/issues/84).)
CI enforces this with a `grep` step in the `Lint & Build` job
-(`.github/workflows/ci.yaml`):
+(`.github/workflows/pull-request.yaml`, mirrored in `ci.yaml`) over the
+**active** hotpaths:
```bash
grep -rEn 'tokio::time::(sleep|sleep_until|interval)|std::thread::sleep' \
- node/src/scanner.rs node/src/scanner_runtime.rs node/src/scanner_ws.rs \
- node/src/scanner_ws_parse.rs node/src/publisher.rs \
+ node/src/main.rs node/src/publisher.rs \
| grep -v 'scanner-polling-ok:'
```
-Any match without a `scanner-polling-ok:` comment marker on the same line fails
-the build. The marker is the documented per-line opt-out for genuinely justified
-exceptions (today: the 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.
+Any match without a `scanner-polling-ok:` comment marker **on the same line**
+fails the build. The marker is the documented per-line opt-out for genuinely
+justified exceptions. Today the grandfathered case is the v1 `scan_to_tip` idle
+backoff in `main.rs` (and related resume/retry backoffs): bitcoind block-signal
+subscription is **follow-up work**; until then the sleep is an explicit,
+named poll — never a silent one. The same line must explain why the sleep is
+not an unacknowledged tip poll.
### Hardware target
The node targets a single **Mac Studio M3 Ultra** (96 GB unified RAM): all
on-box compute (P/E cores, Apple GPU via Metal, Neural Engine, AMX), **no
-external GPU/CUDA, no cloud proving services**. Performance budget: warm proof
-≤ 5 s (target ≤ 1 s), cold-start ≤ 30 s, memory peak < 64 GB. If a design
-overshoots the budget, the design changes — we do not add external hardware.
+external GPU/CUDA, no cloud proving services**. If a design overshoots the
+budget, the design changes — we do not add external hardware.
+
+The v1 circuit `C` (BIP-340 + S2C verified in-circuit) is, by design, a
+~90–100 GiB build/prove memory peak — this is the deliberate v1 hardware
+reality on the 96 GB target, not a regression against a legacy cap. The
+closest real measurement on file, [`docs/build-report.md`](./docs/build-report.md)
+(a 128 GB Apple M5 Max, not the M3 Ultra target — not a cross-host capacity
+claim), puts circuit-build peak RSS at 88.3 GiB and the full
+circuit-test-suite peak at 92.5 GiB, in the same ~90 GiB band.
+
+The old `<64 GB` / `≤ 5 s` warm / `≤ 30 s` cold-start budget below was the
+ROADMAP step-9 target for the legacy Poseidon-only circuit
+(`LEGACY_BUDGET_PEAK_RSS_KB` = 64 GiB, `LEGACY_BUDGET_WARM_PROVE_MS` = 5 000,
+`LEGACY_BUDGET_COLD_START_MS` = 30 000 in `node/src/r2_budgets.rs`). Applying
+those numbers to a healthy v1 prove produces false reds — the failure mode
+`r2_budgets.rs` exists to prevent. There is no equivalent fixed v1 cap in
+this document: `budgets_for_mode` under `ProverMode::V1` derives
+warm-prove / cold-start / peak-RSS budgets from stored measurement samples
+(`derive_budget_from_samples`, `V1_CALIBRATION`, `V1_WARM_SAMPLES_MS` /
+`V1_COLD_SAMPLES_MS` / `V1_RSS_SAMPLES_KB`) with a `MIN_SAMPLES_FOR_BUDGET`
+floor and a `BUDGET_HEADROOM_PERCENT` (25 %) headroom over the observed max,
+and refuses loudly (`BudgetUnavailable`) rather than silently falling back
+to the legacy numbers while the sample arrays are empty.
+
+A ~90–100 GiB peak fits the 96 GB target host only because at most one full
+`C` residency is ever resident host-wide, via three mechanisms:
+
+- **Secondary-verifier cache** — `ZKCOINS_VERIFIER_CACHE_ROLE` (default
+ `primary`) decides which process builds `C_balance` and writes the shared
+ verifier cache vs. which process only loads it. `secondary` never builds
+ `C_balance` itself (it still lazily builds the much smaller `C` circuit on
+ first prove). See `VerifierCacheRole` / `verifier_cache_role_from_env`
+ (`node/src/v1/mode.rs`) and `script-plonky2/src/verifier_cache.rs`.
+- **Host-wide proving lease** — `script-plonky2/src/prover_lease.rs`
+ serialises the memory-heavy prover across processes with an flock-backed
+ lease file: acquisition precedes every fresh circuit build, so two `C`
+ builds are never resident on the same host at once.
+- **Drop-when-idle** — `C` / `C_balance` circuit slots are reference-counted
+ and evicted, once no caller still holds a reference and the proving
+ lease's idle TTL has elapsed, by the idle reaper (`try_evict_slot` /
+ `try_evict_all_unreferenced`, `script-plonky2/src/prover_bridge.rs`), so
+ an idle process does not pin the peak in memory indefinitely.
## Project structure
@@ -161,7 +316,7 @@ node/
When working inside `program-plonky2/`, read
[`program-plonky2/CONTRIBUTING.md`](./program-plonky2/CONTRIBUTING.md) for the
crate's toolchain, coverage gate, and gadget-authoring pattern. Protocol-level
-context lives in the spec at [docs.zkcoins.app/specification](https://docs.zkcoins.app/specification).
+context lives in the spec at [docs.zkcoins.com/specification](https://docs.zkcoins.com/specification).
## REST API & OpenAPI
@@ -189,45 +344,84 @@ Adding an endpoint:
The node reads configuration **exclusively from environment variables** (no
`.env` is loaded). Required variables panic the bootstrap on startup if unset —
-there is no silent fallback.
+there is no silent fallback. The authoritative full set for a running stack is
+`deploy/local-e2e/env.example.sh` and [`docs/local-stack.md`](./docs/local-stack.md).
+A non-exhaustive subset:
| Variable | Default | Description |
|---|---|---|
| `DATABASE_URL` | _(required)_ | Postgres connection string for the state layer. |
+| `KERNEL_GRPC_ADDR` | _(required)_ | Kernel gRPC bind address (no default host/port). |
| `PUBLISHER_KEY` | _(required)_ | 32-byte hex private key for Taproot inscription publishing. Required on every network. **Never commit a real key**; generate via `openssl rand -hex 32`, source deployed values from a secret manager. |
-| `USERNAME_DOMAIN` | _(required)_ | External hostname returned by `/api/info`. |
+| `USERNAME_DOMAIN` | _(required)_ | External hostname returned by residual `/api/info`. |
| `IS_MAINNET` | _(required)_ | Exact string `true` or `false`; any other value panics. |
-| `ESPLORA_URL` | _(required)_ | HTTP Esplora endpoint (electrs or compatible). |
-| `ESPLORA_WS_URL` | _(required)_ | Esplora-compatible WebSocket endpoint consumed by `scanner_ws` (issue #84). |
-| `NETWORK_NAME` | derived | Human-readable name returned by `/api/info`. Cosmetic. |
+| `ZKCOINS_V1_SHADOW` | _(required for Stage 3)_ | Must be `1` / on; Stage-3 binary refuses the legacy dual stack. |
+| `ZKCOINS_NETWORK` / activation / circuit digests | _(required)_ | §3.6 pins — see `docs/local-stack.md`. |
+| `ZKCOINS_V1_BITCOIND_RPC_URL` / cookie / wallet | _(required)_ | bitcoind RPC for scan + publish (not Esplora WS). |
+| `ZKCOINS_V1_BOOTSTRAP_MANIFEST_PATH` | _(required when engine present)_ | Path to a verified **BMF1** artifact; `ChainIdentity` install fails closed without it. |
+| `ESPLORA_URL` | residual boot pin | HTTP Esplora endpoint (legacy residual; Stage-3 scan is bitcoind). |
| `PROOFS_DIR` | `./proofs` | Directory for per-proof bincode files. |
| `ZKCOINS_SKIP_BOOTSTRAP_WARMUP` | `false` | When `1`/`true`, skip the Plonky2 prover warmup so `/health/ready` returns 200 immediately. Used by smoke tests; leave unset in production. |
| `RUST_LOG` | `info` | Log level. |
-```bash
-export DATABASE_URL="postgresql://postgres:dev@localhost:5432/postgres"
-export PUBLISHER_KEY="$(openssl rand -hex 32)"
-export USERNAME_DOMAIN="test.zkcoins.local"
-export IS_MAINNET="false"
-export ESPLORA_URL="http://localhost:3000"
-export ESPLORA_WS_URL="ws://localhost:8999/api/v1/ws"
-cargo run -p node
-```
+Do **not** use a minimal `export … && cargo run -p node` snippet as the
+supported operator path — it will panic on missing pins/manifest. Use
+`deploy/local-e2e/` (or an equivalent full env from `docs/local-stack.md`).
+
+### Bitcoind-finality function restriction (SDR Phase B)
+
+Stage-3 SDR Phase B seals `SelfDeliveryRecordV1` only when first-occurrence
+inclusion + BIP-113 MTP are available. The production path,
+`finalize_due_phase_b_adapter`, uses `BitcoindInclusionMtp` **uniformly on
+every network** (mainnet, testnet, regtest) — there is no per-network branch
+and no wall-clock/tip-hash stand-in in the reachable path:
+
+- `BitcoindInclusionMtp` resolves the nullifier's first-occurrence height via
+ `getblockhash` / `getblockheader`, requires `header.height` to match the
+ looked-up height, requires at least `FINALITY_CONFIRMATIONS` (6, §3.9)
+ confirmations, and requires a present BIP-113 `mediantime`. Any RPC error,
+ height mismatch, insufficient confirmations, or missing `mediantime` is
+ itself fail-closed (`bail!`) — it never falls back to tip/wall-clock.
+- In `finalize_due_phase_b_with_mtp`, a `BitcoindInclusionMtp` failure for a
+ given Phase-A row is caught, logged, and turned into a named
+ `db_sdr::mark_failed(pool, transition_pk, reason)` — no silent skip. A row
+ whose NfLog classification is still `Pending` (not yet a first-occurrence
+ winner) instead returns `Ok(false)` and stays in `awaiting_first_occurrence`
+ for the next scan cycle; that is the normal "not yet due" case, not a
+ failure.
+
+`provisional_inclusion_mtp_for_network` (the old `PROVISIONAL_MTP_MAINNET_REFUSED`
+/ tip-hash-plus-wall-clock stand-in, with its "leaves Phase-A rows open,
+does **not** `mark_failed`" mainnet-refusal semantics) still exists in
+`node/src/v1/sdr.rs` but has no caller outside its own unit tests — it is
+**not** on the production `finalize_due_phase_b_adapter` path described
+above.
+
+See `node/src/v1/sdr.rs` (`BitcoindInclusionMtp`,
+`finalize_due_phase_b_adapter`, `finalize_due_phase_b_with_mtp`).
## Docker
+A single-container `docker run` with only `ESPLORA_URL` / `USERNAME_DOMAIN` is
+**not startable**: the binary fails closed without `DATABASE_URL`,
+`PUBLISHER_KEY`, `KERNEL_GRPC_ADDR`, Stage-3 pins, bitcoind RPC, and a verified
+BMF1 bootstrap manifest. Do not treat a minimal `docker run` as an operator path.
+
+**Supported local stack** (postgres, bitcoind regtest, nostr-relay, node, api):
+
```bash
-docker build -t zkcoins/node .
-docker run -p 4242:4242 --network bitcoin \
- -e ESPLORA_URL=http://electrs-mainnet:3000 \
- -e USERNAME_DOMAIN=zkcoins.app \
- zkcoins/node
+# Full env + compose — see deploy/local-e2e/README.md and docs/local-stack.md
+cp deploy/local-e2e/env.example.sh deploy/local-e2e/env.local.sh
+# Edit env.local.sh (required secrets/pins), then:
+bash -c 'set -a && source deploy/local-e2e/env.local.sh && set +a && ./deploy/local-e2e/up.sh'
```
-Docker builds use nightly Rust auto-installed via the workspace `rust-toolchain`
-— no Succinct toolchain, no zkVM target. The node connects to Bitcoin Core with
-an Esplora-compatible indexer (electrs) over the shared Docker network `bitcoin`;
-the underlying bitcoind needs `txindex=1`, `rest=1`, `server=1`.
+Or the workspace Compose path documented in `docs/local-stack.md`
+(`docker compose up --build` after generating/signing BMF1 and filling env).
+
+Image builds use nightly Rust via the workspace `rust-toolchain` — no Succinct
+toolchain, no zkVM target. Stage-3 scan + publish use **bitcoind RPC** (not
+Esplora WS); residual `ESPLORA_URL` is a boot pin only.
## Git workflow
@@ -240,7 +434,7 @@ the underlying bitcoind needs `txindex=1`, `rest=1`, `server=1`.
| `main` | Production releases, promoted from `develop` | PRD node |
- **Open feature PRs against `staging`** by default — it is the integration buffer where feature branches accumulate before being batched into a single `develop` promotion. (Repo-hygiene/cleanup PRs that target develop-only files may go directly to `develop`; note the reason in the PR body.)
-- **`develop` and `main` are protected** — no direct pushes, no force-pushes, no deletions. `develop` is auto-PR'd from `staging` (`auto-release-pr-staging.yaml`, `ci:full` applied); `main` is auto-PR'd from `develop` (`auto-release-pr.yaml`).
+- **`develop` and `main` are protected** — no direct pushes, no force-pushes, no deletions. `develop` is auto-PR'd from `staging` (`auto-release-pr-staging.yaml`); `main` is auto-PR'd from `develop` (`auto-release-pr.yaml`). Non-draft PRs always get the heavy CI gate. Drafts start the same gate with the `ci` or `ci:full` label.
- **Maintainers merge PRs; agents open them as drafts.** Never force-push, never amend, never `--no-verify` on a real change.
### Commit messages
@@ -261,19 +455,35 @@ wip
| Workflow | Trigger | Action |
|---|---|---|
-| `ci.yaml` — **Lint & Build** | Any ready PR, push to develop | `cargo fmt --check`, clippy (MVP + all-features + program), build, the no-polling grep. Fast GitHub-hosted tier, no label needed. |
-| `ci.yaml` — **Tests + Coverage Gate** | Ready PR with `ci:full` label, push to develop | Full `node` + `shared` nextest suite under `llvm-cov` on the self-hosted M3 Ultra pool, 100% line + function gate. |
+| `pull-request.yaml` (live) / `ci.yaml` — **Lint & Build** | Every non-draft PR, or a draft with the `ci` / `ci:full` label | `cargo fmt --check`, clippy (MVP + all-features + program/prover), build, the no-polling grep over `node/src/main.rs` + `node/src/publisher.rs` (same-line `scanner-polling-ok:` opt-out). Fast GitHub-hosted tier. |
+| `pull-request.yaml` (live) / `ci.yaml` — **Tests + Coverage Gate** | Same start rule as Lint & Build | Full `node` + `shared` nextest under `llvm-cov` on the self-hosted M3 Ultra pool, measured coverage floor (see `.github/coverage-baseline.md`), then release-mode `zkcoins-prover-plonky2`, ignored prove flows (`--run-ignored ignored-only --test-threads 1`, excluding `api_remote`), and circuit-digest verify. |
| `deploy-dev.yaml` | Push to develop | Docker build (ARM64) → `zkcoins/node:beta` → DEV |
| `deploy-prd.yaml` | Push to main | Docker build (ARM64) → `zkcoins/node:latest` → PRD |
-| `auto-release-pr-staging.yaml` | Push to staging | Promote PR (staging → develop), `ci:full` |
-| `auto-release-pr.yaml` | Push to develop | Release PR (develop → main), `ci:full` |
+| `auto-release-pr-staging.yaml` | Push to staging | Promote PR (staging → develop) |
+| `auto-release-pr.yaml` | Push to develop | Release PR (develop → main) |
+
+**Draft PRs skip every CI job unless they carry `ci` or `ci:full`.**
+The live workflow file is `.github/workflows/pull-request.yaml`;
+`.github/workflows/ci.yaml` is the same jobs currently disabled on the
+repo — keep the two files in lockstep. Apply `ci` / `ci:full` to start
+the same suite a ready PR would get; the PR stays a draft.
+Ready-for-review is not a CI switch. Non-draft PRs always run the heavy
+gate (no extra label). After a start, watch CI until green; never
+abandon a red run.
+
+**No-polling gate (Lint & Build).** Matches the workflow step exactly:
+
+```bash
+grep -rEn 'tokio::time::(sleep|sleep_until|interval)|std::thread::sleep' \
+ node/src/main.rs node/src/publisher.rs \
+ | grep -v 'scanner-polling-ok:'
+```
-**Draft PRs skip every `ci.yaml` job** — CI fires once the PR is marked
-ready-for-review. Apply the `ci:full` label when the PR is ready to run against
-the authoritative gate. After push, watch CI until green; never abandon a red run.
+Any hit without a same-line `scanner-polling-ok:` comment fails the build
+(see [No polling — events only](#no-polling--events-only)).
## Related Repos
- [zk-coins/app](https://github.com/zk-coins/app) — Web application (frontend).
-- [zk-coins/docs](https://github.com/zk-coins/docs) — Documentation ([docs.zkcoins.app](https://docs.zkcoins.app)).
+- [zk-coins/docs](https://github.com/zk-coins/docs) — Documentation ([docs.zkcoins.com](https://docs.zkcoins.com)).
- [zk-coins/research](https://github.com/zk-coins/research) — Protocol research, design drafts, upstream repos, paper PDFs.
diff --git a/Cargo.lock b/Cargo.lock
index e5455457..e8c28b74 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,6 +8,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
[[package]]
name = "ahash"
version = "0.8.12"
@@ -255,6 +265,12 @@ dependencies = [
"bitcoin_hashes 0.14.1",
]
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
[[package]]
name = "base64"
version = "0.21.7"
@@ -367,6 +383,30 @@ dependencies = [
"hex-conservative 0.3.2",
]
+[[package]]
+name = "bitcoincore-rpc"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aedd23ae0fd321affb4bbbc36126c6f49a32818dc6b979395d24da8c9d4e80ee"
+dependencies = [
+ "bitcoincore-rpc-json",
+ "jsonrpc",
+ "log",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "bitcoincore-rpc-json"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8909583c5fab98508e80ef73e5592a651c954993dc6b7739963257d19f0e71a"
+dependencies = [
+ "bitcoin",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "bitcoincore-zmq"
version = "1.5.4"
@@ -441,7 +481,7 @@ dependencies = [
"tokio",
"tokio-stream",
"tokio-util",
- "tonic",
+ "tonic 0.14.6",
"tower-service",
"url",
"winapi",
@@ -453,9 +493,9 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad"
dependencies = [
- "prost",
- "prost-types",
- "tonic",
+ "prost 0.14.3",
+ "prost-types 0.14.3",
+ "tonic 0.14.6",
"tonic-prost",
"ureq",
]
@@ -469,7 +509,7 @@ dependencies = [
"base64 0.22.1",
"bollard-buildkit-proto",
"bytes",
- "prost",
+ "prost 0.14.3",
"serde",
"serde_json",
"serde_repr",
@@ -537,6 +577,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+[[package]]
+name = "chacha20"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures 0.2.17",
+]
+
[[package]]
name = "chacha20"
version = "0.10.0"
@@ -548,6 +599,19 @@ dependencies = [
"rand_core 0.10.1",
]
+[[package]]
+name = "chacha20poly1305"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
+dependencies = [
+ "aead",
+ "chacha20 0.9.1",
+ "cipher",
+ "poly1305",
+ "zeroize",
+]
+
[[package]]
name = "chrono"
version = "0.4.44"
@@ -560,6 +624,17 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+ "zeroize",
+]
+
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -732,6 +807,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
+ "rand_core 0.6.4",
"typenum",
]
@@ -890,6 +966,14 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+[[package]]
+name = "downstream-boundary"
+version = "1.1.0"
+dependencies = [
+ "node",
+ "trybuild",
+]
+
[[package]]
name = "dyn-clone"
version = "1.0.20"
@@ -930,6 +1014,15 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "esplora-bound"
+version = "1.1.0"
+dependencies = [
+ "bitcoin",
+ "esplora-client",
+ "stack-policy",
+]
+
[[package]]
name = "esplora-client"
version = "0.11.0"
@@ -1019,6 +1112,12 @@ dependencies = [
"static_assertions",
]
+[[package]]
+name = "fixedbitset"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
+
[[package]]
name = "flate2"
version = "1.1.9"
@@ -1236,6 +1335,12 @@ dependencies = [
"wasip3",
]
+[[package]]
+name = "glob"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
+
[[package]]
name = "h2"
version = "0.3.27"
@@ -1762,6 +1867,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -1814,6 +1928,18 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "jsonrpc"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3662a38d341d77efecb73caf01420cfa5aa63c0253fd7bc05289ef9f6616e1bf"
+dependencies = [
+ "base64 0.13.1",
+ "minreq",
+ "serde",
+ "serde_json",
+]
+
[[package]]
name = "jwalk"
version = "0.8.1"
@@ -1834,6 +1960,15 @@ dependencies = [
"tiny-keccak",
]
+[[package]]
+name = "kernel-proto"
+version = "1.1.0"
+dependencies = [
+ "prost 0.13.5",
+ "tonic 0.13.1",
+ "tonic-build",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2036,6 +2171,12 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "multimap"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
+
[[package]]
name = "native-tls"
version = "0.2.18"
@@ -2060,20 +2201,27 @@ dependencies = [
"anyhow",
"async-stream",
"axum 0.7.9",
+ "base64 0.22.1",
"bincode",
"bitcoin",
"bitcoin_hashes 0.16.0",
+ "bitcoincore-rpc",
"bitcoincore-zmq",
+ "chacha20 0.9.1",
"chrono",
"dashmap",
- "esplora-client",
+ "esplora-bound",
"fs2",
"futures-util",
"hex",
+ "hkdf",
+ "hmac",
"http-body-util",
+ "kernel-proto",
"lazy_static",
"libc",
"mimalloc",
+ "plonky2",
"rand 0.8.6",
"reqwest 0.12.28",
"serde",
@@ -2082,16 +2230,20 @@ dependencies = [
"shared",
"socket2 0.5.10",
"sqlx",
+ "stack-policy",
"sysinfo",
"tempfile",
"testcontainers",
"testcontainers-modules",
"tokio",
"tokio-tungstenite",
+ "tonic 0.13.1",
+ "tonic-types",
"tower",
"tower-http 0.5.2",
"tracing",
"tracing-subscriber",
+ "trybuild",
"utoipa",
"utoipa-swagger-ui",
"uuid",
@@ -2251,6 +2403,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
[[package]]
name = "openssl"
version = "0.10.80"
@@ -2363,6 +2521,16 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "petgraph"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
+dependencies = [
+ "fixedbitset",
+ "indexmap 2.14.0",
+]
+
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -2478,6 +2646,17 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c32c137808ca984ab2458b612b7eb0462d853ee041a3136e83d54b96074c7610"
+[[package]]
+name = "poly1305"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
+dependencies = [
+ "cpufeatures 0.2.17",
+ "opaque-debug",
+ "universal-hash",
+]
+
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -2537,6 +2716,16 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "prost"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
+dependencies = [
+ "bytes",
+ "prost-derive 0.13.5",
+]
+
[[package]]
name = "prost"
version = "0.14.3"
@@ -2544,7 +2733,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes",
- "prost-derive",
+ "prost-derive 0.14.3",
+]
+
+[[package]]
+name = "prost-build"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
+dependencies = [
+ "heck",
+ "itertools 0.14.0",
+ "log",
+ "multimap",
+ "once_cell",
+ "petgraph",
+ "prettyplease",
+ "prost 0.13.5",
+ "prost-types 0.13.5",
+ "regex",
+ "syn 2.0.117",
+ "tempfile",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
+dependencies = [
+ "anyhow",
+ "itertools 0.14.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
]
[[package]]
@@ -2560,13 +2782,22 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "prost-types"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
+dependencies = [
+ "prost 0.13.5",
+]
+
[[package]]
name = "prost-types"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
dependencies = [
- "prost",
+ "prost 0.14.3",
]
[[package]]
@@ -2672,7 +2903,7 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
- "chacha20",
+ "chacha20 0.10.0",
"getrandom 0.4.2",
"rand_core 0.10.1",
]
@@ -3215,6 +3446,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -3294,12 +3534,18 @@ dependencies = [
name = "shared"
version = "1.1.0"
dependencies = [
+ "base64 0.22.1",
+ "bech32",
"bincode",
"bitcoin",
+ "chacha20poly1305",
"hex",
+ "hkdf",
"lazy_static",
+ "plonky2",
"serde",
"sha2",
+ "shared",
"zkcoins-program-plonky2",
]
@@ -3593,6 +3839,10 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+[[package]]
+name = "stack-policy"
+version = "1.1.0"
+
[[package]]
name = "static_assertions"
version = "1.1.0"
@@ -3737,7 +3987,7 @@ dependencies = [
"cfg-expr",
"heck",
"pkg-config",
- "toml",
+ "toml 0.8.23",
"version-compare",
]
@@ -3747,6 +3997,12 @@ version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+[[package]]
+name = "target-triple"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171"
+
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -3760,6 +4016,15 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
[[package]]
name = "testcontainers"
version = "0.27.3"
@@ -4020,11 +4285,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
- "serde_spanned",
- "toml_datetime",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.11",
"toml_edit",
]
+[[package]]
+name = "toml"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde_core",
+ "serde_spanned 1.1.1",
+ "toml_datetime 1.1.1+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 1.0.4",
+]
+
[[package]]
name = "toml_datetime"
version = "0.6.11"
@@ -4034,6 +4314,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
[[package]]
name = "toml_edit"
version = "0.22.27"
@@ -4042,9 +4331,53 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.14.0",
"serde",
- "serde_spanned",
- "toml_datetime",
- "winnow",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.11",
+ "winnow 0.7.15",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+dependencies = [
+ "winnow 1.0.4",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+
+[[package]]
+name = "tonic"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9"
+dependencies = [
+ "async-trait",
+ "axum 0.8.9",
+ "base64 0.22.1",
+ "bytes",
+ "h2 0.4.14",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "http-body-util",
+ "hyper 1.9.0",
+ "hyper-timeout",
+ "hyper-util",
+ "percent-encoding",
+ "pin-project",
+ "prost 0.13.5",
+ "socket2 0.5.10",
+ "tokio",
+ "tokio-stream",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
]
[[package]]
@@ -4076,6 +4409,20 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "tonic-build"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eac6f67be712d12f0b41328db3137e0d0757645d8904b4cb7d51cd9c2279e847"
+dependencies = [
+ "prettyplease",
+ "proc-macro2",
+ "prost-build",
+ "prost-types 0.13.5",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "tonic-prost"
version = "0.14.6"
@@ -4083,8 +4430,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
dependencies = [
"bytes",
- "prost",
- "tonic",
+ "prost 0.14.3",
+ "tonic 0.14.6",
+]
+
+[[package]]
+name = "tonic-types"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07439468da24d5f211d3f3bd7b63665d8f45072804457e838a87414a478e2db8"
+dependencies = [
+ "prost 0.13.5",
+ "prost-types 0.13.5",
+ "tonic 0.13.1",
]
[[package]]
@@ -4229,6 +4587,21 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "trybuild"
+version = "1.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06649c6f63d86604ba0c8950d5a1829fc9a17afd70fc6629f481d75b6a624c78"
+dependencies = [
+ "glob",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "target-triple",
+ "termcolor",
+ "toml 1.1.3+spec-1.1.0",
+]
+
[[package]]
name = "tungstenite"
version = "0.23.0"
@@ -4306,6 +4679,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
[[package]]
name = "unroll"
version = "0.1.5"
@@ -4946,6 +5329,12 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+
[[package]]
name = "winreg"
version = "0.50.0"
@@ -5222,8 +5611,15 @@ version = "0.0.1"
dependencies = [
"anyhow",
"bincode",
+ "itertools 0.11.0",
+ "num",
+ "num-bigint",
+ "num-traits",
"plonky2",
+ "secp256k1",
"serde",
+ "sha2",
+ "shared",
]
[[package]]
@@ -5232,7 +5628,16 @@ version = "0.0.1"
dependencies = [
"anyhow",
"bincode",
+ "bitcoin",
+ "bitcoincore-rpc",
+ "fs2",
+ "num",
"plonky2",
+ "serde",
+ "sha2",
+ "shared",
+ "tracing",
+ "trybuild",
"zkcoins-program-plonky2",
]
diff --git a/Cargo.toml b/Cargo.toml
index e8a81183..0cc4e11f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,13 @@ members = [
"script-plonky2",
"node",
"shared",
+ "esplora-bound",
+ "stack-policy",
+ # Generated kernel.v1 gRPC stubs (tonic-build from proto/kernel/v1).
+ "kernel-proto",
+ # Node-only downstream edge for the sealed plumbing compile-fail matrix.
+ # Must not gain extra direct deps — trybuild flattens them into the UI crate.
+ "downstream-boundary",
]
resolver = "2"
@@ -15,6 +22,10 @@ rand = "0.8"
blake3 = "1.6.1"
lazy_static = "1.5.0"
bitcoin = { version = "0.32.5", features = ["rand", "rand-std", "serde"] }
+bech32 = "0.11"
+# ZBE AEAD (§4.2.1). Workspace-pinned so shared (and any future consumer)
+# share one RustCrypto ChaCha20-Poly1305 version; not pinned past the workspace.
+chacha20poly1305 = "0.10.1"
# Structured logging facade + `fmt` subscriber. Workspace-level so any
# future crate adopting the partial-migration path (shared,
# script-plonky2) picks up the same version automatically.
diff --git a/Dockerfile b/Dockerfile
index f70718f1..b4fafa77 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,6 @@
# Multi-stage Docker build for the zkCoins node post Plonky2 migration.
#
-# The Plonky2 toolchain pin is `nightly` (see `rust-toolchain` at the
+# The Plonky2 toolchain pin is the dated nightly (see `rust-toolchain` at the
# repo root). rustup respects that file and installs the right channel
# automatically when cargo is first invoked — no manual `rustup install`
# step needed.
@@ -24,6 +24,16 @@
FROM rust:bookworm AS builder
WORKDIR /app
+# `kernel-proto/build.rs` compiles the `kernel.v1` gRPC contract with
+# prost, which needs `protoc` on PATH at build time. Pin the Debian
+# bookworm package (same pin as the api image) rather than an
+# unversioned install so the compiler is reproducible across rebuilds.
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ protobuf-compiler=3.21.12-3+deb12u1 \
+ && rm -rf /var/lib/apt/lists/* \
+ && protoc --version
+
# `sqlx::migrate!("./migrations")` is compile-time, so the migrations
# directory must exist when `cargo build` runs (the COPY below pulls
# it in). The current `db.rs` uses runtime-checked `sqlx::query` /
@@ -40,6 +50,13 @@ ENV SQLX_OFFLINE=true
COPY rust-toolchain ./
RUN rustup show
+# Use RUSTC_WORKSPACE_WRAPPER instead of global RUSTFLAGS so external deps such as plonky2 stay uninstrumented.
+RUN printf '%s\n' \
+ '#!/bin/sh' \
+ 'exec "$@" -C instrument-coverage --cfg coverage_nightly' \
+ > /usr/local/bin/coverage-rustc-wrapper.sh \
+ && chmod +x /usr/local/bin/coverage-rustc-wrapper.sh
+
COPY . .
# Cargo features for non-MVP routes. Empty by default — both DEV and
@@ -50,7 +67,19 @@ COPY . .
# here are excluded from the binary at compile time, so the disabled
# code cannot run, crash, or be exploited at runtime.
ARG FEATURES=
-RUN if [ -z "$FEATURES" ]; then \
+# Non-empty only for deploy/local-e2e/compose.coverage.yaml. This adds LLVM
+# instrumentation and the matching signal-flush hook; the default branch below
+# remains the production build path.
+ARG COVERAGE=
+RUN if [ -n "$COVERAGE" ]; then \
+ if [ -z "$FEATURES" ]; then \
+ RUSTC_WORKSPACE_WRAPPER=/usr/local/bin/coverage-rustc-wrapper.sh \
+ cargo build --release -p node --features coverage-flush; \
+ else \
+ RUSTC_WORKSPACE_WRAPPER=/usr/local/bin/coverage-rustc-wrapper.sh \
+ cargo build --release -p node --features "$FEATURES,coverage-flush"; \
+ fi; \
+ elif [ -z "$FEATURES" ]; then \
cargo build --release -p node; \
else \
cargo build --release -p node --features "$FEATURES"; \
@@ -61,6 +90,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates wget \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/node /usr/local/bin/zkcoins-node
+COPY --from=builder /app/target/release/verify_attestation /usr/local/bin/verify_attestation
ENV RUST_LOG=info
WORKDIR /data
diff --git a/README.md b/README.md
index c14294ba..3673848e 100644
--- a/README.md
+++ b/README.md
@@ -1,377 +1,175 @@
-# zkCoins Node
+# zkCoins node
[](https://hub.docker.com/r/zkcoins/node)
[](https://hub.docker.com/r/zkcoins/node)
-Rust/Axum backend for [zkcoins.app](https://zkcoins.app) — account management, ZK proof generation, Bitcoin blockchain scanning, and nullifier publishing.
+**Private Bitcoin payments via Shielded CSV** — no new chain, no token, no consensus change, no trusted operator. Only Bitcoin, zero-knowledge proofs, and the user's own keys.
-Container images: **[hub.docker.com/r/zkcoins/node](https://hub.docker.com/r/zkcoins/node)**
+The **trustless kernel** of zkCoins: Bitcoin chain scanner, nullifier accumulator, recursive-proof verifier and prover, data store, and the publisher/broadcaster — built in Rust (Axum, Plonky2 + Poseidon-Goldilocks).
+
+> Full system docs: **[docs.zkcoins.com](https://docs.zkcoins.com)** · Specification: **[docs.zkcoins.com/specification](https://docs.zkcoins.com/specification)**
-## Live
+## What zkCoins is
-| Environment | URL | Bitcoin chain | Image |
-| ----------- | -------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------ |
-| **PRD** | [api.zkcoins.app](https://api.zkcoins.app) | Mainnet | [`zkcoins/node:latest`](https://hub.docker.com/r/zkcoins/node/tags?name=latest) |
-| **DEV** | [dev-api.zkcoins.app](https://dev-api.zkcoins.app) | Mutinynet | [`zkcoins/node:beta`](https://hub.docker.com/r/zkcoins/node/tags?name=beta) |
+zkCoins lets you send value on Bitcoin without anyone seeing the amount, the asset, who paid, or who received. Bitcoin stores only opaque markers that a spend happened — not the coin's contents, which travel privately between sender and receiver as a small encrypted bundle. Double-spend protection is the chain's job; your seed derives every key, your wallet is the only thing that can spend, any node can serve you, and you verify everything against Bitcoin yourself. Built on the zkCoins concept (Robin Linus) and the Shielded CSV construction (Jonas Nick, Liam Eagen, Robin Linus).
-## Stack
+## The system, end to end
-| Layer | Technology | Why |
-| --------------- | -------------------- | ---------------------------------------------------- |
-| Language | Rust nightly | Required for Plonky2 (`feature(specialization)`) |
-| Web framework | Axum | Built on Tokio, idiomatic async Rust |
-| ZK Proofs | Plonky2 + Poseidon-Goldilocks (cyclic recursion) | Node-side, no zkVM, no external prover dependency |
-| Data structures | SMT + MMR (Poseidon) | Non-inclusion proofs + append-only history |
-| Bitcoin | Taproot Inscriptions | 64-byte nullifiers, Esplora API scanning |
-| Bitcoin index | electrs (Esplora) | Esplora REST API via shared Docker network `bitcoin` |
+| Layer | What it is | Repo |
+|---|---|---|
+| **App · Explorer** | end-user wallet (LNURL receive) · public explorer web-app | [`zk-coins/app`](https://github.com/zk-coins/app) · `zk-coins/explorer` *(planned)* |
+| **SDK** | thin TypeScript client — on-device keys, signing, node/API calls | [`zk-coins/sdk`](https://github.com/zk-coins/sdk) |
+| **zkCoins API** | public REST + LNURL, hosted-wallet service (optional) | currently in **`zk-coins/node`**; a separate API layer is the target design |
+| **zkCoins node** | trustless kernel — scan · accumulator · verify · prove · store · publisher | **[`zk-coins/node`](https://github.com/zk-coins/node)** ← this repo |
+| **bitcoind · Nostr relay** | Bitcoin L1 settlement and ordering · off-chain transport and data availability | upstream (own or external) |
-Full rationale: [docs.zkcoins.app/tech-decisions](https://docs.zkcoins.app/tech-decisions)
+Supporting repos: [`zk-coins/research`](https://github.com/zk-coins/research), [`zk-coins/plonky2`](https://github.com/zk-coins/plonky2), [`zk-coins/docs`](https://github.com/zk-coins/docs).
-## Trust Model
+## This repository (node)
-Proof generation runs **inside this node process**. `AccountNode::send_coins` (`node/src/account_node.rs`) calls `self.prover.prove_account_update_with_in_and_out_coins_and_sources(...)` (and the `prove_initial_*` variant for first-time accounts) on every send / receive / mint. ZK proving requires the full private witness, so the node sees, in cleartext:
+The node is the **Rust/Axum backend** behind [zkcoins.app](https://zkcoins.app): it scans Bitcoin for Taproot-inscription commitments, maintains the nullifier accumulator and account state, generates and verifies the recursive ZK proofs for every mint/send/receive, persists state to Postgres, and broadcasts the commit/reveal inscription pair back to the chain. It is a single self-hostable container — running your own node is the trustless, private path the whole system is designed around.
-- Sender, recipient, and amount of every coin movement
-- The complete in-coin / out-coin / source-aggregator slot layout per account
-- Account history roots, Merkle proofs, and inclusion-proof witnesses
-- Usernames and their bound coin sets (`UsernameStore`)
-- Postgres rows persisting all of the above (`node/migrations/000{1,2}_*.sql`)
+### Trust model — run your own node
-The **on-chain footprint stays private** — Plonky2 ensures that the public outputs (nullifiers, history roots, Taproot inscriptions) carry no readable transaction data. Block explorers and chain analytics see only opaque 64-byte commitments. The trust boundary is therefore the **node operator**, not the chain.
+zkCoins follows the **Bitcoin full-node model: your wallet trusts _your_ node, exactly as a Bitcoin wallet trusts your own `bitcoind`.** Proof generation runs inside this process, so the node sees, in cleartext, the sender, recipient, and amount of every movement plus the full witness — the trust boundary is the **node operator, not the chain**. The on-chain footprint stays private: block explorers see only opaque 64-byte commitments. A foreign operator can never steal, forge, or double-spend your coins (that is enforced cryptographically), but it can see your privacy and affect liveness — the same trade-off as using someone else's Electrum/SPV server instead of your own. **If you need full transaction privacy, run your own node.** Full rationale: [`CONTRIBUTING.md` § Trust model](./CONTRIBUTING.md#trust-model--run-your-own-node).
-| | Hosted (`api.zkcoins.app`) | Self-hosted |
+| | Hosted (`api.zkcoins.app`) | Self-hosted |
| --- | --- | --- |
| On-chain privacy (vs. block explorers) | ✅ | ✅ |
-| Operator sees plaintext transaction data | ❌ Yes — `api.zkcoins.app` is operated by [zkcoins.app](https://zkcoins.app) | ✅ No |
+| Operator sees plaintext transaction data | ❌ Yes — operated by [zkcoins.app](https://zkcoins.app) | ✅ No |
| Setup effort | ✅ None | ⚠️ Postgres + electrs + Bitcoin node |
-**If you need full transaction privacy, run your own node.** Every release is shipped as `zkcoins/node:latest` (see [Live](#live)), the build recipe is [`Dockerfile`](./Dockerfile), and runtime knobs are documented in [Configuration](#configuration). Point the [zkcoins.app](https://zkcoins.app) client at your self-hosted instance for end-to-end self-custody of transaction data.
-
-## Contributing
-
-**New PRs may only merge into `develop` if test coverage is 100% on the activated surface.** Code behind a Cargo feature (`address-list`, `lnurl`) is excluded from the MVP measurement — feature-gated routes do not need to be tested because both DEV and PRD ship the MVP-only binary with every Cargo feature off. (Mint and usernames are part of the MVP and are permanently compiled in — no Cargo feature gate.) Concretely:
-
-- `cargo llvm-cov -p node` (no `--all-features`) must report 100% lines + 100% functions on the activated MVP surface. CI enforces this with `--fail-under-lines 100 --fail-under-functions 100` in the `Coverage Gate (100% lines + functions)` job. The current `develop` baseline is at the gate.
-- Defensive code that genuinely cannot be reached in unit tests (e.g. the publisher's Bitcoin-broadcast path that requires a signet/regtest node, the `main.rs` runtime bootstrap) is excluded from the measured scope at the file level rather than tested.
-- The branch is protected on GitHub: a PR cannot be merged while CI is red.
-
-The same rule applies to `zk-coins/app` (gated `NEXT_PUBLIC_ENABLE_*` flags are excluded from the measured scope).
-
-## Features
-
-API endpoints, background services, their activation status, and the tests that cover them.
-
-**Status legend** (current behaviour): `always` = endpoint/service always compiled in · `env` = behavior controlled by a runtime env var · `feature` = compiled in only when the named Cargo feature is enabled at build time, otherwise excluded from the binary · `planned` = listed in Open Tasks, not yet implemented.
-
-**Triage legend** (MVP testing decision): `mvp` = in MVP scope, must reach full test coverage before launch · `gate` = not in MVP scope; hidden behind a Cargo feature, default off, no test coverage required · `planned` = not in scope for MVP.
-
-**Coverage legend:** unit % refers to `cargo-llvm-cov` line coverage of the module that implements the function. The MVP-scope per-module summary is in § "Test stack" below; the authoritative live numbers are in the `Coverage Gate` CI job. `—` means no test exists.
-
-| Function | Trigger | Status | Triage | Tests |
-| ------------------------------------ | ------------------------------------- | ------------------------ | ------- | ----------------------------- |
-| Health check | `GET /health` | always | mvp | 100% (router) |
-| Network info | `GET /api/info` | env¹ | mvp | 100% (router) |
-| Get balance | `GET /api/balance?address=` | always | mvp | 100% (router) |
-| List per-address history | `GET /api/history?address=&limit=&offset=` | always | mvp | 100% (router) |
-| List all addresses | `GET /api/address` | feature (`address-list`) | gate | 100% (router) |
-| Admit mint job | `POST /api/jobs/mint` | always² | mvp | 100% (router) |
-| Admit send job (phase 1) | `POST /api/jobs/send` | env² | mvp | 100% (router) |
-| Attach signed commit (phase 2) | `POST /api/jobs/:id/commit` | env³ | mvp | 100% (router) · 0% (flow) |
-| Poll job status | `GET /api/jobs/:id` | always | mvp | 100% (router) |
-| Stream job phase events (SSE) | `GET /api/jobs/:id/stream` | always | mvp | 100% (router) |
-| Cancel queued job | `POST /api/jobs/:id/cancel` | always | mvp | 100% (router) |
-| Receive coin | `POST /api/receive` | always | mvp | 100% (account_node) |
-| Download coin proof | `GET /api/proof/:id` | always | mvp | 100% (router) |
-| Claim username | `POST /api/username/claim` | always | mvp | 100% (username) |
-| Resolve username | `GET /api/username/resolve/:username` | always | mvp | 100% (username) |
-| LNURL-Pay metadata | `GET /.well-known/lnurlp/:username` | feature (`lnurl`) | gate | 100% (router) |
-| LNURL-Pay callback | `GET /lnurl/pay/:username` | feature (`lnurl`) | gate | 100% (router) |
-| Bitcoin block scanner (background) | WS subscription in `scanner_ws.rs` | env⁴ | mvp | 100% (scanner) · — (main, excluded) |
-| State persistence (SMT/MMR write) | Scanner callback on commitment match | always | mvp | 100% (state) |
-| Taproot inscription broadcast | Called by dispatcher (`flow.rs`) | env³ | mvp | 0% (publisher) |
-| Publisher UTXO lookup | Internal, before broadcast | env³ | mvp | 0% (publisher) |
-| OpenAPI 3.x spec | `GET /openapi.json` | always | mvp | 100% (openapi_smoke) |
-| Swagger UI | `GET /docs` | always | mvp | 100% (openapi_smoke) |
-| Explorer endpoints (`/api/stats`, …) | n/a | planned | planned | — |
-| Light client support | n/a | planned | planned | — |
-
-¹ `NETWORK_NAME` env var controls the string returned. `IS_MAINNET=true` flips the default to `"Mainnet"`.
-² Proof generation routes through the Plonky2 cyclic-recursion circuit. Single host, single Rust process — no zkVM, no external prover service. Mac Studio M3 Ultra is the production hardware target (96 GB unified memory, no external GPU). See [Proving Strategy](#proving-strategy).
-³ Requires `PUBLISHER_KEY` set to a real funded key and `ESPLORA_URL` reachable. With the default test key the node panics on `IS_MAINNET=true` startup; on testnet it accepts the call but broadcast will fail without funded UTXOs — DEV and PRD both return `503 SERVICE_UNAVAILABLE` to the client on broadcast failure (the historic `DEV_SKIP_BROADCAST_FAILURE` env-gate that silently swallowed these failures was removed once DEV and PRD were unified on the MVP-only binary; the DEV publisher wallet therefore has to be funded for E2E paths).
-⁴ Scanner depends on `ESPLORA_URL` (REST, used for the per-block `get_block_txids` / `get_tx` lookups and for the post-reconnect tip anchor) AND `ESPLORA_WS_URL` (WebSocket, used by `scanner_ws` to receive new-tip events — issue #84). Both are required env vars with no default; see [Configuration](#configuration) for per-stage values. On connection failure the WS subscriber reconnects with exponential backoff capped at 30 s.
-
-### Cargo features
-
-All non-MVP routes are gated by Cargo features so the disabled handler functions, helper structs, and `AppState` fields are excluded from the binary at compile time. With a feature off, the route is never registered and the fallback responds with `404`. There is no runtime path that can reach a disabled handler. Defaults are empty (fail-closed): **both the DEV and the PRD image builds pass no features**, so the two environments run the identical MVP-only binary. The Cargo flags exist for self-hosters who want to compile a binary with a specific non-MVP subset enabled, and for future per-feature rollouts when an individual feature is deemed ready for production.
-
-| Feature | Gates |
-| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `address-list` | `GET /api/address` |
-| `lnurl` | `GET /.well-known/lnurlp/:u`, `GET /lnurl/pay/:u` |
-
-Build the MVP-only binary (DEV + PRD ship this): `cargo build --release -p node`. Build with every feature enabled (CI clippy + tests + self-host opt-in): `cargo build --release -p node --all-features`. The Docker `FEATURES` build arg accepts a comma-separated list and is forwarded to `cargo build --features`; both `deploy-dev.yaml` and `deploy-prd.yaml` leave it empty.
-
-### Triage gaps
-
-Features tagged `mvp` whose current test coverage is insufficient — these block "100% on activated features":
-
-- **Send — phase 2 (commit + broadcast)** — only error-path tests (`commit_missing_body`, `commit_nonexistent_proof_id`); no happy-path test that exercises the publisher
-- **Download coin proof** — only 404 path tested; no test for the happy-path binary stream
-- **Bitcoin block scanner** — parsing helpers covered (`scanner.rs` 51%); no integration test against a real Bitcoin block
-- **Taproot inscription broadcast** — `publisher.rs` 0%, no tests at all (would need signet/regtest + funded publisher key)
-- **Publisher UTXO lookup** — `publisher.rs` 0%, no tests
-
-### Details
-
-#### Health check
-
-- **Module:** `router.rs::main_app` route handler
-- **Behaviour:** returns the literal string `"ok"` with HTTP 200
-- **Tests:** `router.rs::tests::health_returns_ok`
-
-#### Network info
-
-- **Module:** `router.rs::info_handler`
-- **Behaviour:** returns `{ network, capabilities: { address_list, faucet, usernames, lnurl }, username_domain }`. `network` defaults to `Mutinynet` when `IS_MAINNET=false`, `Mainnet` when `true`. `capabilities.{address_list,lnurl}` each reflect whether the corresponding Cargo feature was compiled into this binary, letting clients gate UI on a single node-side source of truth instead of parallel build-time env flags. `capabilities.{faucet,usernames}` are hardcoded `true` — mint and usernames are permanent MVP — and are retained only for back-compat with wallet clients that deserialise the shape. `username_domain` is the external hostname this node serves; **required env var** (node panics on startup if unset). PRD sets `USERNAME_DOMAIN=zkcoins.app`, DEV sets `USERNAME_DOMAIN=dev.zkcoins.app` — distinct from `network` because the same chain can be served from two isolated external hostnames, and the client renders `@` from this field
-- **Tests:** `router.rs::tests::info_returns_network_name_capabilities_and_username_domain`, `router.rs::tests::info_serialization_format_is_stable`
-
-#### Get balance
-
-- **Module:** `router.rs::get_balance_handler` → `account_node.rs::AccountNode::get_account_balance`
-- **Behaviour:** address parsed as hex pubkey, looks up the account. Returns `{ balance, username? }`. A well-formed address with no on-chain activity yields `200 OK` with `balance: 0` (canonical zero state, not 404). The minting address returns `u64::MAX`. Malformed input — invalid hex, wrong length, or a missing `address` query parameter — returns `422`
-- **Tests:** `router.rs::tests::balance_*` (6 tests covering happy path, unknown address with and without a claimed username, invalid hex, missing param, wrong length)
-
-#### List all addresses
-
-- **Module:** `router.rs::get_address_handler` → `account_node.rs::AccountNode::get_addresses`
-- **Behaviour:** returns all known addresses as hex strings. Intended for explorer/debug use, not user-facing
-- **Tests:** `router.rs::tests::address_returns_list`
-
-#### Mint coins (single-phase)
-
-- **Module:** `router.rs::mint_handler` → `account_node.rs::send_coins` with the node-held minting account
-- **Behaviour:** node signs commitment itself (no client roundtrip) using the minting key
-- **Proof generation:** `zkcoins_prover::Prover` (the Plonky2 wrapper in [`script-plonky2/`](./script-plonky2/)) — `prove_initial` for new accounts, `prove_account_update` for receivers
-- **Tests:** `account_node.rs::tests::test_create_minting_account`, `test_mint_single_invoice`, `test_mint_repro_live_setup`
-
-#### Send — phase 1 (generate proof)
-
-- **Module:** `router.rs::send_coin_handler` → `verify_send_signature` (Schnorr over `SHA256(account_address || recipient || amount || timestamp)`, ±5 min skew) → `account_node.rs::send_coins`
-- **Behaviour:** returns `{ proof_id, account_state_hash, output_coins_root }`. Proof is persisted under `data/proofs/.bin` for later commit
-- **Tests:** request-layer tests in `router.rs::tests::send_*` and `send_signature_*` (12 tests covering parser, signature verification, replay). Proof generation itself is not exercised — the Plonky2 cyclic-recursion build is too slow for unit tests (~3–15 min per prove at production parameters); positive proofs are exercised in `program-plonky2/` directly
-
-#### Send — phase 2 (commit + broadcast)
-
-- **Module:** `router.rs::commit_handler` → `publisher.rs::create_and_broadcast_inscription`
-- **Behaviour:** verifies the client's Schnorr commitment, builds a Taproot commit+reveal tx pair, mines a txid prefix `4242` (max 400 000 attempts in `publisher.rs::inscription_txs`), broadcasts both txs, then calls `account_node.rs::receive_coin` to deliver the coin to the recipient
-- **Tests:** `router.rs::tests::commit_missing_body_returns_error`, `commit_nonexistent_proof_id_returns_404`. **No happy-path broadcast test** — would require a live Bitcoin signet/regtest
-
-#### Receive coin
-
-- **Module:** `router.rs::receive_coin_handler` → `account_node.rs::receive_coin`
-- **Behaviour:** replay-protected via per-account `coin_history` SMT
-- **Tests:** `account_node.rs::tests::test_receive_duplicate_coin_rejected`, `test_receive_updates_balance`
-
-#### Download coin proof
-
-- **Module:** `router.rs::get_proof_handler` → `ProofStore::get_proof`
-- **Behaviour:** streams the binary serialised `CoinProof` (`Vec` from bincode) with content-type `application/octet-stream`
-- **Tests:** `router.rs::tests::proof_not_found_returns_404`
-
-#### Claim username
+### Live deployments
-- **Module:** `router.rs::claim_username_handler` → `username.rs::UsernameStore::claim`
-- **Behaviour:** verifies Schnorr signature over `SHA256(username || pubkey || timestamp)` (5 min skew); persists to the Postgres `usernames` table via `db::claim_username` (`INSERT … ON CONFLICT DO NOTHING`)
-- **Tests:** `router.rs::tests::claim_username_*` (3 tests) + `username.rs::tests::*` (8 tests covering valid charset, duplicates, persistence)
+| Environment | URL | Bitcoin chain | Image |
+| --- | --- | --- | --- |
+| **PRD** | [api.zkcoins.app](https://api.zkcoins.app) | Mainnet | [`zkcoins/node:latest`](https://hub.docker.com/r/zkcoins/node/tags?name=latest) |
+| **DEV** | [dev-api.zkcoins.app](https://dev-api.zkcoins.app) | Mutinynet | [`zkcoins/node:beta`](https://hub.docker.com/r/zkcoins/node/tags?name=beta) |
-#### Resolve username
-
-- **Module:** `router.rs::resolve_username_handler` → `username.rs::UsernameStore::resolve`
-- **Behaviour:** if exact username unknown, falls back to hex prefix matching against known addresses. Case-insensitive
-- **Tests:** `router.rs::tests::resolve_unknown_username_returns_404`, `resolve_minting_address_by_hex_prefix`, `username.rs::tests::resolve_is_case_insensitive`
-
-#### LNURL-Pay metadata and callback
-
-- **Module:** `router.rs::lnurlp_handler`, `router.rs::lnurl_callback_handler`
-- **Behaviour:** thin stub implementation of [LNURL-pay](https://github.com/lnurl/luds/blob/luds/06.md). Metadata returned for known usernames; callback returns a phase-2 error (not wired to a real BOLT-11 invoice generator yet)
-- **Tests:** `router.rs::tests::lnurlp_known_address_returns_pay_request`, `lnurlp_unknown_user_returns_404`, `lnurl_pay_callback_returns_phase2_error`
-
-#### Bitcoin block scanner
-
-- **Module:** `scanner.rs::scan_for_inscriptions` / `InscriptionScanner::scan_from_block`. Loop spawned from `main.rs::main`. State saved between runs in `data/latest_block.bin`
-- **Behaviour:** subscribes to the Esplora WebSocket (`scanner_ws.rs`, `ESPLORA_WS_URL`) for new tip events; drains the resulting mpsc channel in `scanner_runtime.rs`, walking forward through `block_status.next_best`; filters txs by txid prefix `4242`; extracts Taproot inscription content via `extract_inscription_content`; deserialises as `Commitment`; calls callback in `main.rs` which verifies the signature and updates state. Polling was removed in [issue #84](https://github.com/zk-coins/node/issues/84); see [CONTRIBUTING.md § "No polling — events only"](./CONTRIBUTING.md#no-polling--events-only) for the CI lint that enforces this
-- **Tests:** `scanner.rs::tests::parse_valid_inscription_into_commitment`, `reject_invalid_inscription_data`, `verify_commitment_signature_after_deserialization`, `parse_multi_chunk_inscription`. **No integration test** with a real Bitcoin block
-
-#### State persistence (SMT/MMR write)
-
-- **Module:** `state.rs::State::update` + scanner callback in `main.rs` → `db::persist_state_tx`
-- **Behaviour:** on each verified commitment: append SMT root to MMR, then atomically upsert the SMT bytes, MMR bytes, and last-processed block hash inside a single `BEGIN; UPSERT; UPSERT; UPSERT; COMMIT` against Postgres (issue #11 fix). Replaces the pre-migration `smt.bin` / `mmr.bin` / `latest_block.bin` sibling files
-- **Tests:** `state.rs::tests::*` (9 tests covering single + multiple updates, persistence roundtrip, proof generation/verification, empty MMR edge cases)
-
-#### Taproot inscription broadcast and Publisher UTXO lookup
-
-- **Module:** `publisher.rs::create_and_broadcast_inscription`, `inscription_txs`, `broadcast_inscription_txs`, `get_publisher_utxo`
-- **Behaviour:** `inscription_txs` mines the commit txid prefix `4242` (uses random nonce loop, up to 400 000 attempts). `get_publisher_utxo` filters Esplora UTXOs for the publisher's Taproot address, requires ≥ 800 sats
-- **Tests:** **none** — would require a live signet/regtest node and a funded publisher key
-
-#### Planned
-
-- **Explorer endpoints (`/api/stats`, `/api/nullifiers`)** — to power the `zkcoins.space` companion app
-- **Light client support** — let wallets verify nullifier set membership without scanning the chain themselves
-
-### Configuration
-
-| Variable | Default | Effect |
-| ----------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `IS_MAINNET` | _(required, no default)_ | Exact string `true` or `false` — anything else panics. PRD sets `true`, DEV sets `false`. Drives the `Network` enum (Mainnet vs Signet) used for address derivation. Truthy values like `1`, `TRUE`, `yes` are rejected to prevent silent misconfiguration. |
-| `ESPLORA_URL` | _(required, no default)_ | HTTP Esplora endpoint for the chain this stage serves. On the `api.zkcoins.app` stack: PRD `http://electrs-mainnet:3000`, DEV `http://electrs-mutinynet:3000`. Self-host: your electrs URL. Empty string is treated as unset. |
-| `ESPLORA_WS_URL` | _(required, no default)_ | Esplora-compatible WebSocket endpoint consumed by `scanner_ws` (issue #84). On the `api.zkcoins.app` stack: PRD `wss://mempool.space/api/v1/ws`, DEV `ws://mempool-api-mutinynet:8999/api/v1/ws` (self-hosted mempool/backend sidecar). Empty string is treated as unset. |
-| `NETWORK_NAME` | `Mutinynet` / `Mainnet` | Human-readable name returned by `/api/info`. Default depends on `IS_MAINNET`. Purely cosmetic — has no behavioural effect on the scanner, publisher, or address derivation. |
-| `USERNAME_DOMAIN` | _(required, no default)_ | External hostname returned by `/api/info`. The client renders `@` from this. **Node panics on startup if unset.** PRD sets `zkcoins.app`, DEV sets `dev.zkcoins.app` — silent fallback would let a misconfigured stage reproduce the cross-network routing bug (#95) |
-| `PUBLISHER_KEY` | _(required, no default)_ | 32-byte hex private key for inscription publishing. Node panics on startup if unset. On `IS_MAINNET=true` an additional check refuses the well-known test key. |
-| `RUST_LOG` | `info` | Log level |
-
-**Why so many required env vars.** Earlier versions of this table listed Mutinynet defaults for the three chain-shaping vars (`IS_MAINNET`, `ESPLORA_URL`, `ESPLORA_WS_URL`). They were silent footguns: a Mainnet deployment that forgot one would scan Mutinynet while answering `/api/info` as Mainnet, with `/health/ready` green throughout (5-s HTTP retry loop on the scanner — issue #84). On the Mutinynet path the WS default coupled the deploy to a public third-party host we do not operate. Making both paths explicit-or-panic — the same pattern as `USERNAME_DOMAIN`, `PUBLISHER_KEY`, and `DATABASE_URL` — removes both classes of bug. A mechanical guardrail (`node/tests/no_chain_hardcodes.rs`) prevents the literal URLs from creeping back into the source.
-
-Runtime config above shapes _behaviour_ of compiled-in routes. _Which_ routes are compiled in is decided at build time by Cargo features — see [Cargo features](#cargo-features).
-
-### Background services
-
-Spawned from `main.rs::main`:
+Container images: **[hub.docker.com/r/zkcoins/node](https://hub.docker.com/r/zkcoins/node)**
-1. **REST API** (`tokio::spawn` of `start_rest_node`) — Axum app bound to `0.0.0.0:4242`
-2. **Block scanner** (driven directly in main, not spawned) — `scan_for_inscriptions` consumes new tips from the WS-fed `mpsc` channel produced by `scanner_ws::run_scanner_ws` (spawned as a tokio task at startup) and writes state on each verified commitment. No fixed-interval polling — see [issue #84](https://github.com/zk-coins/node/issues/84)
+### Tech stack
-### Tests
+| Layer | Technology | Why |
+| --- | --- | --- |
+| Language | Rust nightly (pinned via `rust-toolchain`) | Required for Plonky2 (`feature(specialization)`) |
+| Web framework | Axum | Built on Tokio, idiomatic async Rust |
+| ZK proofs | Plonky2 + Poseidon-Goldilocks (cyclic recursion) | Node-side, no zkVM, no external prover dependency |
+| Data structures | SMT + MMR (Poseidon) | Non-inclusion proofs + append-only history |
+| State store | PostgreSQL (`sqlx`) | Deterministic, atomic SMT/MMR/checkpoint writes |
+| Bitcoin | Taproot inscriptions | 64-byte nullifiers, Esplora API scanning |
+| Bitcoin index | electrs (Esplora) | Esplora REST + WebSocket over the shared Docker network `bitcoin` |
-| Stack | Command | What it covers |
-| ---------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
-| `cargo test` | `cargo test -p node` | MVP code paths — what the DEV + PRD binary actually contains |
-| `cargo test` | `cargo test -p node --all-features` | Including the gated `address-list` and `lnurl` routes |
-| `cargo-llvm-cov` | `cargo llvm-cov -p node` | Coverage gate enforced by CI: 100% lines + functions on the activated MVP surface |
+Full rationale: [docs.zkcoins.com/tech-decisions](https://docs.zkcoins.com/tech-decisions).
-Per-module coverage (CI-gated):
+### Build & run
-| Module | Line + function % | Notes |
-| ------------------- | ----------------- | ---------------------------------------------------------------------------------- |
-| `account_node.rs` | 100% | send-coins flow, account ledger, scanner integration |
-| `scanner.rs` | 100% | Bitcoin block / inscription scanner |
-| `router.rs` | 100% | REST handlers + request validation |
-| `state.rs` | 100% | Poseidon-based SMT + MMR |
-| `username.rs` | 100% | Username claim / resolve / LNURL |
-| `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; the pure helper `parse_ws_frame` is unit-tested, the I/O loop is covered by in-process WS-server tests |
+Prerequisites: nightly Rust (auto-installed via `rust-toolchain`), Docker (for the Postgres testcontainer), and access to a Bitcoin node with an Esplora-compatible indexer (electrs). The node reads configuration **exclusively from environment variables** — required ones panic the bootstrap on startup if unset, there is no silent fallback.
-`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.
+```bash
+git clone https://github.com/zk-coins/node.git
+cd node
-## Running
+# Local Postgres for the state layer
+docker run --name zkcoins-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 -d postgres:17
-Requires access to a Bitcoin node with an Esplora-compatible indexer (electrs) — see [Docker](#docker) and [CONTRIBUTING.md](./CONTRIBUTING.md) for setup.
+export DATABASE_URL="postgresql://postgres:dev@localhost:5432/postgres"
+export PUBLISHER_KEY="$(openssl rand -hex 32)" # 32-byte hex; never commit a real key
+export USERNAME_DOMAIN="test.zkcoins.local" # external hostname returned by /api/info
+export IS_MAINNET="false" # exact "true" / "false"; anything else panics
+export ESPLORA_URL="http://localhost:3000" # HTTP Esplora endpoint
+export ESPLORA_WS_URL="ws://localhost:8999/api/v1/ws" # Esplora WebSocket (issue #84)
-```bash
cargo run -p node
# Node starts on http://0.0.0.0:4242
```
-## Job-API send flow
-
-User sends are admitted to the Job-API and driven by the background dispatcher (PR1, June 2026 — `migrations/0014_jobs.sql` + `src/job_dispatcher.rs`). The wallet never holds an HTTP connection across the ~5 s prove call; each step is a separate poll-friendly request:
-
-1. **`POST /api/jobs/send`** (with `Idempotency-Key` header) — admit the send job. Returns `202` + `{job_id, status: "queued"}` immediately. The dispatcher picks the row up and runs the ZK prove.
-2. **Poll `GET /api/jobs/:id` every ~2 s** — wallet observes `queued → proving → awaiting_signature`. When `status = awaiting_signature`, the body carries `proof_id` so the wallet can `GET /api/proof/:id` to download the proof, sign `Schnorr(hash_concat(account_state_hash, output_coins_root))` with the BIP-32 key at `numPubkeys`, and...
-3. **`POST /api/jobs/:id/commit`** — attach the signed commitment. Returns `200` + `{status: "broadcasting"}`. The dispatcher broadcasts the Taproot inscription and `state.update`s the recipient; the next poll observes `status = completed` with the cached result body.
-
-Mint follows the same admit-then-poll pattern (`POST /api/jobs/mint`) — single-phase under the hood because the node holds the minting key, so `awaiting_signature` is skipped and the job transitions `queued → proving → broadcasting → completed` directly.
-
-Cancellation: `POST /api/jobs/:id/cancel` only succeeds while the job is `queued` (no prove cost paid yet). Past that, the dispatcher has already committed sunk cost and the row is no longer cancellable.
-
-## Project Structure
-
-```
-node/ # Axum REST API
-├── src/
-│ ├── main.rs # Entry point, chain scanner, bind 0.0.0.0:4242
-│ ├── router.rs # REST endpoints + /health
-│ ├── runtime.rs # Bootstrap: lazy_statics, Postgres pool, REST listener
-│ ├── account_node.rs # Account logic, coin proofs, prover calls
-│ ├── state.rs # Sparse Merkle Tree + Merkle Mountain Range
-│ ├── scanner.rs # Bitcoin block scanner (event-driven via scanner_ws, prefix 4242)
-│ ├── scanner_ws.rs # Esplora WebSocket subscriber (issue #84, replaces 30 s polling)
-│ └── publisher.rs # Taproot Inscription broadcaster (commit/reveal)
-shared/ # Shared types (Commitment, Invoice, ClientAccount)
-program-plonky2/ # Cyclic-recursion state-transition circuit (Plonky2 + Poseidon)
-├── src/
-│ ├── circuit/ # `build_circuit` + per-stage gadgets
-│ ├── hash.rs # Poseidon-Goldilocks helpers (HashDigest, digest_to_bytes…)
-│ ├── merkle/ # Poseidon-based SMT + MMR
-│ ├── types.rs # AccountState, Coin, ProofData
-│ └── inputs.rs # CommitmentMerkleProofs, ProofType
-script-plonky2/ # Host-side prover wrapper (Prover struct)
-```
-
-The last SP1 zkVM / SHA256 state is preserved at tag `v0.last-sp1` for historical reference. Recover with `git checkout v0.last-sp1 -- program/ script/`.
-
-## Docker
+Or with Docker:
```bash
docker build -t zkcoins/node .
-docker run -p 4242:4242 \
- --network bitcoin \
+docker run -p 4242:4242 --network bitcoin \
-e ESPLORA_URL=http://electrs-mainnet:3000 \
+ -e USERNAME_DOMAIN=zkcoins.app \
zkcoins/node
```
-Docker builds use nightly Rust auto-installed via `rust-toolchain` (no external toolchain needed). The Dockerfile lives at the repo root; `.github/workflows/deploy-dev.yaml` builds `zkcoins/node:beta` for `linux/arm64` and deploys to the DEV host on every push to `develop`.
+The Docker build is multi-stage Rust → `linux/arm64` and forwards a `FEATURES` build-arg to `cargo build --features`. Both DEV and PRD ship the identical **MVP-only binary** (no Cargo features); the underlying bitcoind needs `txindex=1`, `rest=1`, `server=1`.
-## CI/CD
+Key configuration variables (full table in [`CONTRIBUTING.md` § Environment variables](./CONTRIBUTING.md#environment-variables)):
-| Workflow | Trigger | Action |
-| ---------------------- | ------------ | ---------------------------------------------------- |
-| `deploy-dev.yaml` | Push develop | Docker (ARM64) → `zkcoins/node:beta` → DEV node |
-| `deploy-prd.yaml` | Push main | Docker (ARM64) → `zkcoins/node:latest` → PRD node |
-| `auto-release-pr.yaml` | Push develop | Creates Release PR (develop → main) |
+| Variable | Default | Description |
+| --- | --- | --- |
+| `DATABASE_URL` | _(required)_ | Postgres connection string for the state layer. |
+| `PUBLISHER_KEY` | _(required)_ | 32-byte hex private key for Taproot inscription publishing. Never commit a real key. |
+| `USERNAME_DOMAIN` | _(required)_ | External hostname returned by `/api/info`. |
+| `IS_MAINNET` | _(required)_ | Exact string `true` / `false`; selects Mainnet vs. signet/Mutinynet address derivation. Anything else panics. |
+| `ESPLORA_URL` | _(required)_ | HTTP Esplora endpoint (electrs or compatible). |
+| `ESPLORA_WS_URL` | _(required)_ | Esplora WebSocket endpoint the scanner subscribes to for new-tip events. |
+| `RUST_LOG` | `info` | Log level. |
-Build time: ~5 minutes (Rust compilation on ARM64).
+### Test
-## Proving Strategy
+```bash
+cargo test -p node # MVP code paths — what the DEV + PRD binary contains
+cargo test -p node --all-features # including the gated address-list and lnurl routes
+cargo llvm-cov -p node # coverage: measured floor on node + shared (see .github/coverage-baseline.md)
+```
-zkCoins is **node-heavy**: a single trusted node generates all proofs, the wallet holds only the private key and signs BIP-340 Schnorr over `SHA256(serialize(asth) ‖ serialize(ocr))`. There is no in-browser Poseidon, no wasm-Plonky2 verifier, no in-app ZK gadget. See the [protocol specification](https://docs.zkcoins.app/specification) for the full rationale.
+The `db_tests` spin up their own `postgres:17` container via `testcontainers-modules`. CI enforces a **measured coverage floor** (currently 75 % lines / 75 % functions; full record in [`.github/coverage-baseline.md`](.github/coverage-baseline.md)) over `-p node -p shared --all-features`. Legitimate exclusions are test infrastructure (`*_tests.rs`, `test_db.rs`, `bin/`), crate entrypoints (`main.rs`, `lib.rs`), and the Plonky2 circuit packages (`program-plonky2/`, `script-plonky2/`) — those circuits are secured by the §1.7.9 digest generator, prove tests, and the D-05 differential test, not by line coverage. Production modules such as `publisher.rs`, `runtime.rs`, `flow.rs`, `job_dispatcher.rs`, and the scanners are **included** in the measurement. Enable the pre-push hook (`git config core.hooksPath .githooks`) to run `cargo fmt --check`, clippy, and `cargo check` before push. CI also enforces a **no-polling** rule: scanner/publisher hot paths subscribe to events, they never poll the chain tip (issue [#84](https://github.com/zk-coins/node/issues/84)).
-**Hardware target: Mac Studio M3 Ultra** (96 GB unified RAM, single host). All on-box compute is available: Performance + Efficiency cores, the integrated Apple Silicon GPU (via Metal — currently unused because Plonky2 ships CPU + CUDA backends only), Neural Engine, AMX. **Not available**: external GPU accelerators (no NVIDIA, no CUDA), no cloud prover services (no Succinct Prover Network, no AWS GPU). Performance budget is what the M3 Ultra delivers; if a design overshoots, the design changes — we do not add external hardware.
+### HTTP surface
-Current cyclic-recursion proof times at production parameters (`MAX_IN_COINS = MAX_OUT_COINS = 8`, `INNER_PAD_BITS = 14`): 3–15 min wall per `prove_*` call. The detailed test-time table is archived in [zk-coins/research](https://github.com/zk-coins/research/tree/develop/zkcoins-design/program-plonky2-sessions).
+The REST + LNURL API is documented by an **OpenAPI 3.x spec generated at compile time** from `#[utoipa::path]` annotations (the wire contract cannot drift from the docs). Served at `GET /openapi.json` and rendered with bundled Swagger UI at `GET /docs`. User sends are admitted to a **Job API** (`POST /api/jobs/send` → poll `GET /api/jobs/:id` → `POST /api/jobs/:id/commit`) so the thin wallet never holds an HTTP connection across the multi-second prove call. Mint follows the same admit-then-poll pattern (`POST /api/jobs/mint`); the node holds the minting key, so the signing phase is skipped.
-## Open Tasks
+### Repository layout
+
+```
+node/
+├── node/ # Axum REST API (router, account_node, state, scanner, publisher, job dispatcher)
+│ ├── src/
+│ │ ├── main.rs # Entry point, chain scanner, bind 0.0.0.0:4242
+│ │ ├── router.rs # REST endpoints + /health
+│ │ ├── runtime.rs # Bootstrap: lazy_statics, Postgres pool, REST listener
+│ │ ├── account_node.rs# Account logic, coin proofs, prover calls
+│ │ ├── state.rs # Sparse Merkle Tree + Merkle Mountain Range (Poseidon)
+│ │ ├── scanner.rs # Bitcoin block scanner (event-driven, prefix 4242)
+│ │ ├── scanner_ws.rs # Esplora WebSocket subscriber (issue #84, replaces polling)
+│ │ ├── publisher.rs # Taproot inscription broadcaster (commit/reveal)
+│ │ ├── job_dispatcher.rs / job_store.rs # Async Job API for sends
+│ │ └── openapi.rs # Compile-time OpenAPI 3.x spec
+│ └── migrations/ # Forward-only SQL migrations (no down-migrations in the MVP)
+├── shared/ # Shared types (Commitment, Invoice, ClientAccount)
+├── program-plonky2/ # Plonky2 + Poseidon cyclic-recursion state-transition circuit
+│ └── CONTRIBUTING.md # Toolchain/build/test/coverage handoff for the circuit crate
+├── script-plonky2/ # Host-side Plonky2 prover wrapper (zkcoins-prover-plonky2)
+├── Cargo.toml # Workspace root (nightly toolchain, tuned release profile)
+├── Dockerfile # Multi-stage Rust build (linux/arm64, FEATURES build-arg)
+└── rust-toolchain # Pinned nightly
+```
-- [ ] Step 9: signet end-to-end roundtrip against `dev.zkcoins.app` (create account → mint → send → receive)
-- [ ] Step 9: R2 performance measurement on the M3 Ultra (warm proof ≤ 5 s target ≤ 1 s; cold ≤ 30 s; peak mem < 64 GB)
-- [ ] Pre-mainnet hardening: D2/D10 (hiding recipient), D7 (reorg safety), D8 (per-coin nullifier-accum) — see the [protocol specification](https://docs.zkcoins.app/specification) divergence list
-- [ ] Explorer endpoints (`/api/stats`, `/api/nullifiers`)
-- [ ] Light client support
+### Proving strategy
-## Related
+zkCoins is **node-heavy**: this node generates all proofs; the wallet holds only the private key and signs BIP-340 Schnorr over the proof outputs. There is no in-browser Poseidon, no wasm verifier, no in-app ZK gadget. The hardware target is a single **Mac Studio M3 Ultra** (96 GB unified RAM): all on-box compute, no external GPU/CUDA, no cloud proving services. Performance budget: warm proof ≤ 5 s (target ≤ 1 s), cold-start ≤ 30 s, memory peak < 64 GB — if a design overshoots, the design changes. See [docs.zkcoins.com/specification](https://docs.zkcoins.com/specification).
-| Repo | Purpose |
-| --------------------------------------------------------- | ------------------------------------------------------------ |
-| [zk-coins/app](https://github.com/zk-coins/app) | Web application (frontend, PWA) |
-| [zk-coins/docs](https://github.com/zk-coins/docs) | Documentation ([docs.zkcoins.app](https://docs.zkcoins.app)) |
-| [zk-coins/research](https://github.com/zk-coins/research) | Protocol research, upstream repos, paper PDF |
+### Branch flow
-## Design Documents
+Feature PRs land on **`staging`** first (the integration buffer), are batched into **`develop`** (auto-PR'd, deploys to the DEV node), and promoted to **`main`** (auto-PR'd, deploys to the PRD node). `develop` and `main` are protected — no direct pushes. Maintainers merge PRs; contributors open them as drafts. See [`CONTRIBUTING.md` § Git workflow](./CONTRIBUTING.md#git-workflow) for the full table and conventions.
-Protocol design drafts (LN atomic swap, BitVM/Glock bridge, multi-asset, Arkade
-integration, migration research) and the circuit/single-asset spec live in the
-research repo under [`zk-coins/research` → `zkcoins-design/`](https://github.com/zk-coins/research/tree/develop/zkcoins-design).
-The target-design protocol specification and the roadmap are published on the docs
-site: [docs.zkcoins.app/specification](https://docs.zkcoins.app/specification) and
-[docs.zkcoins.app/roadmap](https://docs.zkcoins.app/roadmap).
+| Branch | Purpose | Deploy target |
+| --- | --- | --- |
+| `staging` | Integration buffer — feature PRs land here first | none |
+| `develop` | Active development, promoted from `staging` in batches | DEV node |
+| `main` | Production releases, promoted from `develop` | PRD node |
## Protocol
-Based on [Shielded CSV](https://eprint.iacr.org/2025/068) by Jonas Nick (Blockstream), Liam Eagen (Alpen Labs), Robin Linus (ZeroSync). Node code derived from [ZeroSync/ZKCoins](https://github.com/ZeroSync/ZKCoins).
+Based on [Shielded CSV](https://eprint.iacr.org/2025/068) by Jonas Nick (Blockstream), Liam Eagen (Alpen Labs), and Robin Linus (ZeroSync). Node code derived from [ZeroSync/ZKCoins](https://github.com/ZeroSync/ZKCoins). Protocol design drafts and the spec live in [`zk-coins/research`](https://github.com/zk-coins/research/tree/develop/zkcoins-design) and on the docs site: [docs.zkcoins.com/specification](https://docs.zkcoins.com/specification) · [docs.zkcoins.com/roadmap](https://docs.zkcoins.com/roadmap).
+
+## Contributing
+
+See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for setup, coding standards, the coverage gate, and the PR flow. Security policy: [`SECURITY.md`](./SECURITY.md).
## License
-MIT
+MIT — see [`LICENSE`](./LICENSE).
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 00000000..532d15b7
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,604 @@
+# Local developer stack for the zkCoins node (Stage-3 binary) + public API.
+#
+# Services are derived from the node/api bootstrap code, not from guesswork:
+# - postgres:17 — state layer (node/src/test_db.rs with_tag("17"), README)
+# - bitcoind — regtest RPC for Stage-3 NfLog scan + AggregateStateNullifierV3
+# publish (node/src/v1/scan.rs, node/src/v1/publish.rs)
+# - nostr-relay — NIP-01 WebSocket relay (node/src/v1/nostr/relay.rs;
+# testcontainers pin scsibug/nostr-rs-relay:0.8.13)
+# - node — Dockerfile + 0.0.0.0:4242 REST + KERNEL_GRPC_ADDR gRPC
+# - api — sibling zk-coins/api Dockerfile; REST §7.5 over kernel gRPC
+#
+# NOT a compose service (see docs/local-stack.md):
+# - electrs / Esplora — still required by residual NETWORK_CONFIG +
+# /health/ready (lib.rs build_network_config_from_env,
+# router.rs check_esplora); not in this file
+# - wallet / SDK — signing and operational-bundle material stay off-compose
+#
+# No restart:always — a boot failure must stay visible.
+# No invented PUBLISHER_KEY / chain pins — compose fails at parse if missing.
+# No IS_MAINNET=true.
+# No latest-tag images.
+
+name: zkcoins-local
+
+services:
+ postgres:
+ # Version pin: testcontainers and README use postgres:17
+ # (node/src/test_db.rs:348 `.with_tag("17")`, README.md local Postgres).
+ image: postgres:17
+ environment:
+ POSTGRES_USER: zkcoins
+ # Local-only DB password for the compose network (not a crypto key).
+ # Documented in docs/local-stack.md. Do not reuse outside this stack.
+ POSTGRES_PASSWORD: localdev
+ POSTGRES_DB: zkcoins
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ # Real Postgres readiness — not a always-true probe.
+ test: ["CMD-SHELL", "pg_isready -U zkcoins -d zkcoins"]
+ interval: 5s
+ timeout: 5s
+ retries: 12
+ start_period: 10s
+
+ bitcoind:
+ # Image pin: repo (CI, docs, tests) does not name a bitcoind version.
+ # Chosen: bitcoin/bitcoin:31.1 — multi-platform Debian image from the
+ # willcl-ark/bitcoin-core-docker line on Docker Hub (not `latest`),
+ # carrying Bitcoin Core 31.1 release binaries. Compatible with this
+ # tree's bitcoincore-rpc 0.19 client (node/Cargo.toml, script-plonky2).
+ # Regtest only — never mainnet.
+ image: bitcoin/bitcoin:31.1
+ command:
+ - -printtoconsole
+ - -regtest=1
+ - -server=1
+ - -txindex=1
+ - -rest=1
+ - -rpcallowip=0.0.0.0/0
+ - -rpcbind=0.0.0.0
+ - -fallbackfee=0.0002
+ ports:
+ # Regtest JSON-RPC/REST (docs for bitcoin/bitcoin image; tests use :18443).
+ - "18443:18443"
+ volumes:
+ # Default datadir of the image: /home/bitcoin/.bitcoin
+ # Cookie (regtest): /home/bitcoin/.bitcoin/regtest/.cookie
+ - bitcoind_data:/home/bitcoin/.bitcoin
+ healthcheck:
+ # Cookie auth + RPC: bitcoin-cli reads the cookie from the datadir.
+ # Fails until bitcoind has written regtest/.cookie and answers RPC.
+ test:
+ [
+ "CMD",
+ "bitcoin-cli",
+ "-regtest",
+ "-datadir=/home/bitcoin/.bitcoin",
+ "getblockchaininfo",
+ ]
+ interval: 5s
+ timeout: 5s
+ retries: 12
+ start_period: 15s
+
+ nostr-relay:
+ # Image pin: scsibug/nostr-rs-relay:0.8.13 — same tag as the
+ # testcontainers integration tests in node/src/v1/nostr/relay.rs
+ # (RELAY_IMAGE / RELAY_TAG). Starts with the image default config
+ # (listen 0.0.0.0:8080, on-disk SQLite). Not `latest`.
+ # WebSocket NIP-01 endpoint: ws://nostr-relay:8080/ (compose DNS).
+ # Host publish is 18080 (not 8080): host 8080 is the api REST surface
+ # below — both containers listen on 8080 internally, but only one
+ # process can bind a given host port.
+ image: scsibug/nostr-rs-relay:0.8.13
+ ports:
+ - "18080:8080"
+ volumes:
+ - nostr_relay_data:/usr/src/app/db
+ healthcheck:
+ # TCP readiness on the relay listen port — the image has bash;
+ # fails until the process accepts connections on 8080.
+ test:
+ [
+ "CMD-SHELL",
+ "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'",
+ ]
+ interval: 5s
+ timeout: 5s
+ retries: 12
+ start_period: 15s
+
+ node:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ depends_on:
+ postgres:
+ condition: service_healthy
+ bitcoind:
+ condition: service_healthy
+ ports:
+ # Listener address is hard-coded: node/src/main.rs ACCOUNT_NODE_ADDR
+ # "0.0.0.0:4242"; Dockerfile EXPOSE 4242.
+ - "4242:4242"
+ # Kernel gRPC (KERNEL_GRPC_ADDR). Host-side zk-coins/api dials this.
+ - "50051:50051"
+ volumes:
+ - node_data:/data
+ # Stage-3 scanner + publisher cookie auth (v1/scan.rs, v1/publish.rs).
+ # Same volume as bitcoind; cookie path matches regtest datadir layout.
+ - type: volume
+ source: bitcoind_data
+ target: /run/bitcoind-data
+ read_only: true
+ # Signed §4.3 BootstrapManifest (BMF1). Host path is operator-supplied
+ # (produce with `gen_bootstrap_manifest` — see docs/local-stack.md).
+ # No silent default artifact; compose fails at parse if the host path
+ # env is unset. Container path is fixed so the node env pin is stable.
+ - type: bind
+ source: ${ZKCOINS_V1_BOOTSTRAP_MANIFEST_HOST_PATH:?set ZKCOINS_V1_BOOTSTRAP_MANIFEST_HOST_PATH to the host path of a signed BMF1 artifact}
+ target: /run/bootstrap/manifest.bmf1
+ read_only: true
+ # Shared verifier cache: node1 (primary) writes; node2 (secondary) loads.
+ - verifier_cache_shared:/data/verifier_cache
+ environment:
+ # --- Postgres (internal compose network; matches postgres service) ---
+ # Panic site if unset outside compose: node/src/lib.rs DATABASE_URL lazy_static.
+ DATABASE_URL: postgresql://zkcoins:localdev@postgres:5432/zkcoins
+
+ # --- Chain label for residual EsploraConfig (NOT mainnet) ---
+ # Panic: node/src/lib.rs build_network_config_from_env IS_MAINNET.
+ # Only exact "true" | "false". Local stack is never mainnet.
+ IS_MAINNET: "false"
+ NETWORK_NAME: regtest
+
+ # Still required at REST bootstrap even though Stage-3 NfLog scan is
+ # bitcoind RPC (main.rs run_v1_scan_loop). NETWORK_CONFIG panics without
+ # them (lib.rs); /health/ready pings ESPLORA_URL (router.rs ready_handler).
+ ESPLORA_URL: ${ESPLORA_URL:?set ESPLORA_URL to a live Esplora HTTP base URL}
+ ESPLORA_WS_URL: ${ESPLORA_WS_URL:?set ESPLORA_WS_URL to a live Esplora WebSocket URL}
+
+ # --- Publisher key (crypto secret — never invent a default) ---
+ # Panic: node/src/lib.rs PUBLISHER_KEY lazy_static.
+ # Quoted: the `: ` inside the :? message is a YAML mapping separator in an
+ # unquoted scalar — without the quotes `docker compose config` rejects the
+ # whole file, which is why this stack had never been validated.
+ PUBLISHER_KEY: "${PUBLISHER_KEY:?set PUBLISHER_KEY — generate with `openssl rand -hex 32`}"
+
+ # --- Username domain returned by /api/info ---
+ # Panic: node/src/lib.rs USERNAME_DOMAIN lazy_static.
+ USERNAME_DOMAIN: ${USERNAME_DOMAIN:?set USERNAME_DOMAIN e.g. local.zkcoins.test}
+
+ # --- Stage 3 exclusive stack (binary refuses Off) ---
+ # Panic: node/src/main.rs match shadow_mode V1ShadowMode::Off.
+ ZKCOINS_V1_SHADOW: "1"
+
+ # §3.6 boot pins — missing any → V1_BOOT_CONFIG_ERROR
+ # (node/src/v1/mode.rs v1_boot_pins_from_env).
+ # Regtest activation_height is pinned at 0 (mode.rs validate_v1_boot_pins).
+ ZKCOINS_NETWORK: regtest
+ ZKCOINS_ACTIVATION_HEIGHT: "0"
+ ZKCOINS_CIRCUIT_DIGEST_C: ${ZKCOINS_CIRCUIT_DIGEST_C:?set ZKCOINS_CIRCUIT_DIGEST_C (64 lowercase hex; see docs/local-stack.md)}
+ ZKCOINS_CIRCUIT_DIGEST_C_BALANCE: ${ZKCOINS_CIRCUIT_DIGEST_C_BALANCE:?set ZKCOINS_CIRCUIT_DIGEST_C_BALANCE (64 lowercase hex; see docs/local-stack.md)}
+ ZKCOINS_BOOTSTRAP_PUBKEY: ${ZKCOINS_BOOTSTRAP_PUBKEY:?set ZKCOINS_BOOTSTRAP_PUBKEY (64 lowercase hex x-only)}
+ ZKCOINS_EXPECTED_PARAMS_IDENTIFIER: ${ZKCOINS_EXPECTED_PARAMS_IDENTIFIER:?set ZKCOINS_EXPECTED_PARAMS_IDENTIFIER (SHA-256 of canonical network-params; see docs/local-stack.md)}
+
+ # Stage-3 scanner + publisher: bitcoind RPC only
+ # (node/src/v1/scan.rs v1_bitcoind_rpc_from_env;
+ # node/src/v1/publish.rs v1_publisher_env_from_env).
+ # Production names (not the live-test ZKCOINS_REGTEST_* aliases):
+ # URL form matches tests: http://host:18443 (no /wallet/… suffix)
+ # cookie: path to .cookie file
+ # wallet: loaded bitcoind wallet name
+ # Compose-internal service DNS — not host.docker.internal.
+ ZKCOINS_V1_BITCOIND_RPC_URL: http://bitcoind:18443
+ ZKCOINS_V1_BITCOIND_COOKIE_PATH: /run/bitcoind-data/regtest/.cookie
+
+ # Publisher wallet + fee + reveal — required for a mint that reaches
+ # completed (finalise handoff runs construct/broadcast; boot also
+ # aborts if pending rows exist without these — main.rs).
+ # Wallet must already exist and be funded before publish (docs).
+ ZKCOINS_V1_BITCOIND_WALLET: ${ZKCOINS_V1_BITCOIND_WALLET:?set ZKCOINS_V1_BITCOIND_WALLET to a loaded bitcoind wallet name}
+ ZKCOINS_V1_FEE_RATE_SAT_PER_VB: ${ZKCOINS_V1_FEE_RATE_SAT_PER_VB:?set ZKCOINS_V1_FEE_RATE_SAT_PER_VB integer > 0}
+ ZKCOINS_V1_REVEAL_OUTPUT_SATS: ${ZKCOINS_V1_REVEAL_OUTPUT_SATS:?set ZKCOINS_V1_REVEAL_OUTPUT_SATS integer > 0}
+
+ # Optional: comma-separated hosts for attest channel binding
+ # (node/src/v1/attest.rs public_hosts_from_env — empty is OK, fails loud on use).
+ ZKCOINS_PUBLIC_HOST: ${ZKCOINS_PUBLIC_HOST:-}
+
+ # GetInfo / ChainIdentity operational pins (node/src/kernel/chain.rs +
+ # runtime::require_chain_identity_ops_from_env). No defaults — missing
+ # aborts at parse or binary edge. Complete ChainIdentity also needs a
+ # verified §4.3 BootstrapManifest (BMF1) under the path below — produce
+ # it with `gen_bootstrap_manifest` (docs/local-stack.md).
+ ZKCOINS_RELAY_URL: ${ZKCOINS_RELAY_URL:?set ZKCOINS_RELAY_URL (operator-chosen Nostr relay URL for this node)}
+ ZKCOINS_BLOSSOM_URL: ${ZKCOINS_BLOSSOM_URL:?set ZKCOINS_BLOSSOM_URL (operator-chosen Blossom base URL for this node)}
+ ZKCOINS_MAX_BLOB_BYTES: ${ZKCOINS_MAX_BLOB_BYTES:?set ZKCOINS_MAX_BLOB_BYTES integer > 0}
+ ZKCOINS_KERNEL_PARTS: ${ZKCOINS_KERNEL_PARTS:?set ZKCOINS_KERNEL_PARTS e.g. scanner,prover,publisher}
+
+ # §7.6 AcceptFeeLess batch interval (seconds). Required when
+ # kernel_parts includes publisher — no invented default
+ # (runtime.rs ZKCOINS_PUBLISH_BATCH_ETA_SECS; missing eta with
+ # publisher role → internal_error on Publish).
+ ZKCOINS_PUBLISH_BATCH_ETA_SECS: ${ZKCOINS_PUBLISH_BATCH_ETA_SECS:?set ZKCOINS_PUBLISH_BATCH_ETA_SECS to a non-negative integer batch interval in seconds}
+
+ # §4.3 / §7.7 BMF1 path inside the container (bind-mounted above).
+ # Fixed path — host location is ZKCOINS_V1_BOOTSTRAP_MANIFEST_HOST_PATH.
+ # Artifact must verify under ZKCOINS_BOOTSTRAP_PUBKEY or boot aborts.
+ ZKCOINS_V1_BOOTSTRAP_MANIFEST_PATH: /run/bootstrap/manifest.bmf1
+
+ # Kernel gRPC bind (node/src/kernel_rpc.rs KERNEL_GRPC_ADDR — no default).
+ # Must be 0.0.0.0 so the host-side api can reach it via published 50051.
+ KERNEL_GRPC_ADDR: ${KERNEL_GRPC_ADDR:?set KERNEL_GRPC_ADDR e.g. 0.0.0.0:50051}
+
+ PROOFS_DIR: /data/proofs
+ ZKCOINS_VERIFIER_CACHE_DIR: /data/verifier_cache
+ ZKCOINS_PROVER_LEASE_PATH: /data/verifier_cache/prover.lease
+ ZKCOINS_PROVER_IDLE_TTL_SECS: "180"
+ # Verifier-cache role: primary builds circuits and WRITES the shared cache
+ # that node2 (secondary) loads (node/src/v1/mode.rs verifier_cache_role_from_env).
+ # Unset also resolves to Primary — explicit here for clarity across two nodes.
+ ZKCOINS_VERIFIER_CACHE_ROLE: primary
+ RUST_LOG: ${RUST_LOG:-info}
+ healthcheck:
+ # Liveness only: GET /health returns "ok" once the TCP listener is bound
+ # (node/src/router.rs health_handler). Does not mask dependency failure —
+ # /health/ready is the readiness probe (db + esplora + prover + v1_scan).
+ # /health is served only after §1.7.9 circuits (C + C_balance) stand —
+ # cold construction is multi-minute, not a hung process. start_period must
+ # cover that window so failed probes do not burn retries into unhealthy.
+ test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4242/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 30
+ # 20 min: cold Plonky2 circuit construction before /health is bound.
+ # Aligns with deploy/local-e2e/up.sh node wait (1200s). Not a hang budget.
+ start_period: 1200s
+
+ api:
+ # Build context is the sibling checkout of zk-coins/api next to this
+ # node worktree: …/zk-coins/api when this file lives in …/zk-coins/node
+ # (compose path ../api). That layout is an operator assumption, not a
+ # monorepo guarantee. If the api repo is elsewhere, point
+ # build.context at that path (or build/tag an image yourself and
+ # replace this block with `image:`) — there is no fallback context
+ # and no pre-built registry pin in this stack.
+ build:
+ context: ../api
+ dockerfile: Dockerfile
+ # depends_on — only what the api process actually dials:
+ # node (YES): ZKCOINS_KERNEL_ADDR → kernel gRPC (api/src/config.rs,
+ # api/src/main.rs connect_lazy, api/src/kernel/client.rs). Every
+ # non-local REST surface is a kernel RPC pass-through.
+ # postgres (NO): api holds no value-bearing DB and has no DATABASE_URL
+ # (api/src/config.rs closed env set). Postgres is node-only.
+ # bitcoind (NO): api never speaks Bitcoin RPC; scan/publish stay in
+ # the kernel (node/src/v1/scan.rs, node/src/v1/publish.rs).
+ # nostr-relay (NO): NIP-01 transport and delivery live in the node
+ # path, not in the api process (api only proxies kernel RPCs;
+ # local-stack.md: node client not yet wired into send/receive).
+ depends_on:
+ node:
+ condition: service_healthy
+ ports:
+ # Operator bind: ZKCOINS_BIND_ADDR (api/src/config.rs + main.rs).
+ # Local-stack convention inside the container: 0.0.0.0:8080
+ # (Dockerfile EXPOSE 8080) — not a binary default.
+ - "8080:8080"
+ volumes:
+ # §7.4 content-addressed Blossom store (api/src/config.rs
+ # ZKCOINS_BLOSSOM_STORE; api/src/blossom/store.rs BlobStore::open).
+ - api_blossom_data:/data/blossom
+ environment:
+ # --- Pflicht (api/src/config.rs Config::from_env) — no defaults ---
+ ZKCOINS_BIND_ADDR: "0.0.0.0:8080"
+ # Compose DNS → node KERNEL_GRPC_ADDR (published host 50051).
+ ZKCOINS_KERNEL_ADDR: "http://node:50051"
+ # Variable required; unknown token is a start error. Full pass needs
+ # at least wallet,explorer (docs/local-stack.md). Empty string =
+ # all features off is allowed by the binary but rejected here by
+ # ${…:?} so an operator must name the set explicitly.
+ ZKCOINS_FEATURES: ${ZKCOINS_FEATURES:?set ZKCOINS_FEATURES e.g. wallet,explorer}
+ # Variable required; empty string allowed (OwnershipProof / session
+ # surfaces fail loud — mint/sign/nullifier do not need it).
+ # For host-side wallets dialing http://127.0.0.1:8080 the SDK derives
+ # chan_bind from host "127.0.0.1:8080" (sdk/src/v1/ownership.ts
+ # canonicalHostFromApiUrl) — set that exact string when using
+ # bootstrap/pull/attest/grants from the host.
+ ZKCOINS_PUBLIC_HOST: ${ZKCOINS_PUBLIC_HOST:-}
+
+ # --- Blossom (§7.4) — store set ⇒ companions Pflicht ---
+ # Store path matches the volume mount. Absent store would leave
+ # blossom routes unmounted; this stack mounts the store so delivery
+ # blobs have a local holder.
+ ZKCOINS_BLOSSOM_STORE: /data/blossom
+ ZKCOINS_BLOSSOM_MAX_BLOB_BYTES: ${ZKCOINS_BLOSSOM_MAX_BLOB_BYTES:?set ZKCOINS_BLOSSOM_MAX_BLOB_BYTES integer > 0}
+ # Variable required when store is set; empty string allowed
+ # (surface up, every upload 403 — api/src/config.rs).
+ ZKCOINS_BLOSSOM_ALLOWED_OPS: ${ZKCOINS_BLOSSOM_ALLOWED_OPS:-}
+
+ RUST_LOG: ${RUST_LOG:-info}
+ healthcheck:
+ # Liveness only: GET /health → body "ok" once the TCP listener is
+ # bound (api/src/routes.rs health). Do **not** probe /health/ready
+ # here: that path is a GetInfo projection (api/src/info.rs
+ # health_ready) and depends on kernel ChainIdentity (verified BMF1 +
+ # ops pins). A depends_on on ready would park the stack on kernel
+ # identity issues without proving the REST listener is up.
+ test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 12
+ start_period: 30s
+
+ postgres2:
+ # Version pin: testcontainers and README use postgres:17
+ # (node/src/test_db.rs:348 `.with_tag("17")`, README.md local Postgres).
+ image: postgres:17
+ environment:
+ POSTGRES_USER: zkcoins
+ # Local-only DB password for the compose network (not a crypto key).
+ # Documented in docs/local-stack.md. Do not reuse outside this stack.
+ POSTGRES_PASSWORD: localdev
+ POSTGRES_DB: zkcoins
+ volumes:
+ - postgres2_data:/var/lib/postgresql/data
+ healthcheck:
+ # Real Postgres readiness — not a always-true probe.
+ test: ["CMD-SHELL", "pg_isready -U zkcoins -d zkcoins"]
+ interval: 5s
+ timeout: 5s
+ retries: 12
+ start_period: 10s
+
+ node2:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ depends_on:
+ postgres2:
+ condition: service_healthy
+ bitcoind:
+ condition: service_healthy
+ node:
+ condition: service_healthy
+ ports:
+ # Listener address is hard-coded: node/src/main.rs ACCOUNT_NODE_ADDR
+ # "0.0.0.0:4242"; Dockerfile EXPOSE 4242. Host 4243 avoids node1 collision.
+ - "4243:4242"
+ # Kernel gRPC (KERNEL_GRPC_ADDR). Host 50052 avoids node1's 50051.
+ - "50052:50051"
+ volumes:
+ - node2_data:/data
+ # Stage-3 scanner + publisher cookie auth (v1/scan.rs, v1/publish.rs).
+ # Same volume as bitcoind; cookie path matches regtest datadir layout.
+ - type: volume
+ source: bitcoind_data
+ target: /run/bitcoind-data
+ read_only: true
+ # Signed §4.3 BootstrapManifest (BMF1). Host path is operator-supplied
+ # (produce with `gen_bootstrap_manifest` — see docs/local-stack.md).
+ # No silent default artifact; compose fails at parse if the host path
+ # env is unset. Container path is fixed so the node env pin is stable.
+ # Same BMF1 as node1 (same network, same bootstrap trust anchor).
+ - type: bind
+ source: ${ZKCOINS_V1_BOOTSTRAP_MANIFEST_HOST_PATH:?set ZKCOINS_V1_BOOTSTRAP_MANIFEST_HOST_PATH to the host path of a signed BMF1 artifact}
+ target: /run/bootstrap/manifest.bmf1
+ read_only: true
+ # Shared verifier cache written by node1 (primary); secondary only reads
+ # (node/src/main.rs VerifierCacheRole::Secondary → load_balance_verifier_cache_checked).
+ - type: volume
+ source: verifier_cache_shared
+ target: /data/verifier_cache
+ read_only: true
+ environment:
+ # --- Postgres (internal compose network; matches postgres2 service) ---
+ # Panic site if unset outside compose: node/src/lib.rs DATABASE_URL lazy_static.
+ DATABASE_URL: postgresql://zkcoins:localdev@postgres2:5432/zkcoins
+
+ # --- Chain label for residual EsploraConfig (NOT mainnet) ---
+ # Panic: node/src/lib.rs build_network_config_from_env IS_MAINNET.
+ # Only exact "true" | "false". Local stack is never mainnet.
+ IS_MAINNET: "false"
+ NETWORK_NAME: regtest
+
+ # Still required at REST bootstrap even though Stage-3 NfLog scan is
+ # bitcoind RPC (main.rs run_v1_scan_loop). NETWORK_CONFIG panics without
+ # them (lib.rs); /health/ready pings ESPLORA_URL (router.rs ready_handler).
+ ESPLORA_URL: ${ESPLORA_URL:?set ESPLORA_URL to a live Esplora HTTP base URL}
+ ESPLORA_WS_URL: ${ESPLORA_WS_URL:?set ESPLORA_WS_URL to a live Esplora WebSocket URL}
+
+ # --- Publisher key (crypto secret — never invent a default) ---
+ # Panic: node/src/lib.rs PUBLISHER_KEY lazy_static.
+ # Quoted: the `: ` inside the :? message is a YAML mapping separator in an
+ # unquoted scalar — without the quotes `docker compose config` rejects the
+ # whole file, which is why this stack had never been validated.
+ PUBLISHER_KEY: "${PUBLISHER_KEY_2:?set PUBLISHER_KEY_2 — a second openssl rand -hex 32 for node2's identity, distinct from PUBLISHER_KEY}"
+
+ # --- Username domain returned by /api/info ---
+ # Panic: node/src/lib.rs USERNAME_DOMAIN lazy_static.
+ USERNAME_DOMAIN: ${USERNAME_DOMAIN:?set USERNAME_DOMAIN e.g. local.zkcoins.test}
+
+ # --- Stage 3 exclusive stack (binary refuses Off) ---
+ # Panic: node/src/main.rs match shadow_mode V1ShadowMode::Off.
+ ZKCOINS_V1_SHADOW: "1"
+
+ # §3.6 boot pins — missing any → V1_BOOT_CONFIG_ERROR
+ # (node/src/v1/mode.rs v1_boot_pins_from_env).
+ # Regtest activation_height is pinned at 0 (mode.rs validate_v1_boot_pins).
+ ZKCOINS_NETWORK: regtest
+ ZKCOINS_ACTIVATION_HEIGHT: "0"
+ ZKCOINS_CIRCUIT_DIGEST_C: ${ZKCOINS_CIRCUIT_DIGEST_C:?set ZKCOINS_CIRCUIT_DIGEST_C (64 lowercase hex; see docs/local-stack.md)}
+ ZKCOINS_CIRCUIT_DIGEST_C_BALANCE: ${ZKCOINS_CIRCUIT_DIGEST_C_BALANCE:?set ZKCOINS_CIRCUIT_DIGEST_C_BALANCE (64 lowercase hex; see docs/local-stack.md)}
+ ZKCOINS_BOOTSTRAP_PUBKEY: ${ZKCOINS_BOOTSTRAP_PUBKEY:?set ZKCOINS_BOOTSTRAP_PUBKEY (64 lowercase hex x-only)}
+ ZKCOINS_EXPECTED_PARAMS_IDENTIFIER: ${ZKCOINS_EXPECTED_PARAMS_IDENTIFIER:?set ZKCOINS_EXPECTED_PARAMS_IDENTIFIER (SHA-256 of canonical network-params; see docs/local-stack.md)}
+
+ # Stage-3 scanner + publisher: bitcoind RPC only
+ # (node/src/v1/scan.rs v1_bitcoind_rpc_from_env;
+ # node/src/v1/publish.rs v1_publisher_env_from_env).
+ # Production names (not the live-test ZKCOINS_REGTEST_* aliases):
+ # URL form matches tests: http://host:18443 (no /wallet/… suffix)
+ # cookie: path to .cookie file
+ # wallet: loaded bitcoind wallet name
+ # Compose-internal service DNS — not host.docker.internal.
+ ZKCOINS_V1_BITCOIND_RPC_URL: http://bitcoind:18443
+ ZKCOINS_V1_BITCOIND_COOKIE_PATH: /run/bitcoind-data/regtest/.cookie
+
+ # Publisher wallet + fee + reveal — required for a mint that reaches
+ # completed (finalise handoff runs construct/broadcast; boot also
+ # aborts if pending rows exist without these — main.rs).
+ # Wallet must already exist and be funded before publish (docs).
+ ZKCOINS_V1_BITCOIND_WALLET: ${ZKCOINS_V1_BITCOIND_WALLET_2:?set ZKCOINS_V1_BITCOIND_WALLET_2 to a loaded bitcoind wallet name for node2, e.g. zkcoins2}
+ ZKCOINS_V1_FEE_RATE_SAT_PER_VB: ${ZKCOINS_V1_FEE_RATE_SAT_PER_VB:?set ZKCOINS_V1_FEE_RATE_SAT_PER_VB integer > 0}
+ ZKCOINS_V1_REVEAL_OUTPUT_SATS: ${ZKCOINS_V1_REVEAL_OUTPUT_SATS:?set ZKCOINS_V1_REVEAL_OUTPUT_SATS integer > 0}
+
+ # Optional: comma-separated hosts for attest channel binding
+ # (node/src/v1/attest.rs public_hosts_from_env — empty is OK, fails loud on use).
+ ZKCOINS_PUBLIC_HOST: ${ZKCOINS_PUBLIC_HOST_2:-}
+ ZKCOINS_V1_RECOVERY: "1"
+ ZKCOINS_V1_RECOVERY_PAGE_LIMIT: "500"
+ ZKCOINS_V1_RECOVERY_EARLIEST: "0"
+
+ # GetInfo / ChainIdentity operational pins (node/src/kernel/chain.rs +
+ # runtime::require_chain_identity_ops_from_env). No defaults — missing
+ # aborts at parse or binary edge. Complete ChainIdentity also needs a
+ # verified §4.3 BootstrapManifest (BMF1) under the path below — produce
+ # it with `gen_bootstrap_manifest` (docs/local-stack.md).
+ ZKCOINS_RELAY_URL: ${ZKCOINS_RELAY_URL:?set ZKCOINS_RELAY_URL (operator-chosen Nostr relay URL for this node)}
+ ZKCOINS_BLOSSOM_URL: ${ZKCOINS_BLOSSOM_URL_2:?set ZKCOINS_BLOSSOM_URL_2 (operator-chosen Blossom base URL for node2, e.g. http://api2:8080/)}
+ ZKCOINS_MAX_BLOB_BYTES: ${ZKCOINS_MAX_BLOB_BYTES:?set ZKCOINS_MAX_BLOB_BYTES integer > 0}
+ ZKCOINS_KERNEL_PARTS: ${ZKCOINS_KERNEL_PARTS:?set ZKCOINS_KERNEL_PARTS e.g. scanner,prover,publisher}
+
+ # §7.6 AcceptFeeLess batch interval (seconds). Required when
+ # kernel_parts includes publisher — no invented default
+ # (runtime.rs ZKCOINS_PUBLISH_BATCH_ETA_SECS; missing eta with
+ # publisher role → internal_error on Publish).
+ ZKCOINS_PUBLISH_BATCH_ETA_SECS: ${ZKCOINS_PUBLISH_BATCH_ETA_SECS:?set ZKCOINS_PUBLISH_BATCH_ETA_SECS to a non-negative integer batch interval in seconds}
+
+ # §4.3 / §7.7 BMF1 path inside the container (bind-mounted above).
+ # Fixed path — host location is ZKCOINS_V1_BOOTSTRAP_MANIFEST_HOST_PATH.
+ # Artifact must verify under ZKCOINS_BOOTSTRAP_PUBKEY or boot aborts.
+ ZKCOINS_V1_BOOTSTRAP_MANIFEST_PATH: /run/bootstrap/manifest.bmf1
+
+ # Kernel gRPC bind (node/src/kernel_rpc.rs KERNEL_GRPC_ADDR — no default).
+ # Must be 0.0.0.0 so the host-side api can reach it via published 50052.
+ KERNEL_GRPC_ADDR: ${KERNEL_GRPC_ADDR:?set KERNEL_GRPC_ADDR e.g. 0.0.0.0:50051}
+
+ PROOFS_DIR: /data/proofs
+ ZKCOINS_VERIFIER_CACHE_DIR: /data/verifier_cache
+ ZKCOINS_PROVER_LEASE_PATH: /data/verifier_cache/prover.lease
+ ZKCOINS_PROVER_IDLE_TTL_SECS: "180"
+ # Verifier-cache role: secondary LOADS the shared cache written by node1
+ # (node/src/v1/mode.rs verifier_cache_role_from_env; main.rs Secondary).
+ ZKCOINS_VERIFIER_CACHE_ROLE: secondary
+ RUST_LOG: ${RUST_LOG:-info}
+ healthcheck:
+ # Liveness only: GET /health returns "ok" once the TCP listener is bound
+ # (node/src/router.rs health_handler). Does not mask dependency failure —
+ # /health/ready is the readiness probe (db + esplora + prover + v1_scan).
+ # /health is served only after §1.7.9 circuits (C + C_balance) stand —
+ # cold construction is multi-minute, not a hung process. start_period must
+ # cover that window so failed probes do not burn retries into unhealthy.
+ # Container-internal port remains 4242 (host mapping is 4243).
+ test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4242/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 30
+ # 20 min: cold Plonky2 circuit construction before /health is bound.
+ # Aligns with deploy/local-e2e/up.sh node wait (1200s). Not a hang budget.
+ start_period: 1200s
+
+ api2:
+ # Build context is the sibling checkout of zk-coins/api next to this
+ # node worktree: …/zk-coins/api when this file lives in …/zk-coins/node
+ # (compose path ../api). That layout is an operator assumption, not a
+ # monorepo guarantee. If the api repo is elsewhere, point
+ # build.context at that path (or build/tag an image yourself and
+ # replace this block with `image:`) — there is no fallback context
+ # and no pre-built registry pin in this stack.
+ build:
+ context: ../api
+ dockerfile: Dockerfile
+ # depends_on — only what the api process actually dials:
+ # node2 (YES): ZKCOINS_KERNEL_ADDR → kernel gRPC (api/src/config.rs,
+ # api/src/main.rs connect_lazy, api/src/kernel/client.rs). Every
+ # non-local REST surface is a kernel RPC pass-through.
+ # postgres (NO): api holds no value-bearing DB and has no DATABASE_URL
+ # (api/src/config.rs closed env set). Postgres is node-only.
+ # bitcoind (NO): api never speaks Bitcoin RPC; scan/publish stay in
+ # the kernel (node/src/v1/scan.rs, node/src/v1/publish.rs).
+ # nostr-relay (NO): NIP-01 transport and delivery live in the node
+ # path, not in the api process (api only proxies kernel RPCs;
+ # local-stack.md: node client not yet wired into send/receive).
+ depends_on:
+ node2:
+ condition: service_healthy
+ ports:
+ # Operator bind: ZKCOINS_BIND_ADDR (api/src/config.rs + main.rs).
+ # Local-stack convention inside the container: 0.0.0.0:8080
+ # (Dockerfile EXPOSE 8080) — not a binary default. Host 8081 avoids api.
+ - "8081:8080"
+ volumes:
+ # §7.4 content-addressed Blossom store (api/src/config.rs
+ # ZKCOINS_BLOSSOM_STORE; api/src/blossom/store.rs BlobStore::open).
+ - api2_blossom_data:/data/blossom
+ environment:
+ # --- Pflicht (api/src/config.rs Config::from_env) — no defaults ---
+ ZKCOINS_BIND_ADDR: "0.0.0.0:8080"
+ # Compose DNS → node2 KERNEL_GRPC_ADDR (container-internal 50051).
+ ZKCOINS_KERNEL_ADDR: "http://node2:50051"
+ # Variable required; unknown token is a start error. Full pass needs
+ # at least wallet,explorer (docs/local-stack.md). Empty string =
+ # all features off is allowed by the binary but rejected here by
+ # ${…:?} so an operator must name the set explicitly.
+ ZKCOINS_FEATURES: ${ZKCOINS_FEATURES:?set ZKCOINS_FEATURES e.g. wallet,explorer}
+ # Variable required; empty string allowed (OwnershipProof / session
+ # surfaces fail loud — mint/sign/nullifier do not need it).
+ # For host-side wallets dialing http://127.0.0.1:8080 the SDK derives
+ # chan_bind from host "127.0.0.1:8080" (sdk/src/v1/ownership.ts
+ # canonicalHostFromApiUrl) — set that exact string when using
+ # bootstrap/pull/attest/grants from the host.
+ ZKCOINS_PUBLIC_HOST: ${ZKCOINS_PUBLIC_HOST_2:-}
+
+ # --- Blossom (§7.4) — store set ⇒ companions Pflicht ---
+ # Store path matches the volume mount. Absent store would leave
+ # blossom routes unmounted; this stack mounts the store so delivery
+ # blobs have a local holder.
+ ZKCOINS_BLOSSOM_STORE: /data/blossom
+ ZKCOINS_BLOSSOM_MAX_BLOB_BYTES: ${ZKCOINS_BLOSSOM_MAX_BLOB_BYTES:?set ZKCOINS_BLOSSOM_MAX_BLOB_BYTES integer > 0}
+ # Variable required when store is set; empty string allowed
+ # (surface up, every upload 403 — api/src/config.rs).
+ ZKCOINS_BLOSSOM_ALLOWED_OPS: ${ZKCOINS_BLOSSOM_ALLOWED_OPS:-}
+
+ RUST_LOG: ${RUST_LOG:-info}
+ healthcheck:
+ # Liveness only: GET /health → body "ok" once the TCP listener is
+ # bound (api/src/routes.rs health). Do **not** probe /health/ready
+ # here: that path is a GetInfo projection (api/src/info.rs
+ # health_ready) and depends on kernel ChainIdentity (verified BMF1 +
+ # ops pins). A depends_on on ready would park the stack on kernel
+ # identity issues without proving the REST listener is up.
+ test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 12
+ start_period: 30s
+
+volumes:
+ postgres_data:
+ node_data:
+ bitcoind_data:
+ nostr_relay_data:
+ api_blossom_data:
+ node2_data:
+ postgres2_data:
+ api2_blossom_data:
+ # Host-global shared lease/cache volume. Create once before `up` with:
+ # `docker volume create zkcoins_verifier_cache_shared`.
+ verifier_cache_shared:
+ external: true
+ name: zkcoins_verifier_cache_shared
diff --git a/deploy/local-e2e/.gitignore b/deploy/local-e2e/.gitignore
new file mode 100644
index 00000000..f532b5cb
--- /dev/null
+++ b/deploy/local-e2e/.gitignore
@@ -0,0 +1,5 @@
+# Operator secrets and generated BMF1 — never commit
+env.local.sh
+data/
+node_modules/
+package-lock.json
diff --git a/deploy/local-e2e/README.md b/deploy/local-e2e/README.md
new file mode 100644
index 00000000..b3c3878b
--- /dev/null
+++ b/deploy/local-e2e/README.md
@@ -0,0 +1,175 @@
+# `deploy/local-e2e/` — full stack entry point
+
+Ordered entry point for an **unmocked** local pass of the zkCoins stack
+(postgres, bitcoind regtest, nostr-relay, node, api) and the mandate §3
+A-to-Z machine-evaluable assertions.
+
+This directory is the **mechanism** the audit asked for: not a narrative that
+the journey works, but scripts that hard-fail when a numbered assertion does
+not hold.
+
+Operator background: [`docs/local-stack.md`](../../docs/local-stack.md).
+Pass predicate: `docs-vectors/docs/implementation-mandate.md` §3.
+
+## Prerequisites
+
+| Need | Detail |
+| --- | --- |
+| Docker Compose v2 | Required by `up.sh` / `down.sh`. |
+| Host tools | `curl`, `cargo` (only if `gen_bootstrap_manifest` is not already built). Journey needs Node.js ≥ 22. |
+| Sibling `api` checkout | Compose builds `../api` next to this `node` worktree. |
+| **Memory** | The Docker VM (OrbStack / Docker Desktop) needs **well more than 16 GiB** for the node's cold-start circuit construction (C + C_balance, full Plonky2 recursion). **Observed: OOMKilled (exit 137) at ~15.6 GiB.** Assign **≥ 24 GiB** to the Docker VM. The exact peak is build-dependent; 15.6 GiB is proven insufficient. Settings: **OrbStack** → VM memory; **Docker Desktop** → Settings → Resources → Memory. Changing the limit requires a **VM restart**. `up.sh` warns (non-fatal) when Docker reports under ~20 GiB. |
+| **bash for env** | `env.local.sh` derives paths via `${BASH_SOURCE[0]}` and aborts if sourced under zsh/sh. Source under bash (see Environment below). Scripts (`up.sh`, …) already use `#!/usr/bin/env bash`. |
+
+## Layout
+
+| Path | Role |
+| --- | --- |
+| `env.example.sh` | Every compose `${VAR:?}` pin + generator-only bootstrap secret path. Placeholders only. |
+| `up.sh` | Preflight → BMF1 (`gen_bootstrap_manifest`) → `docker compose up` → health waits → regtest wallet + mature coinbase → node restart. |
+| `journey.sh` / `journey.mjs` | A-to-Z hard pass/fail chain via `@zkcoins/sdk` (`file:../../../sdk`). |
+| `down.sh` | `compose down`; `--wipe` also removes volumes. |
+| `package.json` | Private journey deps (`@zkcoins/sdk` + noble/scure). |
+| `data/` | Local-only BMF1 + bootstrap.priv (create yourself; never commit). |
+
+## Ordered runbook
+
+### 1. Environment
+
+```bash
+cp deploy/local-e2e/env.example.sh deploy/local-e2e/env.local.sh
+# Edit env.local.sh:
+# - PUBLISHER_KEY = $(openssl rand -hex 32)
+# - ZKCOINS_BOOTSTRAP_PUBKEY + matching privkey file (64 hex, mode 0600)
+# - ZKCOINS_EXPECTED_PARAMS_IDENTIFIER (formula in docs/local-stack.md)
+# - ESPLORA_URL / ESPLORA_WS_URL (operator Esplora; residual boot pin)
+# - ZKCOINS_BOOTSTRAP_OPERATOR_ID
+# Never commit env.local.sh or data/*.priv
+
+mkdir -p deploy/local-e2e/data
+# Write bootstrap.priv (64 lowercase hex), chmod 0600
+# Point ZKCOINS_BOOTSTRAP_PRIVKEY_FILE at it (default path in env.example.sh)
+
+# Source under bash — not zsh. From a zsh login shell, either:
+bash -c 'set -a && source deploy/local-e2e/env.local.sh && set +a && ./deploy/local-e2e/up.sh'
+# or enter bash first, then:
+# bash
+# set -a && source deploy/local-e2e/env.local.sh && set +a
+# ./deploy/local-e2e/up.sh
+```
+
+Regtest circuit digests are **tree-pinned** in `env.example.sh` from
+`script-plonky2/tests/generated_circuit_digests.txt`. The params identifier
+is **not** pinned in-tree: it includes *your* `ZKCOINS_BOOTSTRAP_PUBKEY`.
+
+### 2. Start the stack
+
+```bash
+# If env was already sourced in this bash shell:
+./deploy/local-e2e/up.sh
+```
+
+What `up.sh` does, fail-closed:
+
+1. Checks docker compose + every required env (refuses `REPLACE_ME_*`).
+ Non-fatal warn if Docker VM memory is under ~20 GiB (OOM risk; see Prerequisites).
+2. Builds/signs BMF1 with `gen_bootstrap_manifest` if the host path is empty
+ (secret only via `ZKCOINS_BOOTSTRAP_PRIVKEY_FILE` — never argv).
+3. `docker compose up -d --build`.
+4. Waits for health: postgres → bitcoind → nostr-relay → node `/health` →
+ api `/health` (named timeouts; no silent continue). Node cold start allows
+ **20 minutes** for §1.7.9 circuit construction, with progress every 60s.
+5. Creates/loads `ZKCOINS_V1_BITCOIND_WALLET`, mines ~110 blocks for mature
+ coinbase, restarts `node` so the publisher sees the funded wallet.
+
+### 3. Journey
+
+```bash
+./deploy/local-e2e/journey.sh # default: stages 1 + 2
+./deploy/local-e2e/journey.sh --list
+./deploy/local-e2e/journey.sh --stage 1 --stage 2
+./deploy/local-e2e/journey.sh --stage 7 # named control (may be TODO)
+```
+
+Signing and key derivation use **`@zkcoins/sdk`** against the live api
+(`http://127.0.0.1:8080`). The stack does not sign (custody boundary).
+
+### 4. Stop
+
+```bash
+./deploy/local-e2e/down.sh # keep volumes (proofs, DB, regtest chain)
+./deploy/local-e2e/down.sh --wipe # also remove named volumes
+```
+
+## Cold-start cost (honest)
+
+| Step | Expectation |
+| --- | --- |
+| First **node** image build | Multi-stage Rust + Plonky2 circuits — **many minutes to hours** on a cold machine. Dominant cost. |
+| First **node** process boot | Builds §1.7.9 circuits (C + C_balance) **before** `/health` is served — often many minutes; needs **≥ 24 GiB** Docker-VM RAM (see Prerequisites). `up.sh` waits up to 20 minutes with progress logs. |
+| First **api** image build | Multi-stage Rust + protoc — shorter than node, still cold-cache heavy. |
+| Subsequent `up.sh` | Reuses images and volumes; still pays migrations + scanner connect + optional circuit warm. |
+| `gen_bootstrap_manifest` | Fast if `target/release/…` already built; otherwise one release crate build. |
+| Journey stage 2 (mint prove) | Real Plonky2 proof — can take minutes per transition on modest hardware. |
+
+Do not treat a multi-hour first boot as a script bug. Under ~16 GiB Docker-VM RAM,
+expect OOM (exit 137) during circuit construction rather than a logic failure.
+
+## What each journey stage asserts
+
+| Stage | Mandate §3 | Status in this tree |
+| --- | --- | --- |
+| **1** | `GET /v1/info` equals pinned `circuit_digests` (`C`, `C_balance`) and bounds | **Hard** — digests + `finality_confirmations=6`, `max_tx_*=8`, `max_rx_coins=4`, `max_account_assets=32`, `activation_height=0` |
+| **2** | Alice mint → job `completed` → nullifier inscribed → §3.10 `completed` after 6 blocks → balance `1_000_000_000` | **Hard driver** — entrust bundle, mint, SDK `refuseOrSignAndSubmit` (awaiting_signature recompute), mine, `/v1/chain/nullifier` + inscriptions, pull + parse balances |
+| **2b** | Carol EUR-Demo token-standard-2 genesis + Alice receive; two-asset map | **TODO skeleton** — needs non-self mint delivery |
+| **3–4** | Alice fee-less send to Bob (case (c)); publisher half-agg + inscription; Alice balance `999_750_000` | **Partial**: fee_address **negative** control is hard; positive send is **TODO** (Nostr/Blossom delivery gap) |
+| **5** | Bob receive fold → balance `250_000` | **TODO skeleton** (depends on 3–4) |
+| **6** | Confirmation link reports §3.10 `completed` for the payment | **TODO skeleton** for payment; mint §3.10 already checked in stage 2 |
+| **7** | Reorg control N-09 | **TODO skeleton** |
+| **8** | Recovery control Req 6 | **TODO skeleton** |
+| **9** | Portability control Req 10 | **TODO skeleton** |
+| **10** | Attestation control Req 9(b) | **TODO skeleton** (challenge surface probed) |
+| **11** | Grant control Req 9(c) | **TODO skeleton** (challenge surface probed) |
+
+Default `journey.sh` runs **1 + 2 only**, so a green default run does **not**
+claim the full A-to-Z suite. Requesting a TODO stage exits non-zero with a
+named message — never a silent pass.
+
+## Fixtures (mandate §3)
+
+- Mnemonic: BIP-39 V.2-ext
+ `abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`
+- Alice `account' = 0`, Bob `1`, Carol `2`
+- Asset: `USD-Demo`, `decimals = 2`, `issuance_version = 1`, supply `1_000_000_000`
+- Fee-less (D9): no fee coin; send with `fee_address` is rejected
+
+## Fail-closed policy
+
+- Missing env / placeholder → abort before compose parse or at `up.sh` preflight.
+- BMF1 generation failure → no compose up.
+- Health wait timeout → abort with service name and log hint.
+- Journey: first failed assertion → exit 1 with `journey FAIL [stage N]: …`.
+- No `|| true` on real errors. No protocol mocks.
+
+## Known stack gaps (not hidden by these scripts)
+
+See `docs/local-stack.md` “Gaps / open items”. Material to journey completeness:
+
+1. Esplora not bundled (residual boot + node `/health/ready`).
+2. Nostr delivery client not fully wired into send/receive (blocks stages 3–6, 2b).
+3. Recipient `IVPK` / Invoice off REST inventory — wallet must supply delivery credentials.
+4. Kernel operational-bundle store is process-local (lost on node restart).
+5. Empty `ZKCOINS_BLOSSOM_ALLOWED_OPS` → uploads 403 (set op pubkeys when delivery is live).
+
+## Verification (syntax)
+
+```bash
+bash -n deploy/local-e2e/up.sh
+bash -n deploy/local-e2e/journey.sh
+bash -n deploy/local-e2e/down.sh
+bash -n deploy/local-e2e/env.example.sh
+# if available:
+shellcheck deploy/local-e2e/*.sh
+```
+
+A real stack start is the orchestrator’s job after these files land.
diff --git a/deploy/local-e2e/collect-integration-coverage.sh b/deploy/local-e2e/collect-integration-coverage.sh
new file mode 100755
index 00000000..89c7fda7
--- /dev/null
+++ b/deploy/local-e2e/collect-integration-coverage.sh
@@ -0,0 +1,287 @@
+#!/usr/bin/env bash
+# Build and run the real local journey under LLVM source-based coverage,
+# flush both node processes, and merge the result with unit-test LCOV data.
+#
+# This script intentionally performs the expensive work; do not invoke it for
+# a quick compile check. Before running it, source env.example.sh (with every
+# placeholder replaced) exactly as for up.sh.
+# Prerequisites: Docker Compose v2, Node.js/npm as required by journey.sh,
+# cargo-llvm-cov + cargo-nextest, the pinned Rust llvm-tools component, and the
+# standalone LCOV toolkit (`brew install lcov` on the intended macOS host).
+#
+# By default the unit suite is run as part of this pipeline, so unit.lcov and
+# integration.lcov use the same checkout and ignore policy. To reuse an LCOV
+# file produced by the documented cargo-llvm-cov command instead, set both:
+#
+# ZKCOINS_REUSE_UNIT_LCOV=1
+# ZKCOINS_UNIT_LCOV=/absolute/path/to/unit.lcov
+#
+# A reused file must have been produced from this checkout with the IGNORE
+# expression below; the script deliberately has no silent "unit data absent"
+# fallback.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+BASE_COMPOSE="${REPO_ROOT}/compose.yaml"
+COVERAGE_COMPOSE="${SCRIPT_DIR}/compose.coverage.yaml"
+COVERAGE_BASE="${SCRIPT_DIR}/coverage-data"
+IGNORE='_tests\.rs$|test_db\.rs$|bin/.*\.rs$|main\.rs$|lib\.rs$|program-plonky2/|script-plonky2/'
+cd "${REPO_ROOT}"
+
+# The Docker builder and host tools must resolve the same pinned
+# nightly-2026-06-18 LLVM profile format. Override LLVM_TOOLS_DIR only when the
+# host triple differs; do not point it at an unrelated LLVM installation.
+LLVM_TOOLS_DIR="${LLVM_TOOLS_DIR:-${HOME}/.rustup/toolchains/nightly-2026-06-18-aarch64-apple-darwin/lib/rustlib/aarch64-apple-darwin/bin}"
+LLVM_PROFDATA="${LLVM_TOOLS_DIR}/llvm-profdata"
+LLVM_COV="${LLVM_TOOLS_DIR}/llvm-cov"
+
+die() {
+ echo "collect-integration-coverage.sh: ERROR: $*" >&2
+ exit 1
+}
+
+log() {
+ echo "collect-integration-coverage.sh: $*" >&2
+}
+
+require_cmd() {
+ command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"
+}
+
+require_file() {
+ [[ -f "$1" ]] || die "required file not found: $1"
+}
+
+require_cmd docker
+require_cmd cargo
+require_cmd lcov
+require_cmd cmp
+require_cmd find
+require_cmd grep
+require_cmd mktemp
+[[ -x "${LLVM_PROFDATA}" ]] || die "llvm-profdata not executable: ${LLVM_PROFDATA}"
+[[ -x "${LLVM_COV}" ]] || die "llvm-cov not executable: ${LLVM_COV}"
+docker compose version >/dev/null 2>&1 || die "Docker Compose v2 is required"
+require_file "${BASE_COMPOSE}"
+require_file "${COVERAGE_COMPOSE}"
+
+mkdir -p "${COVERAGE_BASE}"
+RUN_DIR="$(mktemp -d "${COVERAGE_BASE}/run-XXXXXXXX")"
+mkdir -p "${RUN_DIR}/node1" "${RUN_DIR}/node2"
+export ZKCOINS_COVERAGE_DATA_DIR="${RUN_DIR}"
+export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-zkcoins-local-coverage}"
+
+# up.sh and journey.sh accept only one literal -f argument. Compose therefore
+# resolves both source files into one private temporary file which those
+# existing lifecycle scripts can consume. `docker compose config` may expand
+# secret-valued environment entries, so the file is created under umask 077
+# and is always deleted by the EXIT trap.
+umask 077
+MERGED_COMPOSE="$(mktemp "${TMPDIR:-/tmp}/zkcoins-compose-coverage.XXXXXXXX")"
+STACK_MAY_EXIST=0
+NODES_STOPPED=0
+
+cleanup() {
+ local status="$1"
+ set +e
+ if (( STACK_MAY_EXIST == 1 && NODES_STOPPED == 0 )); then
+ log "stopping coverage nodes after an interrupted/failed run"
+ docker compose -f "${MERGED_COMPOSE}" stop -t 60 node node2 >/dev/null 2>&1
+ fi
+ rm -f "${MERGED_COMPOSE}"
+ exit "${status}"
+}
+trap 'cleanup $?' EXIT
+
+COMPOSE=(docker compose -f "${BASE_COMPOSE}" -f "${COVERAGE_COMPOSE}")
+
+log "coverage artifacts will be written to ${RUN_DIR}"
+log "building the instrumented node and node2 images"
+"${COMPOSE[@]}" build node node2 \
+ || die "instrumented Docker image build failed"
+
+"${COMPOSE[@]}" config >"${MERGED_COMPOSE}" \
+ || die "could not merge base and coverage Compose files"
+[[ -s "${MERGED_COMPOSE}" ]] || die "merged Compose file is empty"
+grep -Fq 'LLVM_PROFILE_FILE: /cov/node1/' "${MERGED_COMPOSE}" \
+ || die "merged Compose file lost node's coverage environment"
+grep -Fq "${RUN_DIR}/node2" "${MERGED_COMPOSE}" \
+ || die "merged Compose file lost node2's coverage bind mount"
+export COMPOSE_FILE="${MERGED_COMPOSE}"
+
+# Reuse all ordered BMF1 generation, dependency health checks, wallet funding,
+# and node2 setup from the canonical stack launcher. Its second --build is a
+# cache hit for the images explicitly built above.
+STACK_MAY_EXIST=1
+log "wiping stale volumes for a clean coverage run (stale account state otherwise fails stage 2 with KeyBindingRefusalError)"
+docker compose -f "${MERGED_COMPOSE}" down -v --remove-orphans || die "volume wipe before coverage run failed"
+log "starting the coverage stack through deploy/local-e2e/up.sh"
+bash "${SCRIPT_DIR}/up.sh" \
+ || die "coverage stack startup failed"
+
+NODE1_CID="$(docker compose -f "${MERGED_COMPOSE}" ps -q node)"
+NODE2_CID="$(docker compose -f "${MERGED_COMPOSE}" ps -q node2)"
+[[ -n "${NODE1_CID}" ]] || die "could not resolve the node container id"
+[[ -n "${NODE2_CID}" ]] || die "could not resolve the node2 container id"
+
+# "1 through 9" includes the catalogued 2b stage between 2 and 3. Stages 3
+# and 4 intentionally share one implementation; journey.mjs suppresses the
+# duplicate call while retaining both stage assertions.
+log "running journey stages 1, 2, 2b, 3, 4, 5, 6, 7, 8, and 9"
+# Exercise live fail-closed dependency paths during the integration coverage journey.
+export ZKCOINS_JOURNEY_FAULTS=1
+# Exercise live fail-closed dependency paths during the integration coverage journey.
+export ZKCOINS_JOURNEY_FAULTS=1
+bash "${SCRIPT_DIR}/journey.sh" \
+ --stage 1 \
+ --stage 2 \
+ --stage 2b \
+ --stage 3 \
+ --stage 4 \
+ --stage 5 \
+ --stage 6 \
+ --stage 7 \
+ --stage 8 \
+ --stage 9 \
+ || die "journey failed; coverage nodes will still be stopped by the EXIT trap"
+
+log "sending SIGTERM to node and node2 so their coverage handlers flush"
+docker compose -f "${MERGED_COMPOSE}" stop -t 60 node node2 \
+ || die "failed to stop coverage nodes"
+NODES_STOPPED=1
+
+profiles_ready() {
+ [[ -n "$(find "${RUN_DIR}/node1" -type f -name '*.profraw' -size +0c -print -quit)" ]] &&
+ [[ -n "$(find "${RUN_DIR}/node2" -type f -name '*.profraw' -size +0c -print -quit)" ]]
+}
+
+log "waiting up to 30 seconds for non-empty node1 and node2 .profraw files"
+PROFILE_WAIT_START="${SECONDS}"
+until profiles_ready; do
+ if (( SECONDS - PROFILE_WAIT_START >= 30 )); then
+ die "timed out waiting for .profraw files under ${RUN_DIR}/{node1,node2}"
+ fi
+ sleep 1
+done
+
+PROFRAW_FILES=()
+while IFS= read -r profile; do
+ PROFRAW_FILES+=("${profile}")
+done < <(find "${RUN_DIR}/node1" "${RUN_DIR}/node2" -type f -name '*.profraw' -size +0c -print | sort)
+(( ${#PROFRAW_FILES[@]} > 0 )) || die "profile discovery returned no files"
+
+INTEGRATION_PROFDATA="${RUN_DIR}/integration.profdata"
+log "merging ${#PROFRAW_FILES[@]} raw profiles"
+"${LLVM_PROFDATA}" merge -sparse "${PROFRAW_FILES[@]}" -o "${INTEGRATION_PROFDATA}" \
+ || die "llvm-profdata merge failed"
+[[ -s "${INTEGRATION_PROFDATA}" ]] || die "integration.profdata is empty"
+
+# Extract both service binaries and prove byte identity before one is used as
+# llvm-cov's object. This makes the profile/object hash invariant explicit:
+# no locally rebuilt or merely similar binary is accepted.
+NODE1_BINARY="${RUN_DIR}/zkcoins-node.node1"
+NODE2_BINARY="${RUN_DIR}/zkcoins-node.node2"
+docker cp "${NODE1_CID}:/usr/local/bin/zkcoins-node" "${NODE1_BINARY}" \
+ || die "failed to copy the instrumented node binary"
+docker cp "${NODE2_CID}:/usr/local/bin/zkcoins-node" "${NODE2_BINARY}" \
+ || die "failed to copy the instrumented node2 binary"
+[[ -s "${NODE1_BINARY}" ]] || die "copied node binary is empty"
+[[ -s "${NODE2_BINARY}" ]] || die "copied node2 binary is empty"
+cmp -s "${NODE1_BINARY}" "${NODE2_BINARY}" \
+ || die "node and node2 instrumented binaries differ; refusing a hash-unsafe export"
+
+INTEGRATION_LCOV="${RUN_DIR}/integration.lcov"
+log "exporting integration LCOV with Docker /app paths mapped to this checkout"
+"${LLVM_COV}" export \
+ --format=lcov \
+ --instr-profile="${INTEGRATION_PROFDATA}" \
+ -path-equivalence="/app,${REPO_ROOT}" \
+ --ignore-filename-regex="${IGNORE}" \
+ "${NODE1_BINARY}" >"${INTEGRATION_LCOV}" \
+ || die "llvm-cov integration export failed"
+[[ -s "${INTEGRATION_LCOV}" ]] || die "integration.lcov is empty"
+
+# llvm-cov's -path-equivalence maps source lookup only; it does NOT rewrite the SF: lines
+# in the lcov output, which keep the Docker /app paths. Rewrite them to this checkout so the
+# node-only extract + lcov merge below match the host-path unit.lcov.
+sed -i '' "s|^SF:/app/|SF:${REPO_ROOT}/|" "${INTEGRATION_LCOV}" \
+ || die "failed to normalize integration.lcov /app paths to the host checkout"
+grep -Fq "SF:${REPO_ROOT}/node/" "${INTEGRATION_LCOV}" \
+ || die "integration.lcov still lacks host node-crate paths after normalization"
+
+UNIT_LCOV="${ZKCOINS_UNIT_LCOV:-${RUN_DIR}/unit.lcov}"
+REUSE_UNIT_LCOV="${ZKCOINS_REUSE_UNIT_LCOV:-0}"
+case "${REUSE_UNIT_LCOV}" in
+ 0)
+ log "running the unit-test coverage suite (same scope as the CI baseline)"
+ # The unit suite is hermetic and asserts against the CI test env, NOT the live-stack
+ # env.local.sh the caller sourced for the journey. Notably router_tests.rs derives the
+ # mocked publisher address from PUBLISHER_KEY=0000...0001; the live PUBLISHER_KEY breaks it.
+ # Source of truth: .github/workflows/ci.yaml (Tests + Coverage Gate job env).
+ export IS_MAINNET="false"
+ export ESPLORA_URL="http://127.0.0.1:1/api"
+ export ESPLORA_WS_URL="ws://127.0.0.1:1/api/v1/ws"
+ export USERNAME_DOMAIN="test.zkcoins.local"
+ export PUBLISHER_KEY="0000000000000000000000000000000000000000000000000000000000000001"
+ RUSTFLAGS="--cfg coverage_nightly" cargo llvm-cov nextest \
+ --release \
+ -p node \
+ -p shared \
+ --all-features \
+ --ignore-filename-regex "${IGNORE}" \
+ --fail-under-lines 0 \
+ --fail-under-functions 0 \
+ --test-threads 8 \
+ -E 'not binary(api_remote)' \
+ || die "unit-test coverage run failed"
+ RUSTFLAGS="--cfg coverage_nightly" cargo llvm-cov report \
+ --release \
+ --lcov \
+ --output-path "${UNIT_LCOV}" \
+ --ignore-filename-regex "${IGNORE}" \
+ || die "unit LCOV export failed"
+ ;;
+ 1)
+ [[ -n "${ZKCOINS_UNIT_LCOV:-}" ]] \
+ || die "ZKCOINS_REUSE_UNIT_LCOV=1 requires an explicit ZKCOINS_UNIT_LCOV"
+ log "reusing caller-supplied unit LCOV: ${UNIT_LCOV}"
+ ;;
+ *)
+ die "ZKCOINS_REUSE_UNIT_LCOV must be 0 or 1 (got ${REUSE_UNIT_LCOV})"
+ ;;
+esac
+require_file "${UNIT_LCOV}"
+[[ -s "${UNIT_LCOV}" ]] || die "unit LCOV is empty: ${UNIT_LCOV}"
+
+# Host test binaries and the Linux integration binary are distinct objects, so
+# their profdata cannot be merged safely. LCOV is the correct common layer.
+# First restrict both inputs to node/ (the unit command also measures shared),
+# then add line hit counts for identical SF paths. -path-equivalence above is
+# what makes Docker's /app/node/... paths match the host checkout paths here.
+UNIT_NODE_LCOV="${RUN_DIR}/unit-node.lcov"
+INTEGRATION_NODE_LCOV="${RUN_DIR}/integration-node.lcov"
+COMBINED_LCOV="${RUN_DIR}/combined.lcov"
+grep -Fq "SF:${REPO_ROOT}/node/" "${UNIT_LCOV}" \
+ || die "unit LCOV paths do not name this checkout's node crate"
+grep -Fq "SF:${REPO_ROOT}/node/" "${INTEGRATION_LCOV}" \
+ || die "integration path equivalence did not map /app to this checkout"
+lcov --extract "${UNIT_LCOV}" "${REPO_ROOT}/node/*" --output-file "${UNIT_NODE_LCOV}" \
+ || die "could not restrict unit LCOV to the node crate"
+lcov --extract "${INTEGRATION_LCOV}" "${REPO_ROOT}/node/*" --output-file "${INTEGRATION_NODE_LCOV}" \
+ || die "could not restrict integration LCOV to the node crate"
+[[ -s "${UNIT_NODE_LCOV}" ]] || die "node-only unit LCOV is empty"
+[[ -s "${INTEGRATION_NODE_LCOV}" ]] || die "node-only integration LCOV is empty"
+lcov \
+ --add-tracefile "${UNIT_NODE_LCOV}" \
+ --add-tracefile "${INTEGRATION_NODE_LCOV}" \
+ --output-file "${COMBINED_LCOV}" \
+ || die "LCOV merge failed"
+[[ -s "${COMBINED_LCOV}" ]] || die "combined.lcov is empty"
+
+log "combined node line coverage"
+lcov --summary "${COMBINED_LCOV}" \
+ || die "could not summarize combined LCOV"
+log "complete: ${COMBINED_LCOV}"
+log "node services are stopped; the remaining local-e2e services stay running"
diff --git a/deploy/local-e2e/compose.coverage.yaml b/deploy/local-e2e/compose.coverage.yaml
new file mode 100644
index 00000000..3b88436c
--- /dev/null
+++ b/deploy/local-e2e/compose.coverage.yaml
@@ -0,0 +1,28 @@
+# Docker Compose override for the instrumented local E2E run.
+#
+# `collect-integration-coverage.sh` exports ZKCOINS_COVERAGE_DATA_DIR as an
+# absolute, per-run host directory before Compose reads this file. Keeping the
+# two processes in separate bind mounts makes profile-name collisions
+# impossible even if the containers happen to use the same PID/build ID.
+services:
+ node:
+ build:
+ args:
+ COVERAGE: "1"
+ environment:
+ LLVM_PROFILE_FILE: /cov/node1/node-%p-%m.profraw
+ volumes:
+ - type: bind
+ source: ${ZKCOINS_COVERAGE_DATA_DIR:?set by collect-integration-coverage.sh}/node1
+ target: /cov/node1
+
+ node2:
+ build:
+ args:
+ COVERAGE: "1"
+ environment:
+ LLVM_PROFILE_FILE: /cov/node2/node-%p-%m.profraw
+ volumes:
+ - type: bind
+ source: ${ZKCOINS_COVERAGE_DATA_DIR:?set by collect-integration-coverage.sh}/node2
+ target: /cov/node2
diff --git a/deploy/local-e2e/down.sh b/deploy/local-e2e/down.sh
new file mode 100755
index 00000000..86f333a3
--- /dev/null
+++ b/deploy/local-e2e/down.sh
@@ -0,0 +1,68 @@
+#!/usr/bin/env bash
+# down.sh — stop the local-e2e stack.
+#
+# Usage:
+# ./deploy/local-e2e/down.sh # stop containers; keep volumes
+# ./deploy/local-e2e/down.sh --wipe # stop and remove volumes (proofs, DB, regtest, Blossom)
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+cd "${REPO_ROOT}"
+
+die() {
+ echo "down.sh: ERROR: $*" >&2
+ exit 1
+}
+
+log() {
+ echo "down.sh: $*" >&2
+}
+
+command -v docker >/dev/null 2>&1 || die "required command not found: docker"
+docker compose version >/dev/null 2>&1 || die "docker compose (v2) is required"
+
+export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_ROOT}/compose.yaml}"
+export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-zkcoins-local}"
+[[ -f "${COMPOSE_FILE}" ]] || die "compose file not found: ${COMPOSE_FILE}"
+
+WIPE=0
+for arg in "$@"; do
+ case "${arg}" in
+ --wipe|-v|--volumes)
+ WIPE=1
+ ;;
+ -h|--help)
+ cat <<'EOF'
+Usage: down.sh [--wipe]
+
+ (default) docker compose down — stop containers; keep named volumes
+ --wipe docker compose down -v — also remove volumes:
+ postgres_data, node_data (/data/proofs), bitcoind_data,
+ nostr_relay_data, api_blossom_data
+
+Does not delete host-side BMF1 / bootstrap.priv under deploy/local-e2e/data/
+unless you remove them yourself.
+EOF
+ exit 0
+ ;;
+ *)
+ die "unknown argument: ${arg} (try --help)"
+ ;;
+ esac
+done
+
+if (( WIPE == 1 )); then
+ log "stopping stack and removing volumes…"
+ docker compose -f "${COMPOSE_FILE}" down -v \
+ || die "docker compose down -v failed"
+ log "volumes removed. Next up.sh must re-create the bitcoind wallet and re-mine."
+else
+ log "stopping stack (volumes preserved)…"
+ docker compose -f "${COMPOSE_FILE}" down \
+ || die "docker compose down failed"
+ log "volumes kept. Use --wipe to drop postgres/node/bitcoind/nostr/blossom data."
+fi
+
+exit 0
diff --git a/deploy/local-e2e/env.example.sh b/deploy/local-e2e/env.example.sh
new file mode 100755
index 00000000..ed3bf3cf
--- /dev/null
+++ b/deploy/local-e2e/env.example.sh
@@ -0,0 +1,150 @@
+#!/usr/bin/env bash
+# env.example.sh — template for every compose `${VAR:?…}` pin.
+#
+# Copy, fill, then source under **bash** before up.sh (not zsh — see guard):
+#
+# cp deploy/local-e2e/env.example.sh deploy/local-e2e/env.local.sh
+# # edit env.local.sh — never commit secrets
+# bash -c 'set -a && source deploy/local-e2e/env.local.sh && set +a && ./deploy/local-e2e/up.sh'
+#
+# Or stay inside a bash shell:
+#
+# bash
+# set -a && source deploy/local-e2e/env.local.sh && set +a
+# ./deploy/local-e2e/up.sh
+#
+# Placeholders use REPLACE_ME_* so a half-filled file fails loudly.
+# Never put real secrets in this file or any committed path.
+#
+# Full operator context: docs/local-stack.md
+
+# Bash-only: path derivation uses ${BASH_SOURCE[0]}. Sourcing under zsh leaves
+# that unset, yields an empty _SCRIPT_DIR, and points COMPOSE_FILE at the wrong
+# tree. Refuse loudly — never silent wrong paths.
+if [ -z "${BASH_VERSION:-}" ]; then
+ echo "env.example.sh / env.local.sh: ERROR: must be sourced or run under bash (not zsh/sh)." >&2
+ echo " source under bash:" >&2
+ echo " bash -c 'set -a && source deploy/local-e2e/env.local.sh && set +a && ./deploy/local-e2e/up.sh'" >&2
+ echo " or run the scripts directly (they have #!/usr/bin/env bash)." >&2
+ return 1 2>/dev/null || exit 1
+fi
+
+set -euo pipefail
+
+# ─── Crypto secrets (operator material — never invent defaults) ───────────
+
+# 32-byte secp256k1 secret as 64 lowercase hex.
+# Generate: openssl rand -hex 32
+export PUBLISHER_KEY="REPLACE_ME_PUBLISHER_KEY_64_LOWERCASE_HEX"
+
+# 32-byte secp256k1 secret as 64 lowercase hex, for node2's identity — DISTINCT from
+# PUBLISHER_KEY above (two nodes must not share a publisher key).
+# Generate: openssl rand -hex 32
+export PUBLISHER_KEY_2="REPLACE_ME_PUBLISHER_KEY_2_64_LOWERCASE_HEX"
+
+# Username domain returned by residual /api/info surfaces.
+export USERNAME_DOMAIN="local.zkcoins.test"
+
+# ─── Residual Esplora (still required at node boot; Stage-3 scan is bitcoind) ─
+# Operator-supplied HTTP + WS endpoints the *node container* can reach.
+# No invented third-party URLs. Point at your Esplora for this regtest, or
+# expect node /health/ready to stay non-ready while jobs still run on bitcoind.
+export ESPLORA_URL="REPLACE_ME_ESPLORA_HTTP_BASE"
+export ESPLORA_WS_URL="REPLACE_ME_ESPLORA_WS_URL"
+
+# ─── §3.6 boot pins (regtest digests are tree-pinned) ─────────────────────
+# Source: script-plonky2/tests/generated_circuit_digests.txt (drop 0x).
+export ZKCOINS_CIRCUIT_DIGEST_C="9d256e8c828f531fc6cf9ffd4fa1ca9480473d00a99f92ea535912daa34e8352"
+export ZKCOINS_CIRCUIT_DIGEST_C_BALANCE="bd696087e0e0f47b556a6803ef4fb5b9ebae2327e0438dd405f33752dc90772d"
+
+# BIP-340 x-only public key of the local-network bootstrap secret (64 hex).
+# Must match the secret used to sign the BMF1 artifact below.
+export ZKCOINS_BOOTSTRAP_PUBKEY="REPLACE_ME_BOOTSTRAP_PUBKEY_64_LOWERCASE_HEX_XONLY"
+
+# SHA-256 of canonical NetworkParams encoding. Formula and python snippet:
+# docs/local-stack.md → "Computing ZKCOINS_EXPECTED_PARAMS_IDENTIFIER"
+# Inputs: tag zkCoins/v1/regtest, digests above, activation_height=0,
+# bootstrap_pubkey (this network's pin).
+export ZKCOINS_EXPECTED_PARAMS_IDENTIFIER="REPLACE_ME_PARAMS_IDENTIFIER_64_HEX"
+
+# ─── §4.3 BootstrapManifest (BMF1) ────────────────────────────────────────
+# Host path of the signed BMF1 file. up.sh generates it when missing, using
+# gen_bootstrap_manifest + the secret file below.
+# Prefer an absolute path.
+_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+_REPO_ROOT="$(cd "${_SCRIPT_DIR}/../.." && pwd)"
+export ZKCOINS_V1_BOOTSTRAP_MANIFEST_HOST_PATH="${_REPO_ROOT}/deploy/local-e2e/data/bootstrap.bmf1"
+
+# Bootstrap *secret* for gen_bootstrap_manifest only (never mounted into node).
+# File must contain exactly 64 lowercase hex characters, mode 0600.
+# Generate a keypair offline; put the public form in ZKCOINS_BOOTSTRAP_PUBKEY.
+export ZKCOINS_BOOTSTRAP_PRIVKEY_FILE="${_REPO_ROOT}/deploy/local-e2e/data/bootstrap.priv"
+
+# Operator id(s) embedded in the BMF1 body (≥1 required by the generator).
+# Local convention: use the same x-only key as ZKCOINS_BOOTSTRAP_PUBKEY, or
+# another operator pubkey you control for this regtest network.
+export ZKCOINS_BOOTSTRAP_OPERATOR_ID="REPLACE_ME_OPERATOR_ID_64_LOWERCASE_HEX_XONLY"
+
+# ─── GetInfo operational pins ─────────────────────────────────────────────
+# Compose-internal Nostr relay (host tools use ws://127.0.0.1:18080/).
+export ZKCOINS_RELAY_URL="ws://nostr-relay:8080/"
+# node-container-reachable Blossom base: the node and api are separate
+# compose services: 127.0.0.1 inside the node container is the node
+# itself, not the api container. "api" is the compose DNS name for the
+# api service, which serves Blossom on :8080 in-network (compose.yaml).
+export ZKCOINS_BLOSSOM_URL="http://api:8080/"
+# node2 Blossom base (compose-internal DNS to api2). Required by node2's
+# ZKCOINS_BLOSSOM_URL env pin (compose ${ZKCOINS_BLOSSOM_URL_2:?…}).
+export ZKCOINS_BLOSSOM_URL_2="http://api2:8080/"
+export ZKCOINS_MAX_BLOB_BYTES="1048576"
+export ZKCOINS_KERNEL_PARTS="scanner,prover,publisher"
+# Required when KERNEL_PARTS includes publisher — no invented default.
+export ZKCOINS_PUBLISH_BATCH_ETA_SECS="60"
+export KERNEL_GRPC_ADDR="0.0.0.0:50051"
+
+# ─── Publish path (bitcoind wallet must match up.sh createwallet) ──────────
+export ZKCOINS_V1_BITCOIND_WALLET="zkcoins"
+# node2's own funded bitcoind wallet on the SHARED bitcoind (separate wallet name from
+# ZKCOINS_V1_BITCOIND_WALLET above — must not collide, up.sh creates/funds both).
+export ZKCOINS_V1_BITCOIND_WALLET_2="zkcoins2"
+export ZKCOINS_V1_FEE_RATE_SAT_PER_VB="2"
+export ZKCOINS_V1_REVEAL_OUTPUT_SATS="1000"
+
+# ─── api (compose service) ────────────────────────────────────────────────
+export ZKCOINS_FEATURES="wallet,explorer"
+# Host-side wallets dial http://127.0.0.1:8080 → chan_bind host "127.0.0.1:8080".
+export ZKCOINS_PUBLIC_HOST="127.0.0.1:8080"
+# node2 client-facing host for OwnershipProof chan_bind (api2/node2 only); api1/node1 keep ZKCOINS_PUBLIC_HOST — one host string per node.
+export ZKCOINS_PUBLIC_HOST_2="127.0.0.1:8081"
+export ZKCOINS_BLOSSOM_MAX_BLOB_BYTES="1048576"
+# Alice (account'=0), Bob (account'=1), and Carol (account'=2) op_pubkey,
+# derived from the journey's fixed V.2-ext test mnemonic via
+# m/1798'/'/2' (same derivation as journey.mjs buildAccount's
+# `op`/`opPubkey`). All three must be listed: this one shared node holds all
+# three wallets' operational bundles in this local-stack topology. Carol is
+# the token-standard-2 issuer in journey stage 2b (non-owner emission →
+# mesh delivery → Blossom upload). Empty allow-list = surface up, every
+# Blossom upload 403 (docs/local-stack.md gap 8).
+#
+# Changing ZKCOINS_BLOSSOM_URL or ZKCOINS_BLOSSOM_ALLOWED_OPS (node-service
+# env) requires recreating the node container, not just a process restart:
+# docker compose up -d --force-recreate node
+# The node reads these env vars only at container creation.
+export ZKCOINS_BLOSSOM_ALLOWED_OPS="6424b41eea59c6a3aa6169b802c96ff5194962d3bf5f941130e4ebc86de3b485,d91ad56adb703a1b31c40c7cd1d3c42d075c5bcd1c03d02e5e096856b6570f25,43b816d0cbf5a71f775678c267318441e9a98178d033a59a39832adb766a7c8e"
+
+# ─── Optional / journey ───────────────────────────────────────────────────
+export RUST_LOG="${RUST_LOG:-info}"
+
+# Public REST base used by journey.sh / journey.mjs (host → published ports).
+export ZKCOINS_API_URL="${ZKCOINS_API_URL:-http://127.0.0.1:8080}"
+export ZKCOINS_NODE_URL="${ZKCOINS_NODE_URL:-http://127.0.0.1:4242}"
+
+# Public REST base / node URL for node2 (host → published ports 8081 / 4243).
+export ZKCOINS_API_URL_2="${ZKCOINS_API_URL_2:-http://127.0.0.1:8081}"
+export ZKCOINS_NODE_URL_2="${ZKCOINS_NODE_URL_2:-http://127.0.0.1:4243}"
+
+# Compose project file (repo root). up.sh / down.sh honour this.
+export COMPOSE_FILE="${COMPOSE_FILE:-${_REPO_ROOT}/compose.yaml}"
+export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-zkcoins-local}"
+
+unset _SCRIPT_DIR _REPO_ROOT
diff --git a/deploy/local-e2e/journey.mjs b/deploy/local-e2e/journey.mjs
new file mode 100755
index 00000000..c2205c79
--- /dev/null
+++ b/deploy/local-e2e/journey.mjs
@@ -0,0 +1,1984 @@
+#!/usr/bin/env node
+/**
+ * A-to-Z local-e2e journey — machine-evaluable pass/fail (mandate §3).
+ *
+ * No mocks on the protocol path. Fail-closed: first red assertion aborts with
+ * a named stage. Custody: this process holds keys and signs via @zkcoins/sdk;
+ * the stack never signs.
+ *
+ * Fixtures (normative mandate §3):
+ * mnemonic V.2-ext, Alice account'=0, Bob=1, Carol=2
+ * USD-Demo, decimals=2, issuance_version=1, supply 1_000_000_000
+ * fee-less (D9); every confirmation wait = 6 mined blocks
+ *
+ * Default run: stages 1–2 (hard). Stages 2b–11 are named controls that fail
+ * with an honest TODO when the surrounding mechanics are not yet operable.
+ */
+
+import { spawnSync } from 'node:child_process';
+import { createHash, randomBytes } from 'node:crypto';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { HDKey } from '@scure/bip32';
+import { schnorr } from '@noble/curves/secp256k1.js';
+
+import {
+ GENESIS_TAG,
+ assetIdV1,
+ assetIdV2,
+ addressFromParts,
+ bip340NormaliseSecret,
+ buildOwnershipProof,
+ canonicalHostFromApiUrl,
+ chanBindForHost,
+ decodeHexExact,
+ decodeZkAddress,
+ deriveSk0,
+ deriveSpendKey,
+ digestToBytes,
+ encodeHexLower,
+ encodeZkAddress,
+ freshNpkRand,
+ issueInvoice,
+ nkCommit,
+ parseExpiryDecimal,
+ pullChallengeMessage,
+ SCOPE_NOT_AFTER_UNBOUNDED,
+ seedFromMnemonicV1,
+ V1ApiError,
+ ZkCoinsV1Client,
+} from '@zkcoins/sdk';
+
+// ---------------------------------------------------------------------------
+// Constants (mandate §3 + circuit bounds + V.2-ext)
+// ---------------------------------------------------------------------------
+
+const MNEMONIC =
+ 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
+
+const PINNED_DIGEST_C =
+ process.env.ZKCOINS_CIRCUIT_DIGEST_C ??
+ '9d256e8c828f531fc6cf9ffd4fa1ca9480473d00a99f92ea535912daa34e8352';
+const PINNED_DIGEST_C_BALANCE =
+ process.env.ZKCOINS_CIRCUIT_DIGEST_C_BALANCE ??
+ 'bd696087e0e0f47b556a6803ef4fb5b9ebae2327e0438dd405f33752dc90772d';
+
+/** Circuit dimensioning (node program-plonky2 / shared). */
+const BOUNDS = {
+ finality_confirmations: 6,
+ max_tx_inputs: 8,
+ max_tx_outputs: 8,
+ max_rx_coins: 4,
+ max_account_assets: 32,
+ activation_height: 0,
+};
+
+const USD_DEMO = {
+ name: 'USD-Demo',
+ decimals: 2,
+ issuance_version: 1,
+ amount: '1000000000',
+};
+/** Token-standard-2 EUR-Demo fixture (mandate §3 stage 2b / spec V.4). */
+const EUR_DEMO = {
+ name: 'EUR-Demo',
+ decimals: 2,
+ issuance_version: 2,
+ amount: '500000000',
+ cap_total: '500000000',
+};
+/** `terms_salt_fixture = H("zkCoins/v1/test-vector/terms_salt")` (SHA-256). */
+const TERMS_SALT_FIXTURE_HEX = createHash('sha256')
+ .update('zkCoins/v1/test-vector/terms_salt', 'utf8')
+ .digest('hex');
+/** Deterministic grantee secret for stage 11 (not Alice/Bob/Carol). */
+const GRANTEE_SECRET_FIXTURE_HEX = createHash('sha256')
+ .update('zkCoins/v1/journey/stage11/grantee', 'utf8')
+ .digest('hex');
+const SEND_AMOUNT = '250000';
+/** Alice balance after fee-less send of SEND_AMOUNT from USD_DEMO.amount. */
+const ALICE_AFTER_SEND = '999750000';
+
+const API_URL = (process.env.ZKCOINS_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '');
+/** node2 (secondary) REST base — stages 7/8/9 targets (Phase C, not wired here yet). */
+const API_URL_2 = (process.env.ZKCOINS_API_URL_2 ?? 'http://127.0.0.1:8081').replace(/\/+$/, '');
+/** Compose service name for `docker compose exec` against node2 (see compose.yaml `node2`). */
+const NODE2_SERVICE = 'node2';
+/** Compose-internal relay advertised on invoices (node-reachable). */
+const RELAY_URL = process.env.ZKCOINS_RELAY_URL ?? 'ws://nostr-relay:8080/';
+const COMPOSE_FILE =
+ process.env.COMPOSE_FILE ??
+ resolve(dirname(fileURLToPath(import.meta.url)), '../../compose.yaml');
+const WALLET = process.env.ZKCOINS_V1_BITCOIND_WALLET ?? 'zkcoins';
+
+const JOB_WAIT_MS = Number(process.env.ZKCOINS_E2E_JOB_TIMEOUT_MS ?? 30 * 60 * 1000);
+const POLL_CAP_MS = 15_000;
+
+// ---------------------------------------------------------------------------
+// Fail-closed harness
+// ---------------------------------------------------------------------------
+
+function fail(stage, message) {
+ console.error(`journey FAIL [stage ${stage}]: ${message}`);
+ process.exit(1);
+}
+
+function pass(stage, message) {
+ console.log(`journey PASS [stage ${stage}]: ${message}`);
+}
+
+function log(msg) {
+ console.error(`journey: ${msg}`);
+}
+
+// ---------------------------------------------------------------------------
+// HTTP helpers (raw surfaces not on ZkCoinsV1Client)
+// ---------------------------------------------------------------------------
+
+async function httpJson(method, url, body, headers = {}) {
+ const init = {
+ method,
+ headers: { Accept: 'application/json', ...headers },
+ };
+ if (body !== undefined) {
+ init.headers['Content-Type'] = 'application/json';
+ init.body = JSON.stringify(body);
+ }
+ // Bounded retry for transient connection failures only (thrown fetch).
+ // HTTP responses (incl. 4xx/5xx) are never retried. Max 3 attempts;
+ // backoff 500ms then 1500ms via sleep. Per-attempt AbortController 15s.
+ const maxAttempts = 3;
+ const backoffsMs = [500, 1500];
+ let res;
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 15000);
+ try {
+ res = await fetch(url, { ...init, signal: controller.signal });
+ break;
+ } catch (err) {
+ if (attempt === maxAttempts - 1) {
+ throw err;
+ }
+ await sleep(backoffsMs[attempt]);
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+ const text = await res.text();
+ let json = null;
+ if (text.length > 0) {
+ try {
+ json = JSON.parse(text);
+ } catch {
+ /* non-JSON */
+ }
+ }
+ return { status: res.status, json, text, headers: res.headers };
+}
+
+async function sleep(ms) {
+ await new Promise((r) => setTimeout(r, ms));
+}
+
+// ---------------------------------------------------------------------------
+// bitcoind mining via compose
+// ---------------------------------------------------------------------------
+
+function dockerCompose(args, stage) {
+ const result = spawnSync(
+ 'docker',
+ ['compose', '-f', COMPOSE_FILE, ...args],
+ { encoding: 'utf8' },
+ );
+ if (result.status !== 0) {
+ fail(
+ stage,
+ `docker compose ${args.join(' ')} failed: ${result.stderr || result.stdout || `exit ${result.status}`}`,
+ );
+ }
+ return result.stdout.trim();
+}
+
+function btcCli(args) {
+ const r = spawnSync(
+ 'docker',
+ [
+ 'compose',
+ '-f',
+ COMPOSE_FILE,
+ 'exec',
+ '-T',
+ 'bitcoind',
+ 'bitcoin-cli',
+ '-regtest',
+ '-datadir=/home/bitcoin/.bitcoin',
+ ...args,
+ ],
+ { encoding: 'utf8' },
+ );
+ if (r.status !== 0) {
+ fail(
+ 'mine',
+ `bitcoin-cli ${args.join(' ')} failed (exit ${r.status}): ${r.stderr || r.stdout}`,
+ );
+ }
+ return (r.stdout || '').trim().replace(/\r/g, '');
+}
+
+function mineBlocks(n, stage) {
+ log(`mining ${n} regtest block(s)…`);
+ const addr = btcCli([`-rpcwallet=${WALLET}`, 'getnewaddress']);
+ btcCli([`-rpcwallet=${WALLET}`, 'generatetoaddress', String(n), addr]);
+ pass(stage, `mined ${n} block(s)`);
+}
+
+async function readAccumulator(apiUrl) {
+ const res = await httpJson('GET', `${apiUrl}/v1/chain/accumulator`);
+ if (res.status !== 200) {
+ throw new Error(`GET ${apiUrl}/v1/chain/accumulator HTTP ${res.status}: ${res.text}`);
+ }
+ const j = res.json;
+ if (
+ typeof j?.size !== 'number' ||
+ typeof j?.root !== 'string' ||
+ typeof j?.tip_block_hash !== 'string' ||
+ typeof j?.tip_height !== 'number'
+ ) {
+ throw new Error(`malformed /v1/chain/accumulator response: ${JSON.stringify(j)}`);
+ }
+ return { size: j.size, root: j.root, tip_block_hash: j.tip_block_hash, tip_height: j.tip_height };
+}
+
+async function waitNodesConverged(apiUrlA, apiUrlB, minTipHeight, timeoutMs, stage) {
+ const deadline = Date.now() + timeoutMs;
+ let prevA = null;
+ let prevB = null;
+ let last = { aA: null, aB: null };
+ while (Date.now() < deadline) {
+ const aA = await readAccumulator(apiUrlA);
+ const aB = await readAccumulator(apiUrlB);
+ last = { aA, aB };
+ const stable =
+ prevA !== null &&
+ prevB !== null &&
+ aA.tip_block_hash === prevA.tip_block_hash &&
+ aA.tip_height === prevA.tip_height &&
+ aB.tip_block_hash === prevB.tip_block_hash &&
+ aB.tip_height === prevB.tip_height;
+ const converged =
+ aA.tip_block_hash === aB.tip_block_hash &&
+ aA.tip_height === aB.tip_height &&
+ aA.tip_height >= minTipHeight;
+ if (converged && stable) {
+ return aA;
+ }
+ prevA = aA;
+ prevB = aB;
+ await sleep(2000);
+ }
+ fail(
+ stage,
+ `nodes did not converge: node1=${JSON.stringify(last.aA)} node2=${JSON.stringify(last.aB)}`,
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Wallet material (V.2-ext accounts)
+// ---------------------------------------------------------------------------
+
+function deriveBranch(seed, account, pathSuffix) {
+ const master = HDKey.fromMasterSeed(seed);
+ const path = `m/1798'/${account}'/${pathSuffix}`;
+ const child = master.derive(path);
+ if (!child.privateKey) {
+ fail('keys', `no private key at ${path}`);
+ }
+ return child.privateKey.slice();
+}
+
+function buildAccount(seed, accountIndex) {
+ const sk0 = deriveSk0(seed, accountIndex);
+ const nk = deriveBranch(seed, accountIndex, "3'");
+ const ivk = deriveBranch(seed, accountIndex, "1'/0'");
+ const ovk = deriveBranch(seed, accountIndex, "1'/1'");
+ const op = deriveBranch(seed, accountIndex, "2'");
+ const opSecret = deriveBranch(seed, accountIndex, "4'");
+ const nkCommitBytes = digestToBytes(nkCommit(nk));
+ const addressRaw = addressFromParts(sk0.publicKey, nkCommitBytes);
+ const subject = encodeZkAddress(addressRaw);
+ const bundle = new Uint8Array(161);
+ bundle[0] = 0x01;
+ bundle.set(ivk, 1);
+ bundle.set(ovk, 33);
+ bundle.set(op, 65);
+ bundle.set(nk, 97);
+ bundle.set(opSecret, 129);
+ const { pkBytes: opPubkey } = bip340NormaliseSecret(op);
+ const { pkBytes: ivpk } = bip340NormaliseSecret(ivk);
+ return {
+ accountIndex,
+ sk0,
+ nk,
+ nkCommit: nkCommitBytes,
+ subject,
+ bundleHex: encodeHexLower(bundle),
+ op,
+ opPubkey,
+ ivk,
+ ivpk,
+ sendCounter: 0,
+ };
+}
+
+function spendAt(seed, account, index) {
+ return deriveSpendKey(seed, account, index);
+}
+
+/** OwnershipProof for a challenge domain other than PullChallenge (e.g. Entrust). */
+function buildDomainOwnershipProof({
+ subject,
+ sk0Secret,
+ nkCommitBytes,
+ challenge,
+ host,
+ expectedDomain,
+}) {
+ if (challenge.domain !== expectedDomain) {
+ fail(
+ 'ownership',
+ `challenge domain ${JSON.stringify(challenge.domain)} ≠ ${JSON.stringify(expectedDomain)}`,
+ );
+ }
+ const subjectRaw = decodeZkAddress(subject);
+ const nonce = decodeHexExact(challenge.nonce, 32, 'challenge.nonce');
+ const expiry = parseExpiryDecimal(String(challenge.expiry));
+ const chanBind = chanBindForHost(host);
+ const chal = pullChallengeMessage({
+ domain: expectedDomain,
+ nonce,
+ chanBind,
+ subjectRaw,
+ expiry,
+ });
+ const { pkBytes } = bip340NormaliseSecret(sk0Secret);
+ const signature = schnorr.sign(chal, sk0Secret, new Uint8Array(32));
+ return {
+ type: 'ownership',
+ subject,
+ public_key: encodeHexLower(pkBytes),
+ nk_commit: encodeHexLower(nkCommitBytes),
+ signature: encodeHexLower(signature),
+ };
+}
+
+// ---------------------------------------------------------------------------
+// AccountState balances parser (V.3 / serialize.rs — 140 B prefix + 48 B/entry)
+// ---------------------------------------------------------------------------
+
+function parseBalancesMap(accountStateHex) {
+ const byteLen = accountStateHex.length / 2;
+ const bytes = decodeHexExact(accountStateHex, byteLen, 'account_state');
+ if (bytes.length < 140) {
+ fail('balance', `account_state shorter than 140-byte prefix (${bytes.length})`);
+ }
+ const count = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(
+ 136,
+ false,
+ );
+ const expected = 140 + 48 * count;
+ if (bytes.length !== expected) {
+ fail(
+ 'balance',
+ `account_state length ${bytes.length} ≠ expected ${expected} for ${count} balances`,
+ );
+ }
+ /** @type {Map} */
+ const map = new Map();
+ let off = 140;
+ for (let i = 0; i < count; i++) {
+ const aid = encodeHexLower(bytes.subarray(off, off + 32));
+ off += 32;
+ let amount = 0n;
+ for (let j = 0; j < 16; j++) {
+ amount = (amount << 8n) | BigInt(bytes[off + j]);
+ }
+ off += 16;
+ map.set(aid, amount.toString(10));
+ }
+ return map;
+}
+
+function assertBalancesExact(stage, map, expected) {
+ const expKeys = Object.keys(expected).sort();
+ const gotKeys = [...map.keys()].sort();
+ if (expKeys.length !== gotKeys.length || expKeys.some((k, i) => k !== gotKeys[i])) {
+ fail(
+ stage,
+ `balances map keys mismatch: expected [${expKeys.join(',')}] got [${gotKeys.join(',')}]`,
+ );
+ }
+ for (const k of expKeys) {
+ if (map.get(k) !== expected[k]) {
+ fail(stage, `balance for ${k}: expected ${expected[k]}, got ${map.get(k)}`);
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Job lifecycle
+// ---------------------------------------------------------------------------
+
+async function waitJobStatus(client, jobId, want, stage) {
+ const deadline = Date.now() + JOB_WAIT_MS;
+ while (Date.now() < deadline) {
+ const { job, retryAfterMs } = await client.getJob(jobId);
+ if (job.status === want) return job;
+ if (job.status === 'failed' || job.status === 'cancelled') {
+ fail(
+ stage,
+ `job ${jobId} terminal ${job.status}: ${JSON.stringify(job.error ?? job)}`,
+ );
+ }
+ const wait = retryAfterMs ?? 2000;
+ await sleep(Math.min(wait, POLL_CAP_MS));
+ }
+ fail(stage, `timeout waiting for job ${jobId} status ${JSON.stringify(want)}`);
+}
+
+async function runSignedTransition(client, seed, acct, request, stage) {
+ const spend = spendAt(seed, acct.accountIndex, acct.sendCounter);
+ const next = spendAt(seed, acct.accountIndex, acct.sendCounter + 1);
+ const npkRand = freshNpkRand();
+
+ const body = {
+ ...request,
+ subject: acct.subject,
+ next_pubkey: encodeHexLower(next.publicKey),
+ npk_rand: encodeHexLower(npkRand),
+ };
+
+ const accepted = await client.submitTransition(body, {
+ idempotencyKey: `e2e-${stage}-${randomBytes(8).toString('hex')}`,
+ });
+ log(`[${stage}] job accepted ${accepted.job_id}`);
+
+ const awaiting = await waitJobStatus(client, accepted.job_id, 'awaiting_signature', stage);
+ if (!awaiting.awaiting_signature) {
+ fail(stage, `job ${accepted.job_id} status awaiting_signature but payload absent`);
+ }
+
+ // Wallet-side recomputation of ProofData + three refusals (mandate step 3/§7.5).
+ const accountState = {
+ current_pubkey: encodeHexLower(spend.publicKey),
+ send_counter: acct.sendCounter,
+ };
+
+ const { job: postSign } = await client.refuseOrSignAndSubmit({
+ jobId: accepted.job_id,
+ localPubkey: spend.publicKey,
+ secretKey: spend.secretKey,
+ accountState,
+ awaiting: awaiting.awaiting_signature,
+ nextPubkey: next.publicKey,
+ npkRand,
+ nodeNetwork: 'regtest',
+ });
+ log(`[${stage}] signed; status=${postSign.status}`);
+
+ const completed = await waitJobStatus(client, accepted.job_id, 'completed', stage);
+ acct.sendCounter += 1;
+ return { jobId: accepted.job_id, job: completed, spendPubkey: spend.publicKey };
+}
+
+// ---------------------------------------------------------------------------
+// Entrust + pull balances
+// ---------------------------------------------------------------------------
+
+async function entrustBundle(acct, host, apiUrl = API_URL) {
+ const ch = await httpJson('POST', `${apiUrl}/v1/bootstrap/challenge`, {
+ subject: acct.subject,
+ action: 'entrust',
+ });
+ if (ch.status !== 200 || !ch.json) {
+ fail('entrust', `bootstrap/challenge HTTP ${ch.status}: ${ch.text}`);
+ }
+ const proof = buildDomainOwnershipProof({
+ subject: acct.subject,
+ sk0Secret: acct.sk0.secretKey,
+ nkCommitBytes: acct.nkCommit,
+ challenge: {
+ nonce: ch.json.nonce,
+ expiry: String(ch.json.expiry),
+ domain: ch.json.domain,
+ },
+ host,
+ expectedDomain: 'zkCoins/v1/EntrustChallenge',
+ });
+ const en = await httpJson('POST', `${apiUrl}/v1/bootstrap/entrust`, {
+ challenge: { nonce: ch.json.nonce, expiry: String(ch.json.expiry) },
+ ownership_proof: proof,
+ bundle: acct.bundleHex,
+ });
+ if (en.status !== 200 || !en.json?.accepted) {
+ fail('entrust', `bootstrap/entrust HTTP ${en.status}: ${en.text}`);
+ }
+ pass('entrust', `operational bundle accepted for account'=${acct.accountIndex}`);
+}
+
+async function pullBalances(client, acct) {
+ const pull = await client.openOwnershipPullSession({
+ subject: acct.subject,
+ sk0: acct.sk0.secretKey,
+ nkCommit: acct.nkCommit,
+ });
+ const state = await client.getAccountState(pull.session);
+ return parseBalancesMap(state.account_state);
+}
+
+// ---------------------------------------------------------------------------
+// Nullifier / inscription §3.10
+// ---------------------------------------------------------------------------
+
+async function waitNullifierCompleted(pubkeyHex, stage) {
+ const deadline = Date.now() + JOB_WAIT_MS;
+ while (Date.now() < deadline) {
+ const res = await httpJson('GET', `${API_URL}/v1/chain/nullifier/${pubkeyHex}`);
+ if (res.status === 200 && res.json?.present === true) {
+ return res.json;
+ }
+ await sleep(2000);
+ }
+ fail(stage, `nullifier for ${pubkeyHex} never present on /v1/chain/nullifier after timeout`);
+}
+
+async function waitInscriptionCompletedForPubkey(pubkeyHex, stage) {
+ const deadline = Date.now() + JOB_WAIT_MS;
+ while (Date.now() < deadline) {
+ const res = await httpJson('GET', `${API_URL}/v1/chain/inscriptions?limit=50`);
+ if (res.status === 200 && Array.isArray(res.json?.inscriptions)) {
+ for (const ins of res.json.inscriptions) {
+ const members = ins.nullifiers ?? ins.members ?? [];
+ for (const m of members) {
+ const pk = m.pubkey ?? m.pk ?? m.public_key;
+ if (typeof pk === 'string' && pk.toLowerCase() === pubkeyHex.toLowerCase()) {
+ const memberState = m.state;
+ if (
+ ins.confirmation_state === 'completed' &&
+ (memberState === 'completed' || memberState === undefined)
+ ) {
+ return { inscription: ins, member: m };
+ }
+ }
+ }
+ }
+ }
+ await sleep(3000);
+ }
+ fail(stage, `no inscription with confirmation_state=completed for pubkey ${pubkeyHex}`);
+}
+
+function publisherPubkeyHexFromEnv(envName, stage = 'publisher') {
+ const skHex = process.env[envName];
+ if (!skHex || skHex.startsWith('REPLACE_ME_')) {
+ return null;
+ }
+ try {
+ const sk = decodeHexExact(skHex, 32, envName);
+ const { pkBytes } = bip340NormaliseSecret(sk);
+ return encodeHexLower(pkBytes);
+ } catch (e) {
+ fail(stage, `cannot derive publisher pubkey from ${envName}: ${e}`);
+ }
+}
+
+function publisherPubkeyHex() {
+ return publisherPubkeyHexFromEnv('PUBLISHER_KEY');
+}
+
+function usdDemoAssetId(alicePk0) {
+ const nameHash = createHash('sha256').update(USD_DEMO.name, 'utf8').digest();
+ const aidDigest = assetIdV1(
+ GENESIS_TAG,
+ alicePk0,
+ nameHash,
+ USD_DEMO.decimals,
+ USD_DEMO.issuance_version,
+ );
+ return encodeHexLower(digestToBytes(aidDigest));
+}
+
+function eurDemoAssetId(carolPk0) {
+ const nameHash = createHash('sha256').update(EUR_DEMO.name, 'utf8').digest();
+ const termsSalt = decodeHexExact(TERMS_SALT_FIXTURE_HEX, 32, 'terms_salt_fixture');
+ const aidDigest = assetIdV2(
+ GENESIS_TAG,
+ carolPk0,
+ nameHash,
+ EUR_DEMO.decimals,
+ EUR_DEMO.issuance_version,
+ BigInt(EUR_DEMO.cap_total),
+ termsSalt,
+ );
+ return encodeHexLower(digestToBytes(aidDigest));
+}
+
+/**
+ * Subscribe to GET /v1/receipts/stream and wait for a receipt with
+ * state === 'completed' (optionally matching asset_id). SSE framing is
+ * axum-style: `event: receipt\ndata: \n\n` (api/src/routes.rs tests;
+ * receipt_to_json fields: coin_id, asset_id, amount, state, credited_at).
+ *
+ * The hub is push-only with no catch-up replay (receipts.rs): open the
+ * stream before the credit is published, or the event is missed.
+ */
+async function waitForCompletedReceipt(sessionToken, assetIdHex, stage) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), JOB_WAIT_MS);
+ try {
+ const res = await fetch(`${API_URL}/v1/receipts/stream`, {
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ Accept: 'text/event-stream',
+ },
+ signal: controller.signal,
+ });
+ if (!res.ok) {
+ const body = await res.text();
+ fail(stage, `receipts/stream HTTP ${res.status}: ${body}`);
+ }
+ if (!res.body) {
+ fail(stage, 'receipts/stream response has no body');
+ }
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ fail(stage, 'receipts/stream ended before a completed receipt arrived');
+ }
+ buffer += decoder.decode(value, { stream: true });
+ // SSE frames are delimited by a blank line (\n\n).
+ let sep;
+ while ((sep = buffer.indexOf('\n\n')) !== -1) {
+ const frame = buffer.slice(0, sep);
+ buffer = buffer.slice(sep + 2);
+ const lines = frame.split(/\r?\n/);
+ let eventName = 'message';
+ const dataParts = [];
+ for (const line of lines) {
+ if (line.startsWith('event:')) {
+ eventName = line.slice('event:'.length).trim();
+ } else if (line.startsWith('data:')) {
+ dataParts.push(line.slice('data:'.length).trimStart());
+ }
+ }
+ if (eventName === 'error') {
+ fail(stage, `receipts/stream error frame: ${dataParts.join('\n')}`);
+ }
+ if (eventName !== 'receipt' || dataParts.length === 0) {
+ continue;
+ }
+ let receipt;
+ try {
+ receipt = JSON.parse(dataParts.join('\n'));
+ } catch (e) {
+ fail(stage, `receipts/stream data is not JSON: ${e}`);
+ }
+ if (receipt && typeof receipt === 'object') {
+ if (receipt.state === 'failed') {
+ fail(
+ stage,
+ `receipt state=failed for coin_id=${receipt.coin_id ?? '?'}`,
+ );
+ }
+ if (receipt.state === 'completed') {
+ if (
+ assetIdHex !== undefined &&
+ typeof receipt.asset_id === 'string' &&
+ receipt.asset_id.toLowerCase() !== assetIdHex.toLowerCase()
+ ) {
+ continue;
+ }
+ if (typeof receipt.coin_id !== 'string' || receipt.coin_id.length === 0) {
+ fail(stage, 'completed receipt missing coin_id');
+ }
+ try {
+ await reader.cancel();
+ } catch {
+ /* stream already closing */
+ }
+ return receipt;
+ }
+ // pending — keep waiting for completed
+ }
+ }
+ }
+ } catch (e) {
+ if (e && e.name === 'AbortError') {
+ fail(stage, `timeout waiting for completed receipt on /v1/receipts/stream`);
+ }
+ throw e;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/** Mine 1 inclusion block + finality, then assert §3.10 completed for spend pubkey. */
+async function postTransitionOnChain(spendPubkey, stagePrefix) {
+ mineBlocks(1, `${stagePrefix}-include`);
+ await waitNullifierCompleted(encodeHexLower(spendPubkey), `${stagePrefix}-nullifier-present`);
+ mineBlocks(BOUNDS.finality_confirmations, `${stagePrefix}-finality`);
+ await waitInscriptionCompletedForPubkey(
+ encodeHexLower(spendPubkey),
+ `${stagePrefix}-§3.10`,
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Stages
+// ---------------------------------------------------------------------------
+
+async function stage1_info(client) {
+ const info = await client.info();
+ if (info.network !== 'regtest') {
+ fail(1, `network: expected regtest, got ${JSON.stringify(info.network)}`);
+ }
+ if (info.protocol_version !== 'v1') {
+ fail(1, `protocol_version: expected v1, got ${JSON.stringify(info.protocol_version)}`);
+ }
+ const digests = info.circuit_digests;
+ if (!digests || typeof digests !== 'object') {
+ fail(1, 'circuit_digests missing on /v1/info');
+ }
+ const c = digests.C ?? digests.c;
+ const cb = digests.C_balance ?? digests.c_balance;
+ if (c !== PINNED_DIGEST_C) {
+ fail(1, `circuit_digests.C: expected ${PINNED_DIGEST_C}, got ${c}`);
+ }
+ if (cb !== PINNED_DIGEST_C_BALANCE) {
+ fail(1, `circuit_digests.C_balance: expected ${PINNED_DIGEST_C_BALANCE}, got ${cb}`);
+ }
+ for (const [k, v] of Object.entries(BOUNDS)) {
+ if (info[k] !== v) {
+ fail(1, `bound ${k}: expected ${v}, got ${JSON.stringify(info[k])}`);
+ }
+ }
+ pass(1, 'GET /v1/info matches pinned regtest digests + bounds');
+ return info;
+}
+
+async function stage2_alice_mint(client, seed, alice, host) {
+ await entrustBundle(alice, host);
+
+ const assetIdHex = usdDemoAssetId(alice.sk0.publicKey);
+ const pub = publisherPubkeyHex();
+
+ // First mint has no AccountState yet → self-output exemption fails; every
+ // mint/send output (including Alice's self-mint) needs a real Invoice.
+ const selfInvoice = await issueInvoice({
+ amount: USD_DEMO.amount,
+ assetId: assetIdHex,
+ relays: [RELAY_URL],
+ sk0Secret: alice.sk0.secretKey,
+ nkCommit: alice.nkCommit,
+ ivpk: alice.ivpk,
+ opSecret: alice.op,
+ });
+
+ const request = {
+ kind: 'mint',
+ output_templates: [
+ {
+ recipient: alice.subject,
+ asset_id: assetIdHex,
+ amount: USD_DEMO.amount,
+ delivery: { type: 'invoice', invoice: selfInvoice },
+ },
+ ],
+ issuance: {
+ name: USD_DEMO.name,
+ decimals: USD_DEMO.decimals,
+ issuance_version: 1,
+ amount: USD_DEMO.amount,
+ creator_pubkey: encodeHexLower(alice.sk0.publicKey),
+ },
+ };
+ if (pub) {
+ request.publisher_pubkey = pub;
+ }
+
+ const { job, spendPubkey } = await runSignedTransition(
+ client,
+ seed,
+ alice,
+ request,
+ '2-mint',
+ );
+ pass(2, `Alice mint job completed (${job.job_id}); awaiting_signature recompute ok`);
+
+ await postTransitionOnChain(spendPubkey, '2');
+ pass(2, 'mint nullifier inscribed; §3.10 completed after finality blocks');
+
+ const balances = await pullBalances(client, alice);
+ assertBalancesExact(2, balances, { [assetIdHex]: USD_DEMO.amount });
+ pass(2, `Alice balance USD-Demo == ${USD_DEMO.amount}`);
+
+ return { assetIdHex, mintJob: job, mintSpendPubkey: spendPubkey };
+}
+
+async function stage2b_carol_eur(client, seed, alice, carol, host, usdAssetIdHex) {
+ await entrustBundle(carol, host);
+
+ const eurAssetIdHex = eurDemoAssetId(carol.sk0.publicKey);
+ const pub = publisherPubkeyHex();
+
+ // Token-standard-2 forbids self-credit: mint explicitly to Alice. Alice's
+ // Invoice is the delivery credential (non-self output).
+ const aliceInvoice = await issueInvoice({
+ amount: EUR_DEMO.amount,
+ assetId: eurAssetIdHex,
+ relays: [RELAY_URL],
+ sk0Secret: alice.sk0.secretKey,
+ nkCommit: alice.nkCommit,
+ ivpk: alice.ivpk,
+ opSecret: alice.op,
+ });
+
+ // Open Alice's receipts stream before the mint so the credit is not missed
+ // (SSE is push-only; no catch-up replay).
+ const aliceSession = await client.openOwnershipPullSession({
+ subject: alice.subject,
+ sk0: alice.sk0.secretKey,
+ nkCommit: alice.nkCommit,
+ });
+ const receiptWait = waitForCompletedReceipt(
+ aliceSession.session,
+ eurAssetIdHex,
+ '2b-receipt',
+ );
+
+ const mintRequest = {
+ kind: 'mint',
+ output_templates: [
+ {
+ recipient: alice.subject,
+ asset_id: eurAssetIdHex,
+ amount: EUR_DEMO.amount,
+ delivery: { type: 'invoice', invoice: aliceInvoice },
+ },
+ ],
+ issuance: {
+ name: EUR_DEMO.name,
+ decimals: EUR_DEMO.decimals,
+ issuance_version: 2,
+ amount: EUR_DEMO.amount,
+ cap_total: EUR_DEMO.cap_total,
+ terms_salt: TERMS_SALT_FIXTURE_HEX,
+ creator_pubkey: encodeHexLower(carol.sk0.publicKey),
+ },
+ };
+ if (pub) {
+ mintRequest.publisher_pubkey = pub;
+ }
+
+ const { job: mintJob, spendPubkey: mintSpend } = await runSignedTransition(
+ client,
+ seed,
+ carol,
+ mintRequest,
+ '2b-mint',
+ );
+ pass('2b', `Carol EUR-Demo mint job completed (${mintJob.job_id})`);
+
+ await postTransitionOnChain(mintSpend, '2b-mint');
+
+ const eurReceipt = await receiptWait;
+ const foldCoinId = eurReceipt.coin_id;
+ pass('2b', `Alice discovered EUR-Demo coin_id via receipts stream`);
+
+ const receiveRequest = {
+ kind: 'receive',
+ fold_coin_ids: [foldCoinId],
+ };
+ const { job: rxJob, spendPubkey: rxSpend } = await runSignedTransition(
+ client,
+ seed,
+ alice,
+ receiveRequest,
+ '2b-receive',
+ );
+ pass('2b', `Alice EUR-Demo receive completed (${rxJob.job_id})`);
+
+ await postTransitionOnChain(rxSpend, '2b-receive');
+
+ // After stage 4 Alice holds ALICE_AFTER_SEND USD; if stage 4 has not run,
+ // she still holds the full mint. Require usdAssetIdHex for the map key;
+ // amount is whatever pull reports for USD plus exact EUR.
+ const balances = await pullBalances(client, alice);
+ const usdBal = balances.get(usdAssetIdHex);
+ if (usdBal === undefined) {
+ fail('2b', `Alice missing USD-Demo balance after EUR receive`);
+ }
+ assertBalancesExact('2b', balances, {
+ [usdAssetIdHex]: usdBal,
+ [eurAssetIdHex]: EUR_DEMO.amount,
+ });
+ pass(
+ '2b',
+ `Alice two-asset balances: USD-Demo=${usdBal}, EUR-Demo=${EUR_DEMO.amount}`,
+ );
+
+ return { eurAssetIdHex, carolMintJob: mintJob };
+}
+
+async function stage3_4_alice_send(client, seed, alice, bob, host, assetIdHex, aliceMintCoinId, eurAssetIdHex) {
+ const pub = publisherPubkeyHex();
+ if (!pub) {
+ fail(3, 'PUBLISHER_KEY required to assert fee-less case (c) with publisher_pubkey');
+ }
+
+ // Negative control: fee_address MUST be rejected (presence matrix).
+ const feeReject = await httpJson('POST', `${API_URL}/v1/tx`, {
+ kind: 'send',
+ subject: alice.subject,
+ next_pubkey: encodeHexLower(
+ spendAt(seed, alice.accountIndex, alice.sendCounter + 1).publicKey,
+ ),
+ npk_rand: encodeHexLower(freshNpkRand()),
+ publisher_pubkey: pub,
+ fee_address: alice.subject,
+ input_coins: ['00'.repeat(32)],
+ output_templates: [
+ { recipient: bob.subject, asset_id: assetIdHex, amount: SEND_AMOUNT },
+ ],
+ });
+ if (feeReject.status < 400) {
+ fail(3, `fee_address request MUST be rejected; got HTTP ${feeReject.status}`);
+ }
+ pass(3, 'fee_address on send is rejected (presence matrix case (c) negative)');
+
+ // Bob must entrust before Alice delivers so the node holds his ivk/nk for
+ // the incoming scanner and any later receive he proves himself.
+ await entrustBundle(bob, host);
+
+ if (typeof aliceMintCoinId !== 'string' || aliceMintCoinId.length === 0) {
+ fail(3, 'aliceMintCoinId required (stage 2 mintJob.result.output_coin_ids[0])');
+ }
+
+ const bobInvoice = await issueInvoice({
+ amount: SEND_AMOUNT,
+ assetId: assetIdHex,
+ relays: [RELAY_URL],
+ sk0Secret: bob.sk0.secretKey,
+ nkCommit: bob.nkCommit,
+ ivpk: bob.ivpk,
+ opSecret: bob.op,
+ });
+
+ // Open Bob's receipts stream before the send so the credit push is observed
+ // (SubscribeReceipts is push-only; no historical replay).
+ const bobSession = await client.openOwnershipPullSession({
+ subject: bob.subject,
+ sk0: bob.sk0.secretKey,
+ nkCommit: bob.nkCommit,
+ });
+ const receiptWait = waitForCompletedReceipt(bobSession.session, assetIdHex, '3-receipt');
+
+ const request = {
+ kind: 'send',
+ publisher_pubkey: pub,
+ input_coins: [aliceMintCoinId],
+ output_templates: [
+ {
+ recipient: bob.subject,
+ asset_id: assetIdHex,
+ amount: SEND_AMOUNT,
+ delivery: { type: 'invoice', invoice: bobInvoice },
+ },
+ ],
+ };
+ const { job, spendPubkey } = await runSignedTransition(
+ client,
+ seed,
+ alice,
+ request,
+ '3-send',
+ );
+ pass(3, `Alice→Bob send job completed (${job.job_id}); awaiting_signature recompute ok`);
+
+ await postTransitionOnChain(spendPubkey, '3');
+ pass(3, 'send nullifier inscribed; §3.10 completed after finality blocks');
+
+ const bobReceipt = await receiptWait;
+ pass(3, `Bob receipt discovered coin_id=${bobReceipt.coin_id.slice(0, 16)}…`);
+
+ // Send outputs are ordered as caller recipient templates followed by
+ // per-asset change. Preserve Alice's real change coin for the later
+ // portability send: pull/account-state exposes the counter but no coin id.
+ const outputCoinIds = job.result?.output_coin_ids;
+ if (!Array.isArray(outputCoinIds) || outputCoinIds.length !== 2) {
+ fail(
+ 3,
+ `Alice→Bob send expected recipient + change output_coin_ids, got ` +
+ JSON.stringify(outputCoinIds),
+ );
+ }
+ if (outputCoinIds[0] !== bobReceipt.coin_id) {
+ fail(
+ 3,
+ `Alice→Bob recipient output coin id ${outputCoinIds[0]} does not match Bob receipt ${bobReceipt.coin_id}`,
+ );
+ }
+ const aliceChangeCoinId = outputCoinIds[1];
+ if (typeof aliceChangeCoinId !== 'string' || aliceChangeCoinId.length === 0) {
+ fail(3, 'Alice→Bob send result missing Alice change coin id');
+ }
+
+ const balances = await pullBalances(client, alice);
+ const expectedAfterSend = { [assetIdHex]: ALICE_AFTER_SEND };
+ if (typeof eurAssetIdHex === 'string' && eurAssetIdHex.length > 0) {
+ expectedAfterSend[eurAssetIdHex] = EUR_DEMO.amount;
+ }
+ assertBalancesExact(4, balances, expectedAfterSend);
+ pass(
+ 4,
+ `Alice balances after fee-less send of ${SEND_AMOUNT}: USD-Demo == ${ALICE_AFTER_SEND}` +
+ (typeof eurAssetIdHex === 'string' && eurAssetIdHex.length > 0
+ ? `, EUR-Demo == ${EUR_DEMO.amount} (untouched)`
+ : ''),
+ );
+
+ return {
+ assetIdHex,
+ sendJob: job,
+ sendSpendPubkey: spendPubkey,
+ bobCoinId: bobReceipt.coin_id,
+ aliceChangeCoinId,
+ };
+}
+
+async function stage5_bob_receive(client, seed, bob, assetIdHex, bobCoinId) {
+ let discoveredCoinId = bobCoinId;
+
+ // Prefer the coin_id discovered during stage 3/4 (stream opened before
+ // delivery). If missing (stage 5 run alone after a prior delivery), try
+ // a fresh stream wait — this will only succeed if a new credit is still
+ // pending; the hub does not replay already-published receipts.
+ if (typeof discoveredCoinId !== 'string' || discoveredCoinId.length === 0) {
+ const bobSession = await client.openOwnershipPullSession({
+ subject: bob.subject,
+ sk0: bob.sk0.secretKey,
+ nkCommit: bob.nkCommit,
+ });
+ const receipt = await waitForCompletedReceipt(
+ bobSession.session,
+ assetIdHex,
+ '5-receipt',
+ );
+ discoveredCoinId = receipt.coin_id;
+ }
+ pass(5, `Bob fold coin_id ready (${discoveredCoinId.slice(0, 16)}…)`);
+
+ // Self-published receive: omit publisher_pubkey so the kernel default path runs.
+ const request = {
+ kind: 'receive',
+ fold_coin_ids: [discoveredCoinId],
+ genesis_pubkey: encodeHexLower(bob.sk0.publicKey),
+ };
+ const { job, spendPubkey } = await runSignedTransition(
+ client,
+ seed,
+ bob,
+ request,
+ '5-receive',
+ );
+ pass(5, `Bob receive job completed (${job.job_id})`);
+
+ // Same on-chain wait pattern as mint/send (header mandate: every
+ // confirmation wait = 6 mined blocks). Self-published receive still
+ // consumes Bob's spend key and publishes a nullifier.
+ await postTransitionOnChain(spendPubkey, '5');
+ pass(5, 'Bob receive nullifier inscribed; §3.10 completed after finality blocks');
+
+ const balances = await pullBalances(client, bob);
+ assertBalancesExact(5, balances, { [assetIdHex]: SEND_AMOUNT });
+ pass(5, `Bob balance USD-Demo == ${SEND_AMOUNT}`);
+
+ return { bobReceiveJob: job, bobReceiveSpendPubkey: spendPubkey };
+}
+
+async function stage6_confirmation_link(sendSpendPubkey) {
+ if (typeof sendSpendPubkey !== 'string' && !(sendSpendPubkey instanceof Uint8Array)) {
+ fail(6, 'stage 6 requires sendSpendPubkey from stage 3/4 (Alice→Bob payment)');
+ }
+ const pubkeyHex =
+ typeof sendSpendPubkey === 'string'
+ ? sendSpendPubkey
+ : encodeHexLower(sendSpendPubkey);
+ const hit = await waitInscriptionCompletedForPubkey(pubkeyHex, '6');
+ if (hit.inscription.confirmation_state !== 'completed') {
+ fail(
+ 6,
+ `confirmation link expected confirmation_state=completed, got ${JSON.stringify(hit.inscription.confirmation_state)}`,
+ );
+ }
+ pass(
+ 6,
+ `confirmation link for Alice→Bob payment reports §3.10 completed (pubkey ${pubkeyHex.slice(0, 16)}…)`,
+ );
+ return hit;
+}
+
+async function stage7_reorg() {
+ try {
+ // 1. Read node1 accumulator before the reorg (sanity baseline only).
+ const before = await readAccumulator(API_URL);
+ log(`stage 7: pre-reorg node1 tip_height=${before.tip_height} size=${before.size}`);
+
+ // 2. Drive a shallow, unambiguously-canonical reorg on the shared bitcoind:
+ // invalidate the last 3 blocks, then mine a strictly longer (6-block)
+ // competing branch so the new branch is unambiguously longer.
+ const bestHeightBefore = Number(btcCli(['getblockcount']));
+ const forkFromHeight = bestHeightBefore - 2;
+ const invalidateHash = btcCli(['getblockhash', String(forkFromHeight)]);
+ btcCli(['invalidateblock', invalidateHash]);
+
+ mineBlocks(6, 7); // 6 > 3 invalidated blocks -> new branch is strictly longer
+
+ // Diagnostic only — do not gate waits on exact bitcoind tip hash (regtest
+ // can leave equal-height races; nodes lag bitcoind's tip).
+ const newTip = btcCli(['getbestblockhash']);
+ log(`stage 7: reorg mined, bitcoind tip ${newTip}`);
+
+ // 3. Wait for node-to-node convergence + tip stability (not exact hash match).
+ const minTipHeight = forkFromHeight + 6 - 1;
+ await waitNodesConverged(API_URL, API_URL_2, minTipHeight, 90_000, 7);
+
+ // 4. Re-read post-reorg accumulators from both nodes.
+ const post1 = await readAccumulator(API_URL);
+ const post2 = await readAccumulator(API_URL_2);
+
+ // 5. N-09 mandate wants equality against a fresh full rescan of the canonical chain.
+ // node2 booted fresh this session and scans the canonical chain from genesis, so it
+ // IS an independent full-rescan reference; node1 (which processed the reorg
+ // incrementally) converging to it demonstrates canonical-replay convergence.
+ if (post1.size !== post2.size || post1.root !== post2.root) {
+ fail(
+ 7,
+ `reorg convergence broken: node1=(size ${post1.size}, root ${post1.root}) ` +
+ `node2=(size ${post2.size}, root ${post2.root})`,
+ );
+ }
+ pass(
+ 7,
+ `reorg converged (N-09): both nodes at size ${post1.size}, root ` +
+ `${post1.root.slice(0, 16)}…, tip_height ${post1.tip_height} — node1 (incremental ` +
+ `through reorg) == node2 (independent from-genesis scan)`,
+ );
+ } catch (err) {
+ fail(7, err.message);
+ }
+}
+
+async function stage8_recovery() {
+ try {
+ const seed = seedFromMnemonicV1(MNEMONIC);
+ const bob = buildAccount(seed, 1);
+
+ const node1Client = new ZkCoinsV1Client({
+ apiUrl: API_URL,
+ network: 'regtest',
+ requestTimeoutMs: 120_000,
+ });
+ const node2Client = new ZkCoinsV1Client({
+ apiUrl: API_URL_2,
+ network: 'regtest',
+ requestTimeoutMs: 120_000,
+ });
+
+ // Precondition: Bob on node1 must already hold SEND_AMOUNT (stages 1–5).
+ const node1Balances = await pullBalances(node1Client, bob);
+ let assetIdHex = null;
+ let node1Amount;
+ for (const [aid, amount] of node1Balances) {
+ if (amount === SEND_AMOUNT) {
+ assetIdHex = aid;
+ node1Amount = amount;
+ break;
+ }
+ }
+ if (node1Amount !== SEND_AMOUNT || assetIdHex === null) {
+ const actual =
+ node1Balances.size === 0
+ ? 'none'
+ : [...node1Balances.entries()]
+ .map(([k, v]) => `${k.slice(0, 16)}…=${v}`)
+ .join(', ');
+ fail(8, `node1 Bob balance precondition failed: expected ${SEND_AMOUNT}, got ${actual}`);
+ }
+
+ // Entrust Bob's operational bundle onto node2 so §4.5 recovery has a subject
+ // to scan under. Host must match ZKCOINS_PUBLIC_HOST_2 (api2/node2 chan_bind).
+ await entrustBundle(bob, '127.0.0.1:8081', API_URL_2);
+
+ // Poll node2 until Bob's recovered balance matches node1 (or budget expires).
+ const deadline = Date.now() + 180_000;
+ let last;
+ while (Date.now() < deadline) {
+ try {
+ const node2Balances = await pullBalances(node2Client, bob);
+ last = node2Balances.get(assetIdHex);
+ if (last === SEND_AMOUNT) {
+ pass(
+ 8,
+ 'recovery (Req 6): node2 reconstructed Bob from seed+chain+replicated blobs — 250000 == node1',
+ );
+ return;
+ }
+ } catch (e) {
+ // Expected transient: while the §4.5 recovery campaign is still running,
+ // node2 returns HTTP 500 "no indexed AccountState for subject" (fail-closed
+ // backend — it never invents empty state). Tolerate it and keep polling
+ // until the campaign installs Bob's head or the deadline elapses; only a
+ // missing balance AFTER the deadline is a real failure.
+ last = `pending (${(e && e.message ? e.message : String(e)).slice(0, 80)})`;
+ }
+ await sleep(3000);
+ }
+ fail(8, `recovery did not restore Bob: got ${last ?? 'none'}`);
+ } catch (err) {
+ fail(8, err.message);
+ }
+}
+
+async function stage9_portability(ctx) {
+ try {
+ const seed = seedFromMnemonicV1(MNEMONIC);
+ const alice = buildAccount(seed, 0);
+ const bob = buildAccount(seed, 1);
+ const carol = buildAccount(seed, 2);
+
+ const node1Client = new ZkCoinsV1Client({
+ apiUrl: API_URL,
+ network: 'regtest',
+ requestTimeoutMs: 120_000,
+ });
+ const node2Client = new ZkCoinsV1Client({
+ apiUrl: API_URL_2,
+ network: 'regtest',
+ requestTimeoutMs: 120_000,
+ });
+
+ const usdAssetIdHex = usdDemoAssetId(alice.sk0.publicKey);
+ const eurAssetIdHex = eurDemoAssetId(carol.sk0.publicKey);
+ const expectedNode1Balances = {
+ [usdAssetIdHex]: ALICE_AFTER_SEND,
+ [eurAssetIdHex]: EUR_DEMO.amount,
+ };
+
+ // Node1 is the source of truth, but also pin the expected complete map so
+ // a coincidentally equal incomplete recovery cannot satisfy portability.
+ const node1Balances = await pullBalances(node1Client, alice);
+ assertBalancesExact(9, node1Balances, expectedNode1Balances);
+ const node1Map = Object.fromEntries(node1Balances);
+
+ // Repointing is configuration-only: same seed-derived wallet material,
+ // different API URL and channel-binding host.
+ await entrustBundle(alice, '127.0.0.1:8081', API_URL_2);
+
+ const deadline = Date.now() + 180_000;
+ let node2Balances = null;
+ let last = 'none';
+ while (Date.now() < deadline) {
+ try {
+ const candidate = await pullBalances(node2Client, alice);
+ last =
+ candidate.size === 0
+ ? 'empty map'
+ : [...candidate.entries()].map(([k, v]) => `${k.slice(0, 16)}…=${v}`).join(', ');
+ const exact =
+ candidate.size === node1Balances.size &&
+ [...node1Balances].every(([assetId, amount]) => candidate.get(assetId) === amount);
+ if (exact) {
+ node2Balances = candidate;
+ break;
+ }
+ } catch (e) {
+ // Expected while node2's §4.5 campaign has not installed Alice's
+ // recovered AccountState yet. The backend fails closed with HTTP 500;
+ // keep polling, but fail if the deadline expires.
+ last = `pending (${(e && e.message ? e.message : String(e)).slice(0, 80)})`;
+ }
+ await sleep(3000);
+ }
+ if (node2Balances === null) {
+ fail(9, `portability recovery did not reproduce Alice's node1 balances: got ${last}`);
+ }
+ assertBalancesExact(9, node2Balances, node1Map);
+ pass(9, 'portability (Req 10): node2 balances identical to node1');
+
+ const aliceChangeCoinId = ctx?.aliceChangeCoinId;
+ if (typeof aliceChangeCoinId !== 'string' || aliceChangeCoinId.length === 0) {
+ fail(9, 'stage 9 requires Alice change coin id from stage 3/4 in the same run');
+ }
+
+ // The coin id is threaded from the completed stage-3 job because pull
+ // records are opaque and the JS SDK has no canonical CoinProof decoder.
+ // The key counter, however, is live wallet state and is read from node2.
+ const pull = await node2Client.openOwnershipPullSession({
+ subject: alice.subject,
+ sk0: alice.sk0.secretKey,
+ nkCommit: alice.nkCommit,
+ });
+ const state = await node2Client.getAccountState(pull.session);
+ if (!Number.isSafeInteger(state.send_counter) || state.send_counter < 0) {
+ fail(9, `node2 returned invalid Alice send_counter ${JSON.stringify(state.send_counter)}`);
+ }
+ alice.sendCounter = state.send_counter;
+ const expectedCurrentPubkey = encodeHexLower(
+ spendAt(seed, alice.accountIndex, alice.sendCounter).publicKey,
+ );
+ if (state.current_pubkey !== expectedCurrentPubkey) {
+ fail(
+ 9,
+ `node2 Alice current_pubkey does not match seed-derived spend key at counter ${alice.sendCounter}`,
+ );
+ }
+
+ const publisher = publisherPubkeyHexFromEnv('PUBLISHER_KEY_2', 9);
+ if (!publisher) {
+ fail(9, 'PUBLISHER_KEY_2 required for the node2 portability send');
+ }
+ const amount = '1000';
+ const bobInvoice = await issueInvoice({
+ amount,
+ assetId: usdAssetIdHex,
+ relays: [RELAY_URL],
+ sk0Secret: bob.sk0.secretKey,
+ nkCommit: bob.nkCommit,
+ ivpk: bob.ivpk,
+ opSecret: bob.op,
+ });
+ const request = {
+ kind: 'send',
+ input_coins: [aliceChangeCoinId],
+ output_templates: [
+ {
+ recipient: bob.subject,
+ asset_id: usdAssetIdHex,
+ amount,
+ delivery: { type: 'invoice', invoice: bobInvoice },
+ },
+ ],
+ publisher_pubkey: publisher,
+ };
+ const { job, spendPubkey } = await runSignedTransition(
+ node2Client,
+ seed,
+ alice,
+ request,
+ '9-send',
+ );
+ if (job.status !== 'completed') {
+ fail(9, `node2 portability send job ended in ${JSON.stringify(job.status)}`);
+ }
+ await postTransitionOnChain(spendPubkey, '9');
+ pass(9, 'portability (Req 10): send from repointed node2 succeeded');
+ } catch (err) {
+ fail(9, err.message);
+ }
+}
+
+function runVerifyAttestation(attestationHex) {
+ // The attestation hex is large (proof ~180 KB → ~360 KB hex), far past the OS
+ // argv length limit ("argument list too long"), so feed it on stdin instead of
+ // an --attestation-hex arg (the CLI reads trimmed stdin when the flag is absent).
+ return spawnSync(
+ 'docker',
+ ['compose', '-f', COMPOSE_FILE, 'exec', '-T', 'node', 'verify_attestation'],
+ { encoding: 'utf8', input: attestationHex },
+ );
+}
+
+async function stage10_attestation(client, seed, alice, host, usdAssetIdHex) {
+ if (typeof usdAssetIdHex !== 'string' || usdAssetIdHex.length === 0) {
+ fail('10', 'usdAssetIdHex missing/empty — stage 10 requires Alice USD asset id from stage 2');
+ }
+
+ // Produce a real BalanceAttestationV1 via the SDK (challenge is opened inside attestBalance).
+ const assetIdBytes = decodeHexExact(usdAssetIdHex, 32, 'usdAssetIdHex');
+ const accepted = await client.attestBalance({
+ subject: alice.subject,
+ sk0: alice.sk0.secretKey,
+ nkCommit: alice.nkCommit,
+ assetId: assetIdBytes,
+ host,
+ });
+ const attestJob = await waitJobStatus(client, accepted.job_id, 'completed', '10');
+ const attestationHex = attestJob.result?.attestation;
+ if (typeof attestationHex !== 'string' || attestationHex.length === 0) {
+ fail('10', 'attest job completed but result.attestation missing/empty');
+ }
+ pass(
+ '10',
+ `attestation job completed (job ${accepted.job_id}, ${attestationHex.length / 2} bytes)`,
+ );
+
+ // Independent verifier CLI against the untampered attestation (PASS).
+ const verifyReal = runVerifyAttestation(attestationHex);
+ if (verifyReal.status !== 0) {
+ fail(
+ '10',
+ `independent verifier rejected a VALID attestation (exit ${verifyReal.status}): ` +
+ `${verifyReal.stdout}${verifyReal.stderr}`,
+ );
+ }
+ pass('10', 'independent verifier accepted Alice attestation (PASS)');
+
+ // Tamper the LAST byte of the balance field (wire offset 64, 16-byte u128 BE →
+ // hex chars [158, 160)) so header.balance no longer matches the proof's public input.
+ const balanceLastByteHex = attestationHex.slice(158, 160);
+ const tamperedByte = (parseInt(balanceLastByteHex, 16) ^ 0x01).toString(16).padStart(2, '0');
+ const tamperedAttestationHex =
+ attestationHex.slice(0, 158) + tamperedByte + attestationHex.slice(160);
+ if (tamperedAttestationHex === attestationHex) {
+ fail(
+ '10',
+ 'tamper byte XOR produced no change — attestation hex too short or offset math wrong',
+ );
+ }
+
+ const verifyTampered = runVerifyAttestation(tamperedAttestationHex);
+ const stderrTampered = verifyTampered.stderr || '';
+ if (verifyTampered.status === 0) {
+ fail('10', 'CRITICAL: verifier accepted a header-tampered attestation');
+ }
+ if (!stderrTampered.includes('public input `balance` does not match')) {
+ fail(
+ '10',
+ `tampered attestation rejected but not for the expected balance mismatch ` +
+ `(exit ${verifyTampered.status}): ${verifyTampered.stdout}${stderrTampered}`,
+ );
+ }
+ pass('10', 'tampered attestation rejected by independent verifier (FAIL, binding holds)');
+}
+
+async function stage11_grants(client, alice, host, usdAssetIdHex, eurAssetIdHex) {
+ const d = decodeHexExact(GRANTEE_SECRET_FIXTURE_HEX, 32, 'grantee_secret_d');
+ const { pkBytes: granteePk } = bip340NormaliseSecret(d);
+ const usdAssetIdBytes = decodeHexExact(usdAssetIdHex, 32, 'usdAssetIdHex');
+ const eurAssetIdBytes = decodeHexExact(eurAssetIdHex, 32, 'eurAssetIdHex');
+
+ // 1. Issue USD-Demo-scoped view grant for the deterministic grantee.
+ const issued = await client.issueViewGrant({
+ subject: alice.subject,
+ sk0: alice.sk0.secretKey,
+ nkCommit: alice.nkCommit,
+ granteePk,
+ scope: {
+ assetIds: [usdAssetIdBytes],
+ notBefore: 0n,
+ notAfter: SCOPE_NOT_AFTER_UNBOUNDED,
+ },
+ grantExpiry: 9999999999n,
+ host,
+ });
+ const grant = issued && issued.grant;
+ if (typeof grant !== 'string' || grant.length === 0) {
+ fail('11', 'issueViewGrant returned missing/empty grant string');
+ }
+ pass('11', 'USD-scoped view grant issued');
+
+ // 2. In-scope grant pull (USD only) — must return Alice's USD history.
+ const grantPull = await client.openGrantPullSession(
+ { subject: alice.subject, grant, granteeSecret: d },
+ { assetIds: [usdAssetIdBytes] },
+ );
+ if (!Array.isArray(grantPull.records)) {
+ fail('11', 'grant pull in-scope USD: records missing/not an array');
+ }
+ if (grantPull.records.length === 0) {
+ fail(
+ '11',
+ 'grant pull in-scope USD returned 0 records — Alice must hold USD-Demo history from stage 2',
+ );
+ }
+ pass('11', `grantee pulled in-scope USD records (N=${grantPull.records.length})`);
+
+ // 3. Exact-scope cross-check: Alice's own ownership pull with the same USD scope.
+ const aliceChallenge = await client.openPullChallenge(alice.subject);
+ const aliceProof = buildOwnershipProof({
+ subject: alice.subject,
+ sk0: alice.sk0.secretKey,
+ nkCommit: alice.nkCommit,
+ challenge: aliceChallenge,
+ host,
+ });
+ const aliceScopedPull = await client.openPullSession({
+ challenge: aliceChallenge,
+ proof: aliceProof,
+ scope: { assetIds: [usdAssetIdBytes] },
+ });
+ if (!Array.isArray(aliceScopedPull.records)) {
+ fail('11', 'Alice ownership scoped pull: records missing/not an array');
+ }
+
+ const grantIds = new Set(grantPull.records.map((r) => r.record_id));
+ const aliceIds = new Set(aliceScopedPull.records.map((r) => r.record_id));
+ const grantSorted = [...grantIds].sort().join(',');
+ const aliceSorted = [...aliceIds].sort().join(',');
+ let setsEqual = grantIds.size === aliceIds.size;
+ if (setsEqual) {
+ for (const id of grantIds) {
+ if (!aliceIds.has(id)) {
+ setsEqual = false;
+ break;
+ }
+ }
+ }
+ if (!setsEqual) {
+ fail(
+ '11',
+ `grant pull record-id set != ownership pull set: grant=[${grantSorted}] ownership=[${aliceSorted}]`,
+ );
+ }
+ pass('11', 'grant pull record-id set == ownership pull set (exact scope)');
+
+ // 4. Out-of-scope EUR pull under USD-only grant must be refused with 403 scope_exceeded.
+ try {
+ const eurPull = await client.openGrantPullSession(
+ { subject: alice.subject, grant, granteeSecret: d },
+ { assetIds: [eurAssetIdBytes] },
+ );
+ const n = Array.isArray(eurPull.records) ? eurPull.records.length : 'not-an-array';
+ fail(
+ '11',
+ `CRITICAL: grant scope clamp breached — EUR pulled under USD-only grant (records=${n})`,
+ );
+ } catch (err) {
+ if (
+ !(err instanceof V1ApiError) ||
+ err.status !== 403 ||
+ err.machineCode !== 'scope_exceeded'
+ ) {
+ const name = err && err.constructor && err.constructor.name;
+ const status = err instanceof V1ApiError ? err.status : undefined;
+ const machineCode = err instanceof V1ApiError ? err.machineCode : undefined;
+ const msg = err instanceof Error ? err.message : String(err);
+ fail(
+ '11',
+ `out-of-scope EUR pull threw unexpected error: ` +
+ `name=${name} status=${status} machineCode=${machineCode} message=${msg}`,
+ );
+ }
+ pass('11', 'out-of-scope EUR pull refused (403 scope_exceeded)');
+ }
+}
+
+// ---------------------------------------------------------------------------
+// CLI
+// ---------------------------------------------------------------------------
+
+const STAGES = {
+ 1: 'info digests + bounds',
+ 2: 'Alice mint USD-Demo → completed + §3.10 + balance',
+ '2b': 'Carol EUR-Demo genesis + Alice receive',
+ 3: 'Alice send fee-less to Bob + awaiting_signature recompute',
+ 4: 'Alice balance after send (paired with 3)',
+ 5: 'Bob receive fold + balance',
+ 6: 'confirmation link §3.10 completed',
+ 7: 'reorg control N-09',
+ 8: 'recovery control Req 6 (TODO)',
+ 9: 'portability control Req 10',
+ 10: 'attestation round-trip Req 9(b): produce + independent verify + tamper-reject',
+ 11: 'grant control Req 9(c): issue USD-scoped grant, in-scope pull ok, EUR out-of-scope refused',
+};
+
+
+function parseArgs(argv) {
+ /** @type {{ list: boolean, stages: string[] }} */
+ const out = { list: false, stages: [] };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--list') out.list = true;
+ else if (a === '--stage') {
+ const v = argv[++i];
+ if (!v) fail('cli', '--stage requires a value');
+ out.stages.push(v);
+ } else if (a === '-h' || a === '--help') {
+ console.log(`Usage: journey.mjs [--stage N]… [--list]
+Default: stages 1 and 2 (hard core that this tree can drive unmocked).
+Stages 2b–11 are named and fail with TODO until their mechanics are operable.
+`);
+ process.exit(0);
+ } else {
+ fail('cli', `unknown argument: ${a}`);
+ }
+ }
+ if (out.stages.length === 0 && !out.list) {
+ out.stages = ['1', '2'];
+ }
+ return out;
+}
+
+async function pollFaultReadiness(url, expectReady, timeoutMs, intervalMs) {
+ const deadline = Date.now() + timeoutMs;
+ let last = 'not polled';
+
+ while (Date.now() < deadline) {
+ try {
+ const response = await httpJson('GET', url);
+ last = `HTTP ${response.status} ${JSON.stringify(response.json)}`;
+ const ready = response.status === 200 && response.json?.ready === true;
+ if (ready === expectReady) {
+ return { matched: true, last };
+ }
+ } catch (e) {
+ last = `pending (${e.message})`;
+ if (!expectReady) {
+ return { matched: true, last };
+ }
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
+ }
+
+ return { matched: false, last };
+}
+
+/**
+ * Wait until a compose service reports Docker Health=healthy (via
+ * `docker compose up -d --wait`). Fail-loud on non-zero exit (timeout /
+ * unhealthy) through dockerCompose → fail().
+ */
+function waitComposeHealthy(service, timeoutS, stage) {
+ log(`waiting for compose service '${service}' healthy (timeout ${timeoutS}s)…`);
+ dockerCompose(
+ ['up', '-d', '--wait', '--wait-timeout', String(timeoutS), service],
+ stage,
+ );
+}
+
+/**
+ * Poll a plain-text HTTP liveness route until status 200 and body === expectBody.
+ * Fail-loud via fail(stage, …) on timeout. Not for JSON readiness shapes.
+ */
+async function pollHttpBody(url, expectBody, timeoutMs, intervalMs, stage, name) {
+ const deadline = Date.now() + timeoutMs;
+ let last = 'not polled';
+ log(`waiting for HTTP ${name} at ${url} (timeout ${timeoutMs}ms)…`);
+
+ while (Date.now() < deadline) {
+ try {
+ const res = await httpJson('GET', url);
+ last = `HTTP ${res.status} ${JSON.stringify(res.text)}`;
+ if (res.status === 200 && (res.text || '').trim() === expectBody) {
+ return;
+ }
+ } catch (e) {
+ last = `pending (${e.message})`;
+ }
+ await sleep(intervalMs);
+ }
+
+ fail(
+ stage,
+ `timeout after ${timeoutMs}ms waiting for ${name} (${url}) expect body ${JSON.stringify(expectBody)}; last observed: ${last}`,
+ );
+}
+
+/**
+ * Post-restart stack wait matching up.sh (node Docker health → node /health →
+ * api Docker health → api /health). Circuit rebuild may take many minutes.
+ */
+async function waitNodeStackPostRestart(
+ stage,
+ nodeService,
+ nodeHealthUrl,
+ apiService,
+ apiHealthUrl,
+) {
+ log(
+ `post-restart wait for ${nodeService}/${apiService} (circuit rebuild may take many minutes)…`,
+ );
+ waitComposeHealthy(nodeService, 1200, stage);
+ await pollHttpBody(nodeHealthUrl, 'ok', 120_000, 2_000, stage, `${nodeService} /health`);
+ waitComposeHealthy(apiService, 300, stage);
+ await pollHttpBody(apiHealthUrl, 'ok', 120_000, 2_000, stage, `${apiService} /health`);
+}
+
+async function faultStageBitcoind() {
+ const stage = 'fault-bitcoind';
+ log('stopping bitcoind and waiting for node1 to fail closed');
+ dockerCompose(['stop', 'bitcoind'], stage);
+
+ const fault = await pollFaultReadiness(
+ `${API_URL}/health/ready`,
+ false,
+ 90_000,
+ 2_000,
+ );
+
+ log('restoring bitcoind and restarting both affected nodes');
+ // Start + wait dependency healthy before node restart (up.sh wait_healthy 120s).
+ waitComposeHealthy('bitcoind', 120, stage);
+ dockerCompose(['restart', 'node'], stage);
+ dockerCompose(['restart', 'node2'], stage);
+
+ // up.sh post-restart sequence for both node/api pairs (shared bitcoind).
+ await waitNodeStackPostRestart(
+ stage,
+ 'node',
+ 'http://127.0.0.1:4242/health',
+ 'api',
+ `${API_URL}/health`,
+ );
+ await waitNodeStackPostRestart(
+ stage,
+ NODE2_SERVICE,
+ 'http://127.0.0.1:4243/health',
+ 'api2',
+ `${API_URL_2}/health`,
+ );
+
+ const recoveryTimeoutMs = 1_200_000;
+ const recovery = await pollFaultReadiness(
+ `${API_URL}/health/ready`,
+ true,
+ recoveryTimeoutMs,
+ 3_000,
+ );
+
+ if (!fault.matched) {
+ fail(
+ stage,
+ `bitcoind fault was not visible within 90000ms; last observed: ${fault.last}`,
+ );
+ }
+ if (!recovery.matched) {
+ fail(
+ stage,
+ `node1 did not become ready within ${recoveryTimeoutMs}ms after bitcoind recovery; last observed: ${recovery.last}`,
+ );
+ }
+
+ pass(stage, 'bitcoind fault detected; bitcoind and both nodes restored, node1 ready');
+}
+
+async function faultStagePostgres() {
+ const stage = 'fault-postgres';
+ log('stopping node1 postgres and waiting for node1 to fail closed');
+ dockerCompose(['stop', 'postgres'], stage);
+
+ const fault = await pollFaultReadiness(
+ `${API_URL}/health/ready`,
+ false,
+ 90_000,
+ 2_000,
+ );
+
+ log('restoring postgres and restarting node1');
+ // Start + wait dependency healthy before node restart (up.sh wait_healthy 120s).
+ // postgres only — node2 uses postgres2 and is unaffected by this fault.
+ waitComposeHealthy('postgres', 120, stage);
+ dockerCompose(['restart', 'node'], stage);
+
+ // up.sh post-restart sequence for node/api only (node1-scoped fault).
+ await waitNodeStackPostRestart(
+ stage,
+ 'node',
+ 'http://127.0.0.1:4242/health',
+ 'api',
+ `${API_URL}/health`,
+ );
+
+ const recoveryTimeoutMs = 1_200_000;
+ const recovery = await pollFaultReadiness(
+ `${API_URL}/health/ready`,
+ true,
+ recoveryTimeoutMs,
+ 3_000,
+ );
+
+ if (!fault.matched) {
+ fail(
+ stage,
+ `postgres fault was not visible within 90000ms; last observed: ${fault.last}`,
+ );
+ }
+ if (!recovery.matched) {
+ fail(
+ stage,
+ `node1 did not become ready within ${recoveryTimeoutMs}ms after postgres recovery; last observed: ${recovery.last}`,
+ );
+ }
+
+ pass(stage, 'postgres fault detected; postgres and node1 restored and ready');
+}
+
+async function runFaultStages() {
+ await faultStageBitcoind();
+ await faultStagePostgres();
+}
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ if (args.list) {
+ for (const [k, v] of Object.entries(STAGES)) {
+ console.log(` ${String(k).padStart(3)} ${v}`);
+ }
+ process.exit(0);
+ }
+
+ const health = await httpJson('GET', `${API_URL}/health`);
+ if (health.status !== 200 || (health.text || '').trim() !== 'ok') {
+ fail(
+ 'preflight',
+ `api /health not ok (HTTP ${health.status}: ${health.text}) — run up.sh first`,
+ );
+ }
+
+ const client = new ZkCoinsV1Client({
+ apiUrl: API_URL,
+ network: 'regtest',
+ requestTimeoutMs: 120_000,
+ });
+ const host = canonicalHostFromApiUrl(API_URL);
+ const seed = seedFromMnemonicV1(MNEMONIC);
+ const alice = buildAccount(seed, 0);
+ const bob = buildAccount(seed, 1);
+ const carol = buildAccount(seed, 2);
+
+ log(`API ${API_URL}`);
+ log(`Alice ${alice.subject}`);
+ log(`Bob ${bob.subject}`);
+ log(`Carol ${carol.subject}`);
+
+ /**
+ * @type {{
+ * assetIdHex?: string,
+ * mintJob?: object,
+ * mintSpendPubkey?: Uint8Array,
+ * sendJob?: object,
+ * sendSpendPubkey?: Uint8Array,
+ * bobCoinId?: string,
+ * aliceChangeCoinId?: string,
+ * eurAssetIdHex?: string,
+ * }}
+ */
+ let ctx = {};
+
+ for (const s of args.stages) {
+ switch (s) {
+ case '1':
+ await stage1_info(client);
+ break;
+ case '2':
+ ctx = { ...ctx, ...(await stage2_alice_mint(client, seed, alice, host)) };
+ break;
+ case '2b':
+ if (!ctx.assetIdHex) {
+ fail('2b', 'stage 2b requires stage 2 in the same run (Alice USD asset id)');
+ }
+ ctx = {
+ ...ctx,
+ ...(await stage2b_carol_eur(
+ client,
+ seed,
+ alice,
+ carol,
+ host,
+ ctx.assetIdHex,
+ )),
+ };
+ break;
+ case '3':
+ case '4': {
+ // Stages 3 and 4 share one function; run only once if both are listed.
+ if (ctx.sendJob) {
+ break;
+ }
+ if (!ctx.assetIdHex || !ctx.mintJob) {
+ fail(s, 'stage 3/4 require stage 2 in the same run (asset id + mint job)');
+ }
+ const aliceMintCoinId = ctx.mintJob?.result?.output_coin_ids?.[0];
+ if (typeof aliceMintCoinId !== 'string') {
+ fail(s, 'stage 2 mintJob.result.output_coin_ids[0] missing');
+ }
+ ctx = {
+ ...ctx,
+ ...(await stage3_4_alice_send(
+ client,
+ seed,
+ alice,
+ bob,
+ host,
+ ctx.assetIdHex,
+ aliceMintCoinId,
+ ctx.eurAssetIdHex,
+ )),
+ };
+ break;
+ }
+ case '5':
+ if (!ctx.assetIdHex) {
+ fail(5, 'stage 5 requires stage 2 in the same run (asset id)');
+ }
+ ctx = {
+ ...ctx,
+ ...(await stage5_bob_receive(
+ client,
+ seed,
+ bob,
+ ctx.assetIdHex,
+ ctx.bobCoinId,
+ )),
+ };
+ break;
+ case '6':
+ if (!ctx.sendSpendPubkey) {
+ fail(6, 'stage 6 requires stage 3/4 in the same run (sendSpendPubkey)');
+ }
+ await stage6_confirmation_link(ctx.sendSpendPubkey);
+ break;
+ case '7':
+ await stage7_reorg();
+ break;
+ case '8':
+ await stage8_recovery();
+ break;
+ case '9':
+ await stage9_portability(ctx);
+ break;
+ case '10':
+ if (!ctx.assetIdHex) {
+ fail('10', 'stage 10 requires stage 2 in the same run (Alice USD asset id)');
+ }
+ await stage10_attestation(client, seed, alice, host, ctx.assetIdHex);
+ break;
+ case '11':
+ if (!ctx.assetIdHex || !ctx.eurAssetIdHex) {
+ fail('11', 'stage 11 requires stage 2 AND stage 2b in the same run (USD + EUR asset ids)');
+ }
+ await stage11_grants(client, alice, host, ctx.assetIdHex, ctx.eurAssetIdHex);
+ break;
+ default:
+ fail('cli', `unknown stage ${s}; use --list`);
+ }
+ }
+
+ if (process.env.ZKCOINS_JOURNEY_FAULTS === '1') {
+ await runFaultStages();
+ }
+
+ console.log('journey: all requested stages passed.');
+ process.exit(0);
+}
+
+main().catch((err) => {
+ console.error('journey FAIL [uncaught]:', err);
+ process.exit(1);
+});
diff --git a/deploy/local-e2e/journey.sh b/deploy/local-e2e/journey.sh
new file mode 100755
index 00000000..ec2d4f2f
--- /dev/null
+++ b/deploy/local-e2e/journey.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+# journey.sh — launch the A-to-Z hard pass/fail suite (mandate §3).
+#
+# Requires: stack already up (up.sh), env still sourced, Node.js ≥ 22,
+# sibling ../sdk available via package.json file: dependency.
+#
+# Usage:
+# ./deploy/local-e2e/journey.sh # core steps 1–6
+# ./deploy/local-e2e/journey.sh --stage 1 # single stage
+# ./deploy/local-e2e/journey.sh --stage 7 # reorg control (may be TODO)
+# ./deploy/local-e2e/journey.sh --list # list stages
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+
+die() {
+ echo "journey.sh: ERROR: $*" >&2
+ exit 1
+}
+
+log() {
+ echo "journey.sh: $*" >&2
+}
+
+command -v node >/dev/null 2>&1 || die "required command not found: node (need ≥ 22)"
+command -v npm >/dev/null 2>&1 || die "required command not found: npm"
+
+NODE_MAJOR="$(node -p "process.versions.node.split('.')[0]")"
+if (( NODE_MAJOR < 22 )); then
+ die "Node.js ≥ 22 required (got $(node -v))"
+fi
+
+export ZKCOINS_API_URL="${ZKCOINS_API_URL:-http://127.0.0.1:8080}"
+export ZKCOINS_NODE_URL="${ZKCOINS_NODE_URL:-http://127.0.0.1:4242}"
+export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_ROOT}/compose.yaml}"
+export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-zkcoins-local}"
+export ZKCOINS_V1_BITCOIND_WALLET="${ZKCOINS_V1_BITCOIND_WALLET:-zkcoins}"
+
+# Pin expected regtest digests if not already in env (same as env.example.sh).
+export ZKCOINS_CIRCUIT_DIGEST_C="${ZKCOINS_CIRCUIT_DIGEST_C:-9d256e8c828f531fc6cf9ffd4fa1ca9480473d00a99f92ea535912daa34e8352}"
+export ZKCOINS_CIRCUIT_DIGEST_C_BALANCE="${ZKCOINS_CIRCUIT_DIGEST_C_BALANCE:-bd696087e0e0f47b556a6803ef4fb5b9ebae2327e0438dd405f33752dc90772d}"
+
+[[ -d "${REPO_ROOT}/../sdk" ]] \
+ || die "sibling sdk checkout missing at ${REPO_ROOT}/../sdk (file: dependency)"
+
+cd "${SCRIPT_DIR}"
+
+if [[ ! -d node_modules/@zkcoins/sdk ]]; then
+ log "installing journey dependencies (file: ../../../sdk)…"
+ npm install --no-fund --no-audit \
+ || die "npm install failed in deploy/local-e2e"
+fi
+
+exec node "${SCRIPT_DIR}/journey.mjs" "$@"
diff --git a/deploy/local-e2e/package.json b/deploy/local-e2e/package.json
new file mode 100644
index 00000000..4e23aa4d
--- /dev/null
+++ b/deploy/local-e2e/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "zkcoins-local-e2e",
+ "private": true,
+ "type": "module",
+ "description": "A-to-Z local-e2e journey driver (mandate §3) against compose stack + @zkcoins/sdk",
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "@noble/curves": "^2.2.0",
+ "@noble/hashes": "^2.2.0",
+ "@scure/bip32": "^2.2.0",
+ "@zkcoins/sdk": "file:../../../sdk"
+ }
+}
diff --git a/deploy/local-e2e/up.sh b/deploy/local-e2e/up.sh
new file mode 100755
index 00000000..234c3848
--- /dev/null
+++ b/deploy/local-e2e/up.sh
@@ -0,0 +1,345 @@
+#!/usr/bin/env bash
+# up.sh — ordered local-e2e stack start (fail-closed at every stage).
+#
+# Prerequisites: env sourced (see env.example.sh), Docker Compose v2, cargo
+# (only if gen_bootstrap_manifest is not already built).
+#
+# Does not invent secrets. Does not silent-continue past health timeouts.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+cd "${REPO_ROOT}"
+
+# ─── helpers ──────────────────────────────────────────────────────────────
+
+die() {
+ echo "up.sh: ERROR: $*" >&2
+ exit 1
+}
+
+log() {
+ echo "up.sh: $*" >&2
+}
+
+require_cmd() {
+ command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"
+}
+
+require_env() {
+ local name="$1"
+ if [[ -z "${!name:-}" ]]; then
+ die "required env ${name} is unset or empty (see deploy/local-e2e/env.example.sh)"
+ fi
+ if [[ "${!name}" == REPLACE_ME_* ]]; then
+ die "env ${name} still holds placeholder ${!name} — set a real value"
+ fi
+}
+
+# Wait until the compose service reports Health=healthy. Named timeout —
+# never silent continue. Optional third arg: progress note re-logged every 60s
+# (used for node cold-start circuit construction so the wait is not mistaken
+# for a hang).
+wait_healthy() {
+ local service="$1"
+ local timeout_s="$2"
+ local progress_note="${3:-}"
+ local start now elapsed cid health last_progress=0
+ start="$(date +%s)"
+ log "waiting for service '${service}' healthy (timeout ${timeout_s}s)…"
+ if [[ -n "${progress_note}" ]]; then
+ log "${progress_note}"
+ fi
+ while true; do
+ now="$(date +%s)"
+ elapsed=$((now - start))
+ if (( elapsed > timeout_s )); then
+ die "timeout after ${timeout_s}s waiting for service '${service}' healthy — inspect: docker compose -f ${COMPOSE_FILE} logs ${service}"
+ fi
+ health="unknown"
+ cid="$(docker compose -f "${COMPOSE_FILE}" ps -q "${service}" 2>/dev/null | head -n 1 || true)"
+ if [[ -n "${cid}" ]]; then
+ health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "${cid}" 2>/dev/null || echo missing)"
+ if [[ "${health}" == "healthy" ]]; then
+ log "service '${service}' is healthy (${elapsed}s)"
+ return 0
+ fi
+ if [[ "${health}" == "unhealthy" ]]; then
+ die "service '${service}' is unhealthy — inspect: docker compose -f ${COMPOSE_FILE} logs ${service}"
+ fi
+ fi
+ # Periodic progress — multi-minute cold starts must not look hung.
+ if (( elapsed - last_progress >= 60 )); then
+ log "still waiting for '${service}' (${elapsed}s / ${timeout_s}s, health=${health})…"
+ if [[ -n "${progress_note}" ]]; then
+ log " note: ${progress_note}"
+ fi
+ last_progress=$elapsed
+ fi
+ sleep 2
+ done
+}
+
+wait_http_ok() {
+ local name="$1"
+ local url="$2"
+ local timeout_s="$3"
+ local expect_body="${4:-}"
+ local start now elapsed code body
+ start="$(date +%s)"
+ log "waiting for HTTP ${name} at ${url} (timeout ${timeout_s}s)…"
+ while true; do
+ now="$(date +%s)"
+ elapsed=$((now - start))
+ if (( elapsed > timeout_s )); then
+ die "timeout after ${timeout_s}s waiting for ${name} (${url}) — stack is not ready"
+ fi
+ code="000"
+ body=""
+ if body="$(curl -fsS --max-time 5 "${url}" 2>/dev/null)"; then
+ code="200"
+ else
+ code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "${url}" 2>/dev/null || echo 000)"
+ fi
+ if [[ "${code}" == "200" ]]; then
+ if [[ -n "${expect_body}" && "${body}" != "${expect_body}" ]]; then
+ sleep 2
+ continue
+ fi
+ log "${name} is up (${elapsed}s)"
+ return 0
+ fi
+ sleep 2
+ done
+}
+
+# ─── preflight ────────────────────────────────────────────────────────────
+
+require_cmd docker
+require_cmd curl
+require_cmd cargo
+require_cmd date
+
+docker compose version >/dev/null 2>&1 \
+ || die "docker compose (v2) is required"
+
+export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_ROOT}/compose.yaml}"
+export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-zkcoins-local}"
+[[ -f "${COMPOSE_FILE}" ]] || die "compose file not found: ${COMPOSE_FILE}"
+
+# Non-fatal: cold §1.7.9 circuit construction (C + C_balance) OOMs under a
+# lean Docker VM. Observed exit 137 / OOMKilled=true at ~15.6 GiB. Exact
+# threshold is build-dependent — warn, do not hard-abort.
+warn_docker_memory_if_low() {
+ local total_bytes total_gib
+ total_bytes="$(docker info --format '{{.MemTotal}}' 2>/dev/null || echo 0)"
+ if [[ -z "${total_bytes}" || "${total_bytes}" == "0" ]]; then
+ log "WARNING: could not read Docker Total Memory — ensure the Docker VM has ≥ 24 GiB (see deploy/local-e2e/README.md Prerequisites → Memory)"
+ return 0
+ fi
+ total_gib=$((total_bytes / 1024 / 1024 / 1024))
+ if (( total_gib < 20 )); then
+ log "WARNING: Docker VM reports ~${total_gib} GiB Total Memory."
+ log "WARNING: cold-start circuit construction needs well more than 16 GiB (OOM observed at 15.6 GiB)."
+ log "WARNING: assign ≥ 24 GiB to the Docker VM (OrbStack: VM memory; Docker Desktop: Resources → Memory), then restart the VM."
+ log "WARNING: details: deploy/local-e2e/README.md Prerequisites → Memory"
+ fi
+}
+warn_docker_memory_if_low
+
+# Every ${VAR:?} pin from compose.yaml (host-supplied).
+require_env PUBLISHER_KEY
+require_env USERNAME_DOMAIN
+require_env ESPLORA_URL
+require_env ESPLORA_WS_URL
+require_env ZKCOINS_CIRCUIT_DIGEST_C
+require_env ZKCOINS_CIRCUIT_DIGEST_C_BALANCE
+require_env ZKCOINS_BOOTSTRAP_PUBKEY
+require_env ZKCOINS_EXPECTED_PARAMS_IDENTIFIER
+require_env ZKCOINS_V1_BOOTSTRAP_MANIFEST_HOST_PATH
+require_env ZKCOINS_V1_BITCOIND_WALLET
+require_env PUBLISHER_KEY_2
+require_env ZKCOINS_V1_BITCOIND_WALLET_2
+require_env ZKCOINS_V1_FEE_RATE_SAT_PER_VB
+require_env ZKCOINS_V1_REVEAL_OUTPUT_SATS
+require_env ZKCOINS_RELAY_URL
+require_env ZKCOINS_BLOSSOM_URL
+require_env ZKCOINS_BLOSSOM_URL_2
+require_env ZKCOINS_MAX_BLOB_BYTES
+require_env ZKCOINS_KERNEL_PARTS
+require_env ZKCOINS_PUBLISH_BATCH_ETA_SECS
+require_env KERNEL_GRPC_ADDR
+require_env ZKCOINS_FEATURES
+require_env ZKCOINS_BLOSSOM_MAX_BLOB_BYTES
+
+# Generator-only material (not in compose ${…:?}, but required to produce BMF1).
+require_env ZKCOINS_BOOTSTRAP_PRIVKEY_FILE
+require_env ZKCOINS_BOOTSTRAP_OPERATOR_ID
+
+[[ -f "${ZKCOINS_BOOTSTRAP_PRIVKEY_FILE}" ]] \
+ || die "bootstrap privkey file missing: ${ZKCOINS_BOOTSTRAP_PRIVKEY_FILE} (create with 64 lowercase hex, mode 0600)"
+
+# Sibling api build context.
+[[ -f "${REPO_ROOT}/../api/Dockerfile" ]] \
+ || die "sibling api Dockerfile not found at ${REPO_ROOT}/../api/Dockerfile (compose build.context: ../api)"
+
+# ─── BMF1 artifact ────────────────────────────────────────────────────────
+
+MANIFEST_HOST="${ZKCOINS_V1_BOOTSTRAP_MANIFEST_HOST_PATH}"
+MANIFEST_DIR="$(dirname "${MANIFEST_HOST}")"
+mkdir -p "${MANIFEST_DIR}"
+
+if [[ -f "${MANIFEST_HOST}" ]]; then
+ log "BMF1 already present at ${MANIFEST_HOST} — reusing (delete to regenerate)"
+else
+ log "generating signed BMF1 via gen_bootstrap_manifest → ${MANIFEST_HOST}"
+ GEN_BIN="${REPO_ROOT}/target/release/gen_bootstrap_manifest"
+ if [[ ! -x "${GEN_BIN}" ]]; then
+ log "building gen_bootstrap_manifest (release)…"
+ cargo build --release -p node --bin gen_bootstrap_manifest \
+ || die "cargo build of gen_bootstrap_manifest failed"
+ fi
+ [[ -x "${GEN_BIN}" ]] || die "gen_bootstrap_manifest binary missing after build: ${GEN_BIN}"
+
+ NOW="$(date +%s)"
+ EXPIRES="$((NOW + 31536000))"
+ SEED_RELAY="${ZKCOINS_RELAY_URL}"
+ BLOB_STORE="${ZKCOINS_BLOSSOM_URL}"
+
+ # Secret only via env/file — never argv.
+ # Prefer file form (already required above).
+ export ZKCOINS_BOOTSTRAP_PRIVKEY_FILE
+ # Ensure the env form is not also set (tool refuses both).
+ unset ZKCOINS_BOOTSTRAP_PRIVKEY || true
+
+ if ! "${GEN_BIN}" \
+ --output "${MANIFEST_HOST}" \
+ --network regtest \
+ --bootstrap-pubkey "${ZKCOINS_BOOTSTRAP_PUBKEY}" \
+ --seed-relay "${SEED_RELAY}" \
+ --blob-store "${BLOB_STORE}" \
+ --operator-id "${ZKCOINS_BOOTSTRAP_OPERATOR_ID}" \
+ --issued-at "${NOW}" \
+ --expires-at "${EXPIRES}"; then
+ die "gen_bootstrap_manifest failed — refusing to start stack without a valid BMF1"
+ fi
+ [[ -f "${MANIFEST_HOST}" ]] || die "gen_bootstrap_manifest reported success but ${MANIFEST_HOST} is missing"
+ log "BMF1 written (${MANIFEST_HOST})"
+fi
+
+# ─── compose up ───────────────────────────────────────────────────────────
+
+log "docker compose up -d --build (first node image build can take a long time)…"
+docker compose -f "${COMPOSE_FILE}" up -d --build \
+ || die "docker compose up failed"
+
+# Dependency order: infra → node → api. depends_on already gates node/api,
+# but we still wait explicitly with named timeouts.
+
+wait_healthy "postgres" 120
+wait_healthy "bitcoind" 120
+wait_healthy "nostr-relay" 120
+
+# Node cold start: §1.7.9 circuit construction (C + C_balance, full Plonky2
+# recursion) runs before /health is served — often many minutes on a cold
+# machine. 20 min deadline; still fail-closed with compose-logs hint after.
+# Progress is re-logged every 60s so a waiting operator does not assume a hang.
+NODE_HEALTH_TIMEOUT_S=1200
+NODE_COLD_START_NOTE="cold-start circuit construction (C / C_balance) may take many minutes; /health is served only after circuits stand — not a hang"
+wait_healthy "node" "${NODE_HEALTH_TIMEOUT_S}" "${NODE_COLD_START_NOTE}"
+wait_http_ok "node /health" "http://127.0.0.1:4242/health" 120 "ok"
+
+wait_healthy "api" 300
+wait_http_ok "api /health" "http://127.0.0.1:8080/health" 120 "ok"
+
+# ─── regtest wallet + mature coinbase for publisher inscriptions ──────────
+
+WALLET="${ZKCOINS_V1_BITCOIND_WALLET}"
+BTC_CLI=(docker compose -f "${COMPOSE_FILE}" exec -T bitcoind
+ bitcoin-cli -regtest -datadir=/home/bitcoin/.bitcoin)
+
+log "ensuring bitcoind wallet '${WALLET}' exists…"
+# createwallet is not fully idempotent across Core versions; load if present.
+if ! "${BTC_CLI[@]}" -rpcwallet="${WALLET}" getwalletinfo >/dev/null 2>&1; then
+ if ! "${BTC_CLI[@]}" loadwallet "${WALLET}" >/dev/null 2>&1; then
+ "${BTC_CLI[@]}" createwallet "${WALLET}" \
+ || die "failed to create bitcoind wallet '${WALLET}'"
+ fi
+fi
+
+# Confirm wallet answers RPC.
+"${BTC_CLI[@]}" -rpcwallet="${WALLET}" getwalletinfo >/dev/null \
+ || die "wallet '${WALLET}' not usable after create/load"
+
+ADDR="$("${BTC_CLI[@]}" -rpcwallet="${WALLET}" getnewaddress | tr -d '\r\n')"
+[[ -n "${ADDR}" ]] || die "getnewaddress returned empty"
+
+# Mine enough for coinbase maturity (100) plus headroom for inscription fees.
+MINE_COUNT="${ZKCOINS_REGTEST_MINE_BLOCKS:-110}"
+log "mining ${MINE_COUNT} regtest blocks to ${ADDR} (coinbase maturity + fees)…"
+"${BTC_CLI[@]}" -rpcwallet="${WALLET}" generatetoaddress "${MINE_COUNT}" "${ADDR}" >/dev/null \
+ || die "generatetoaddress failed"
+
+BAL="$("${BTC_CLI[@]}" -rpcwallet="${WALLET}" getbalance | tr -d '\r\n')"
+log "publisher wallet balance: ${BAL} BTC"
+# Fail-closed if still zero after mining (wallet mismatch / immature).
+if [[ "${BAL}" == "0" || "${BAL}" == "0.00000000" ]]; then
+ die "publisher wallet balance is zero after mining — cannot fund inscriptions"
+fi
+
+# Node may have started before the wallet existed; restart so publish path sees it.
+log "restarting node so publisher binds the funded wallet…"
+docker compose -f "${COMPOSE_FILE}" restart node \
+ || die "docker compose restart node failed"
+# Same generous deadline: process-local circuit state is lost on restart.
+wait_healthy "node" "${NODE_HEALTH_TIMEOUT_S}" \
+ "post-restart circuit rebuild may take many minutes; /health waits until circuits stand"
+wait_http_ok "node /health (post-restart)" "http://127.0.0.1:4242/health" 120 "ok"
+wait_healthy "api" 300
+wait_http_ok "api /health (post-restart)" "http://127.0.0.1:8080/health" 120 "ok"
+
+# ─── node2 regtest wallet + mature coinbase (own funded wallet, shared bitcoind) ──
+
+WALLET2="${ZKCOINS_V1_BITCOIND_WALLET_2}"
+
+log "ensuring bitcoind wallet '${WALLET2}' exists (node2)…"
+if ! "${BTC_CLI[@]}" -rpcwallet="${WALLET2}" getwalletinfo >/dev/null 2>&1; then
+ if ! "${BTC_CLI[@]}" loadwallet "${WALLET2}" >/dev/null 2>&1; then
+ "${BTC_CLI[@]}" createwallet "${WALLET2}" \
+ || die "failed to create bitcoind wallet '${WALLET2}'"
+ fi
+fi
+
+"${BTC_CLI[@]}" -rpcwallet="${WALLET2}" getwalletinfo >/dev/null \
+ || die "wallet '${WALLET2}' not usable after create/load"
+
+ADDR2="$("${BTC_CLI[@]}" -rpcwallet="${WALLET2}" getnewaddress | tr -d '\r\n')"
+[[ -n "${ADDR2}" ]] || die "getnewaddress (node2) returned empty"
+
+log "mining ${MINE_COUNT} regtest blocks to ${ADDR2} (node2 coinbase maturity + fees)…"
+"${BTC_CLI[@]}" -rpcwallet="${WALLET2}" generatetoaddress "${MINE_COUNT}" "${ADDR2}" >/dev/null \
+ || die "generatetoaddress (node2) failed"
+
+BAL2="$("${BTC_CLI[@]}" -rpcwallet="${WALLET2}" getbalance | tr -d '\r\n')"
+log "node2 publisher wallet balance: ${BAL2} BTC"
+if [[ "${BAL2}" == "0" || "${BAL2}" == "0.00000000" ]]; then
+ die "node2 publisher wallet balance is zero after mining — cannot fund inscriptions"
+fi
+
+log "restarting node2 so publisher binds the funded wallet…"
+docker compose -f "${COMPOSE_FILE}" restart node2 \
+ || die "docker compose restart node2 failed"
+wait_healthy "node2" "${NODE_HEALTH_TIMEOUT_S}" \
+ "post-restart circuit rebuild may take many minutes; /health waits until circuits stand"
+wait_http_ok "node2 /health (post-restart)" "http://127.0.0.1:4243/health" 120 "ok"
+wait_healthy "api2" 300
+wait_http_ok "api2 /health (post-restart)" "http://127.0.0.1:8081/health" 120 "ok"
+
+log "stack is up."
+log " api: http://127.0.0.1:8080/health"
+log " node: http://127.0.0.1:4242/health"
+log " api2: http://127.0.0.1:8081/health"
+log " node2: http://127.0.0.1:4243/health"
+log " next: ./deploy/local-e2e/journey.sh"
+exit 0
diff --git a/docs/build-report.md b/docs/build-report.md
new file mode 100644
index 00000000..5142db91
--- /dev/null
+++ b/docs/build-report.md
@@ -0,0 +1,110 @@
+# Build report — circuit builds, end-to-end proof, and circuit test suite
+
+Measurement report for the Implementation Mandate §4 artefact. Numbers below
+are single-run wall-clock and peak-RSS observations on one host. They are not
+benchmarks, not means, and not capacity claims for other machines.
+
+## Machine and tools
+
+| | |
+|---|---|
+| Host | Apple M5 Max, 18 cores, 128 GB RAM |
+| rustc | `1.98.0-nightly (c1b22f44c 2026-06-17)` |
+| Toolchain pin | `rust-toolchain` → `nightly-2026-06-18` |
+| Backend | `plonky2 = "1.1.0"` (crates.io pin) |
+| Profile | `--release` |
+| Measured revisions | `879eb54` (circuit builds and suite), `2a97412` (end-to-end proof) |
+
+## Circuit metrics
+
+Identical across mainnet, testnet, and regtest:
+
+| Circuit | Gates | `degree_bits` |
+|---|---|---|
+| `C` (compliance) | 1 382 481 | 21 |
+| `C_balance` | 191 268 | 18 |
+
+## Run 1 — six real circuit builds
+
+`C` and `C_balance` × mainnet / testnet / regtest. Digests checked against the
+pinned file `script-plonky2/tests/generated_circuit_digests.txt`.
+
+| | |
+|---|---|
+| Wall clock | 9 544.59 s (2 h 39 min) |
+| Peak RSS | 94 856 232 960 B ≈ 88.3 GiB |
+| Result | all six `circuit_digest` values match the pinned file |
+
+## Run 2 — real end-to-end proof
+
+mint + send + receive through the prover bridge, one process,
+`--test-threads=1`.
+
+| | |
+|---|---|
+| Test time | 3 083.41 s |
+| Wall clock | 3 130.96 s (52 min) |
+| Peak RSS | 95 429 853 184 B ≈ 88.9 GiB |
+
+## Run 3 — circuit test suite
+
+`program-plonky2`, 166 tests: all compliance-clause negative cases, the
+clause-10 receive, `C_balance` with eight negative cases, and the NfLog
+gadget boundary suite over `k = 0…63`.
+
+| | |
+|---|---|
+| Result | 166 passed, 0 failed |
+| Wall clock | 10 828.87 s (3 h 01 min) |
+| Peak RSS | 99 310 649 344 B ≈ 92.5 GiB |
+
+## Memory is the hard limit
+
+Peak RSS on the suite is 92.5 GiB of 128 GB — 72 % of this machine’s RAM. Time
+is large but secondary: a host with less memory must lower test parallelism or
+the process will thrash or be killed. The suite peak (92.5 GiB) exceeds the
+circuit-build peak (88.3 GiB) and the end-to-end peak (88.9 GiB).
+
+## `cargo test`, not `cargo nextest`
+
+The suite shares the circuit through a process-wide `OnceLock`. `cargo nextest`
+starts one process per test and therefore rebuilds the 1.4-million-gate
+circuit for every test. That is not a style preference: it is the difference
+between about three hours and a run that is effectively unusable. Use
+`cargo test` with an explicit `--test-threads` for this crate.
+
+## First execution of the circuit suite
+
+Until these runs, the circuit suite had not been executed — neither locally nor
+in CI. CI gates only run `-p node -p shared`. The numbers above are therefore
+the first observed wall-clock and RSS figures for this suite on this tree, not
+a confirmation of prior practice.
+
+## What this report does not contain
+
+- Proof size in bytes
+- Verification time
+- Memory of a single proof isolated from circuit build
+- Distributions: only single measurements; no repeats, no variance, no
+ percentiles
+
+Absence of those figures does not mean they are small or free.
+
+## Reproduction
+
+```bash
+# Run 1 — circuit builds and digest check
+cargo test --release -p zkcoins-prover-plonky2 \
+ --test generated_circuit_digests_test -- --ignored --nocapture
+
+# Run 2 — end-to-end proof
+cargo test --release -p zkcoins-prover-plonky2 --lib \
+ prover_bridge_real_end_to_end -- --ignored --nocapture --test-threads=1
+
+# Run 3 — circuit test suite
+cargo test --release -p zkcoins-program-plonky2 -- --test-threads=8
+```
+
+Re-running on another host or revision will produce different wall-clock and
+RSS values; only the digest equality check is content-defined against the
+pinned file.
diff --git a/docs/kernel-rpc-mapping.md b/docs/kernel-rpc-mapping.md
new file mode 100644
index 00000000..107fbb4f
--- /dev/null
+++ b/docs/kernel-rpc-mapping.md
@@ -0,0 +1,89 @@
+# Kernel-RPC-Abbildung: §7.5 REST → §7.8 `kernel.v1`
+
+Normative Quellen:
+
+- REST: `docs/specification.md` §7.5 / §7.6 / §7.7 (tag `spec-v1.2`)
+- Kernel: `docs/specification.md` §7.8 + `proto/kernel/v1/kernel.proto`
+- Code-Stand: Worktree `node` (Branch `feat/v1-spec-rebuild`)
+
+Spalte **gRPC verdrahtet?** meint die `tonic`-Implementierung in
+`node/src/kernel_rpc.rs` über die transportneutrale Domain-Fassade
+(`node/src/kernel/`). **„Ja“ / transport-mapped** = Domain-Aufruf +
+Proto-Mapping ist verdrahtet; leere/malformed Bodies und fehlende Chain-
+Abhängigkeiten sind **nicht** `Status::unimplemented` (typisch
+`InvalidArgument` / `Internal`). Das ist **nicht** dasselbe wie
+„production happy-path complete“.
+
+**Feature-Gate (Ausnahme, kein Platzhalter):** `SignTransition` lehnt bei
+**inaktivem** V1-Claim (`!v1_sign_route_active()`) absichtlich am gRPC-Rand
+**vor** dem Domain-Aufruf ab — als `Internal` mit `ErrorInfo`
+(`internal_error` / 500, der §7.8-Fallback für eine Bedingung ausserhalb der
+Prozedur-Fehlertabelle; **nie** `Unimplemented`, das keiner der acht
+zulässigen gRPC-Codes ist) — mit Meldung zu `ZKCOINS_V1_SHADOW` /
+`ScanStackMode::V1`, **nicht** dem Text `not yet implemented` unverdrahteter
+Prozeduren. Bei aktivem V1-Claim ist der Pfad domain-mapped. Dieses Gate
+zählt als transport-mapped, nicht als unverdrahteter Stub.
+
+„Nein“ / unmapped würde die **konkret fehlende** Voraussetzung für eine
+ehrliche Verdrahtung nennen (aktuell: kein Kernel-RPC nur als bare
+`Unimplemented`-Stub).
+
+Boot: gRPC startet **nur** aus `start_rest_node` via
+`serve_kernel_grpc_with_domain` mit **geteiltem** Job-Store + Notify-Map
+(Dispatcher). Es gibt keinen pool-only-Public-Boot mit leerer Map.
+
+## Abbildungstabelle
+
+| §7.5 / §7.6 / §7.7 REST | Kernel-Prozedur (§7.8) | Kind | gRPC verdrahtet? | REST / Engine im node |
+|---|---|---|---|---|
+| `GET /` | — (API-lokal; §7.5) | — | — | ja: `router.rs` `root_handler` — Form weicht ab (legacy endpoint map) |
+| `GET /health` | — (API-lokal; §7.5) | — | — | ja: `router.rs` `health_handler` |
+| `GET /health/ready` | `GetInfo` (Teilfeld `ready` / `ready_reason`) | unary | **teilweise** — Domain-`GetInfo` + closed `reason`-Mapping existieren; mit Engine installiert `start_rest_node` eine verifizierte `ChainIdentity` (BMF1 + ops env), ohne Engine/Identity fail-closed `Internal`. REST-JSON ist eigenes Shape | teilweise: `ready_handler` |
+| `GET /v1/info` | `GetInfo` | unary | **teilweise** — Domain-Projektion vorhanden; Production-Boot mit Engine setzt Identity (s. Runtime), ohne sie fail-closed. §7.5-Route noch Legacy `/api/info` | Legacy: `info_handler` (`/api/info`) |
+| `GET /v1/chain/accumulator` | `GetAccumulator` | unary | **ja** — `kernel_rpc::get_accumulator` → live NfLog tip via `ChainView` | intern: `state_engine` tip/nflog, `shared` accumulator |
+| `GET /v1/chain/inscriptions` | `ListInscriptions` | server-stream | **ja** — `kernel_rpc::list_inscriptions` → Domain `list_inscriptions` über `ChainView` + Scanner-Katalog (`v1_inscriptions` / `v1_inscription_members`, gleiches TX wie NfLog-Fold); Member-`state` = Join Katalog × NfLog-Gewinner | Legacy 410: `get_inscription_handler` |
+| `GET /v1/chain/nullifier/` | `GetNullifierPath` | unary | **ja** — Path-B present/absent gegen live Index; Fehler nie als `present: false` | intern: `accumulator::lookup`, `nflog::inclusion_path` |
+| `POST /v1/tx` | `SubmitTransition` | unary | **ja** — Domain-Admit + gRPC-Request-Mapping (mint/send/receive) | Legacy-Admit: `jobs_mint_handler` / `jobs_send_handler`; Engine: `begin_v1_mint` / `begin_v1_send` / `execute_v1_receive` |
+| `GET /v1/jobs/` | `GetJob` | unary | **ja** — `kernel_rpc::get_job` → `DomainKernel::get_job` → `job_to_proto` | ja: `get_job_v1_handler`; Store `JobStore::load` |
+| `GET /v1/jobs//stream` | `StreamJob` | server-stream | **ja** — `kernel_rpc::stream_job` → `DomainKernel::stream_job` / `JobEventHub` → `job_event_to_proto` (live nur mit shared Notify-Map) | ja: `stream_job_v1_handler` |
+| `POST /v1/jobs//sign` | `SignTransition` | unary | **ja** — `kernel_rpc::sign_transition` → `DomainKernel::sign_transition` → `job_to_proto` (Width 64/32 am gRPC-Rand). **Feature-Gate am gRPC-Rand** (vor Domäne): bei inaktivem V1-Claim (`!v1_sign_route_active()`) `Internal` mit `ErrorInfo` (`internal_error` / 500) mit Meldung, die `ZKCOINS_V1_SHADOW` / `ScanStackMode::V1` nennt und **nicht** den Text `not yet implemented` der unverdrahteten Prozeduren — analog HTTP `feature_disabled` (kein `KernelErrorCode`) | ja: `jobs_sign_handler` → Flag-Gate `feature_disabled` / 404 → `kernel/jobs/sign`; `accept_wallet_transition_signature` |
+| `POST /v1/jobs//cancel` | `CancelJob` | unary | **ja** — `kernel_rpc::cancel_job` → `DomainKernel::cancel_job` (`CancelPolicy::NotYetPublished`) → `job_to_proto` | ja: `jobs_cancel_v1_handler` |
+| `POST /v1/pull/challenge` | `OpenPullChallenge` | unary | **ja** — Domain `open_pull_challenge` / `ChallengeStore::issue_pull` (Pull) bzw. `issue` (AttestBalance / IssueViewGrant / Entrust / Revoke). Action-Set: `""`/`pull`, `attest_balance`, `issue_grant`, `entrust`, `revoke` | **nicht vorhanden** (gRPC only heute) |
+| `POST /v1/pull` | `Pull` | unary | **ja** — Domain-Pull (Challenge-Consume + Session-Issue); Authority via Metadata `x-zkcoins-session-authority` (Proto-GAP) | **nicht vorhanden** |
+| `GET /v1/record/` | `GetRecord` | unary | **ja** — session-gated Domain; Index process-local/leer bis Katalog | **nicht vorhanden** |
+| `GET /v1/proof/` | `GetCoinProof` | unary | **ja** — session-gated Domain | Legacy 410: `get_proof_handler` |
+| `GET /v1/account/state` | `GetAccountState` | unary | **teilweise** — gRPC + Domain ownership-gated; process-local account index is **not** production-rehydrated (test-only writer today) → live success path incomplete | Engine: `state_engine::account` (rehydration follow-up) |
+| `GET /v1/receipts/stream` | `SubscribeReceipts` | server-stream | **ja** — Domain-Hub nach dual-Persist (§4.8): `v1::incoming` → `publish_credit_if_inserted` → `ReceiptHub`; Filter nach server-seitigem Session-Subjekt + resolved Scope (Ownership **oder** Grant). Rückstau: begrenzter Puffer, lag schliesst den Stream (Pull bleibt Wahrheit). REST-SSE bleibt in `zk-coins/api` | **nicht vorhanden** (gRPC only; REST gehört nach §7.5 in die API-Schicht) |
+| `POST /v1/publish/spendrecord` (§7.6) | `Publish` | unary | **ja** — `kernel_rpc::publish` → `DomainKernel::publish` / `kernel::publish::publish` mit `PublishPolicy` (AcceptFeeLess / DeclineFeeLess). Fee-Felder fail-closed am Transport-Rand; abgelehnter Publish ist erfolgreiche RPC mit `accepted: false` + closed `reason`, kein Transport-Fehler | intern: `v1::publish::publish_v1_batch` (crate-private self-publish); kein §7.6-REST-Endpoint |
+| `POST /v1/bootstrap/challenge` (§7.7) | `OpenPullChallenge` (`action` = entrust/revoke) | unary | **ja** — `entrust`/`revoke` am `OpenPullChallenge`-Rand: Domain `ChallengeStore::issue` mit `ChallengeAction::Entrust` / `Revoke` (eigene Nonce-Maps) | **nicht vorhanden** |
+| `POST /v1/bootstrap/entrust` (§7.7) | `EntrustOperationalBundle` | unary | **ja** — `kernel_rpc::entrust_operational_bundle` → `DomainKernel::entrust_operational_bundle` / `bootstrap::entrust_operational_bundle` (Challenge-Consume + Bundle-Persist, Layout 161 Bytes) | **nicht vorhanden** (gRPC only heute; BundleStore process-local) |
+| `POST /v1/bootstrap/revoke` (§7.7) | `RevokeOperationalBundle` | unary | **ja** — `kernel_rpc::revoke_operational_bundle` → `DomainKernel::revoke_operational_bundle` / `bootstrap::revoke_operational_bundle` (einmaliger Nonce-Consume, Active→Revoked) | **nicht vorhanden** |
+| `POST /v1/attest/balance/challenge` | `OpenPullChallenge` (`action` = `attest_balance`) | unary | **ja** — siehe `OpenPullChallenge` | ja: `attest_balance_challenge_handler` |
+| `POST /v1/attest/balance` | `AttestBalance` | unary | **ja** — Domain-Attest-Fassade + Proto-Mapping | ja: `attest_balance_handler`; `issue_attest_challenge` / `prove_attestation_for_job` |
+| `POST /v1/grants/challenge` | `OpenPullChallenge` (`action` = `issue_grant`) | unary | **ja** — siehe `OpenPullChallenge` | **nicht vorhanden** (gRPC only heute) |
+| `POST /v1/grants` | `IssueViewGrant` | unary | **ja** — Domain-Grant (ohne `op_sk` fail-closed vor Challenge-Consume) | **nicht vorhanden** (gRPC only heute) |
+
+## Blossom (§7.4) — kein Kernel-RPC in §7.8
+
+Die REST-Keys `blossom_get` / `blossom_head` / `blossom_upload` / `blossom_delete` (§7.5 closed endpoint map) laufen über die Blossom-Ebene, nicht über `service Kernel`. Im node: **nicht vorhanden**.
+
+## Zählung: Kernel-Prozeduren
+
+20 Prozeduren in `service Kernel`.
+
+| Kriterium | Prozeduren | Zahl |
+|---|---|---|
+| **gRPC transport-mapped** (Domain + Proto-Handler vorhanden; empty/malformed ≠ bare-`Unimplemented`-Stub; `SignTransition`-Feature-Gate bei inaktivem V1-Claim ist absichtlich und zählt hier mit) | alle 20 in `service Kernel` | **20** |
+| **Production happy-path complete** (persist + rehydrate + restart-tested content) | Teilmenge — siehe Zeilennotizen (`GetAccountState`-Index, process-local private records, …) | **nicht 20** |
+| gRPC bare-`Unimplemented` als einzige Fläche (kein Domain-Aufruf, Platzhalter) | — | **0** |
+
+**Kurzfassung:** Alle **20** Kernel-Prozeduren haben einen gRPC-Handler und Domain-Pfad (kein stummes Platzhalter-`Unimplemented`). Das ist **nicht** dasselbe wie „production-complete“: z. B. `GetAccountState` bleibt ohne rehydrierten Account-Index praktisch fail-closed, private records sind process-local bis SQL-Rehydrate, und einige Surfaces brauchen live Session/Engine. `ListInscriptions` liest den beim Falten geschriebenen Inschriften-Katalog. `SubscribeReceipts` streamt Credits vom `ReceiptHub` nach dual-Persist (`v1::incoming`). `OpenPullChallenge` deckt auch `entrust`/`revoke` ab. `SignTransition` hat ein **API-Rand-Feature-Gate** (`Internal` mit `ErrorInfo` bei inaktivem V1-Claim; mit aktivem Claim domain-mapped). Server-Boot nur über `start_rest_node` + shared Hub + pending-sign-Map + shared Receipt-Hub — kein stummer pool-only-Stream-Pfad.
+
+## API-lokale Endpunkte (explizit ohne Kernel)
+
+| REST | Grund |
+|---|---|
+| `GET /` | §7.5: Listing, API-lokal |
+| `GET /health` | §7.5: Liveness, API-lokal |
+
+`GET /health/ready` ist **nicht** rein API-lokal: §7.8 mappt Readiness über `GetInfo.ready` / `ready_reason`.
diff --git a/docs/local-stack.md b/docs/local-stack.md
new file mode 100644
index 00000000..766cb958
--- /dev/null
+++ b/docs/local-stack.md
@@ -0,0 +1,652 @@
+# Local stack (`compose.yaml`) — full unmocked pass
+
+Bring up **five** Compose services — **PostgreSQL 17**, **bitcoind regtest**, a
+**Nostr relay** (`scsibug/nostr-rs-relay:0.8.13`), the **node** (kernel), and the
+**api** (public REST over kernel gRPC) — with the environment those binaries
+actually demand. Goal of this document: a **complete** path
+
+> stack up → readiness **prüfbar** je Dienst → operatives Bundle entrusten →
+> mint → signieren (Wallet/SDK) → Blöcke erzeugen → Nullifier-Nachweis →
+> send → receive
+
+Nothing here invents chain endpoints, circuit pins, publisher secrets, or
+wallet key material.
+
+## What this stack is
+
+| Service | Role | Why it is here |
+| --- | --- | --- |
+| `postgres` | State layer | `db::connect_and_migrate` on every boot (`node/src/main.rs`, `node/src/db.rs`). Schema: `node/migrations/`. Image tag **17** matches testcontainers (`node/src/test_db.rs` `.with_tag("17")`). |
+| `bitcoind` | Regtest L1 | Stage-3 NfLog scan + AggregateStateNullifierV3 publish are **bitcoind RPC + cookie** (`node/src/v1/scan.rs` `v1_bitcoind_rpc_from_env`, `node/src/v1/publish.rs` `v1_publisher_env_from_env`). Image **`bitcoin/bitcoin:31.1`** (pinned; repo has no bitcoind version — see below). |
+| `nostr-relay` | NIP-01 WebSocket relay | Local §4.2 / §4.3 delivery peer. Image **`scsibug/nostr-rs-relay:0.8.13`** (pinned; same tag as testcontainers in `node/src/v1/nostr/relay.rs`). Listens on **8080 inside** the container; **host** publish is **18080** so host port **8080** stays free for the api. The node process does **not** yet wire the relay client into send/receive (later block); the service is here for local stack + client integration tests. |
+| `node` | Kernel binary | Built from this repo `Dockerfile`. REST **`0.0.0.0:4242`** (`ACCOUNT_NODE_ADDR`). Kernel gRPC on `KERNEL_GRPC_ADDR` (published as host **50051**). |
+| `api` | Public REST (§7.5) | Built from sibling **`../api`** (`zk-coins/api` `Dockerfile`). Binds **`0.0.0.0:8080`** in-container (`ZKCOINS_BIND_ADDR`); host **8080**. Dials the kernel at `http://node:50051` (`ZKCOINS_KERNEL_ADDR`). Optional Blossom store volume `api_blossom_data` → `/data/blossom`. |
+
+### api build context layout
+
+`compose.yaml` sets `build.context: ../api`. That assumes a sibling checkout:
+
+```text
+…/zk-coins/api ← Dockerfile + sources
+…/zk-coins/node ← this compose.yaml
+```
+
+If the api repo lives elsewhere, point `build.context` at that path (or replace
+the service with a pre-built `image:`). There is **no** fallback context and no
+registry pin in this stack.
+
+## What this stack is not (compose services)
+
+| Missing as a service | Why | How you get it |
+| --- | --- | --- |
+| **Esplora / electrs** | Still **required** by residual `NETWORK_CONFIG` (`lib.rs` `build_network_config_from_env`) and by node `/health/ready` (`router.rs` `check_esplora`). Stage-3 **scan does not use Esplora**. No electrs image/config in this repo. | Operator-supplied; set `ESPLORA_URL` / `ESPLORA_WS_URL`. |
+| **Mainnet** | `IS_MAINNET` is hard-set to `false`. Do not override to `true`. | — |
+| **Funded wallet / mined blocks** | Compose does **not** create wallets or mine blocks at start. Silent funding would hide operator setup. | Operator steps below. |
+| **Wallet / SDK process** | Signing and key derivation are **not** a compose service. The pass needs a wallet that can produce BIP-340 transition signatures and OwnershipProofs. | **`zk-coins/sdk`** v1 surface (`src/v1/`: `signTransition` / `refuseOrSignTransition`, OwnershipProof helpers). Not the node. |
+
+## How the node reaches bitcoind (boot path)
+
+Production env names (not the live-test aliases):
+
+| Env (production binary) | Live-test alias (script-plonky2 only) | Form |
+| --- | --- | --- |
+| `ZKCOINS_V1_BITCOIND_RPC_URL` | `ZKCOINS_REGTEST_URL` | Base HTTP URL, e.g. `http://127.0.0.1:18443` — **no** `/wallet/` suffix (`publisher.rs` / `scanner.rs` configs). |
+| `ZKCOINS_V1_BITCOIND_COOKIE_PATH` | `ZKCOINS_REGTEST_COOKIE` | Filesystem path to bitcoind `.cookie` (cookie-file auth only). |
+| `ZKCOINS_V1_BITCOIND_WALLET` | `ZKCOINS_REGTEST_WALLET` | Loaded wallet name; publisher appends `/wallet/` to the base URL. |
+
+Boot path (node process):
+
+1. `main.rs` requires `KERNEL_GRPC_ADDR` and chain-identity **ops** env, then migrates Postgres, then exclusive v1 stack (`ZKCOINS_V1_SHADOW=1`).
+2. REST + gRPC bind via `start_rest_node` (gRPC address from step 1).
+3. `run_v1_scan_loop` → `v1_bitcoind_rpc_from_env()` → `Scanner::connect` with RPC URL + cookie path. Failure exits the process (no Esplora fallback).
+4. Publish path (mint/send finalise) → `v1_publisher_env_from_env` (same RPC URL + cookie + wallet + fee + reveal). Missing wallet/fee/reveal aborts that path; with empty pending table, scan-only boot used to log and continue — **this compose requires them** so a mint can finish.
+
+In Compose, URL is fixed to the service DNS name:
+
+```text
+ZKCOINS_V1_BITCOIND_RPC_URL=http://bitcoind:18443
+ZKCOINS_V1_BITCOIND_COOKIE_PATH=/run/bitcoind-data/regtest/.cookie
+```
+
+Cookie volume: named volume `bitcoind_data` → bitcoind datadir `/home/bitcoin/.bitcoin`; node mounts it read-only at `/run/bitcoind-data`.
+
+### bitcoind image version
+
+No version is named in this repo’s CI, docs, or tests. Compose pins **`bitcoin/bitcoin:31.1`** (Bitcoin Core 31.1, multi-platform Debian image on Docker Hub; **not** `latest`). Client library in-tree is `bitcoincore-rpc = "0.19.0"`. Flags match README/CONTRIBUTING: `txindex=1`, `rest=1`, `server=1`, plus `rpcallowip` / `rpcbind` so other containers can use cookie HTTP Basic over the compose network.
+
+### nostr-relay image version
+
+Compose and the relay integration tests pin **`scsibug/nostr-rs-relay:0.8.13`** (not `latest`). Default image config listens on `0.0.0.0:8080` with on-disk SQLite under the `nostr_relay_data` volume. Readiness: TCP accept on port 8080 **inside** the container (`compose.yaml` healthcheck).
+
+| Who | Relay URL |
+| --- | --- |
+| Other compose services (node env pin) | `ws://nostr-relay:8080/` |
+| Host-side tools | `ws://127.0.0.1:18080/` (host port map; container still 8080) |
+
+For `ZKCOINS_RELAY_URL` (GetInfo / identity ops pin — still required at boot even though the NIP-01 client is not yet wired into send/receive) a local-stack choice is:
+
+```bash
+export ZKCOINS_RELAY_URL=ws://nostr-relay:8080/
+```
+
+## api (compose service)
+
+### Env (from `api/src/config.rs`)
+
+| Variable | Rules |
+| --- | --- |
+| `ZKCOINS_BIND_ADDR` | Required, non-empty, parseable `SocketAddr`. Compose fixes `0.0.0.0:8080` (Dockerfile `EXPOSE 8080` convention — not a binary default). |
+| `ZKCOINS_KERNEL_ADDR` | Required, non-empty tonic URI. Compose fixes `http://node:50051` (service DNS → kernel gRPC). |
+| `ZKCOINS_FEATURES` | Required **as a variable**. Closed set: `wallet`, `explorer`, `publisher`, `lightning_bridge`, `mail_bridge`. Compose uses `${…:?}` so the operator must set a non-empty value; full pass: **`wallet,explorer`**. |
+| `ZKCOINS_PUBLIC_HOST` | Required **as a variable** (may be empty). Authoritative hosts for §5.1 `chan_bind`; never taken from the HTTP `Host` header. Empty ⇒ ownership-auth surfaces fail loud; mint/sign/nullifier do not need it. |
+| `ZKCOINS_BLOSSOM_STORE` | Optional gate. **Absent** ⇒ Blossom routes unmounted. Compose **sets** `/data/blossom` (volume) so the §7.4 surface is mounted. |
+| `ZKCOINS_BLOSSOM_MAX_BLOB_BYTES` | Pflicht when store is set; integer **> 0**. |
+| `ZKCOINS_BLOSSOM_ALLOWED_OPS` | Pflicht when store is set; may be empty (every upload `403`). |
+
+The api does **not** take node identity vars (`ZKCOINS_RELAY_URL`, …). Those are kernel-side.
+
+### depends_on (what and why)
+
+| Service | In `depends_on`? | Reason (code) |
+| --- | --- | --- |
+| `node` | **yes** (`service_healthy`) | Sole upstream: `ZKCOINS_KERNEL_ADDR` → `connect_lazy` (`api/src/main.rs`, `api/src/kernel/client.rs`). |
+| `postgres` | **no** | Api has no DB env and no SQL client (`api/src/config.rs` closed set). |
+| `bitcoind` | **no** | Api never opens Bitcoin RPC; scan/publish stay in the kernel. |
+| `nostr-relay` | **no** | Api does not dial NIP-01; transport is node-side (and not yet wired into send/receive). |
+
+Healthcheck is **`GET /health`** (liveness body `ok`), **not** `/health/ready`. Ready is a `GetInfo` projection and needs a complete kernel `ChainIdentity` (verified BMF1 + ops pins) — a ready-based `depends_on` would park the stack on identity issues without proving the REST listener is up.
+
+### `ZKCOINS_PUBLIC_HOST` and wallet `chan_bind`
+
+The wallet computes `chan_bind = H("zkCoins/v1/PullHost" ‖ host)` from the URL it dials (`sdk/src/v1/ownership.ts` `canonicalHostFromApiUrl` / `chanBindForHost`; verified by `api/src/ownership.rs` `chan_bind_for_host` against `ZKCOINS_PUBLIC_HOST`).
+
+For host-side clients:
+
+```text
+api URL: http://127.0.0.1:8080
+host: 127.0.0.1:8080 ← non-default port is kept
+```
+
+So for bootstrap / pull / attest / grants from the host:
+
+```bash
+export ZKCOINS_PUBLIC_HOST=127.0.0.1:8080
+```
+
+Empty `ZKCOINS_PUBLIC_HOST` is valid for mint → sign → nullifier alone; OwnershipProof surfaces then fail loud with no silent localhost.
+
+## Prerequisites
+
+1. Docker with Compose v2.
+2. Disk and RAM for a **first** node image build (multi-stage Rust + Plonky2 circuits). Expect **many minutes to hours** on a cold machine; subsequent boots reuse the image and `/data/proofs` volume but still pay migration + scanner connect. Be honest: the first circuit construction is the long pole (see also `docs/build-report.md` for historical full-build wall times).
+3. A first **api** image build from `../api` (multi-stage Rust + pinned `protoc`; shorter than the node, still cold-cache heavy).
+4. An Esplora-compatible HTTP + WebSocket endpoint the node container can reach (residual config + node readiness only).
+5. Ability to produce BIP-340 creator signatures and (for entrust / pull) OwnershipProofs — **`zk-coins/sdk`** v1 signer + wallet flow, not the node.
+
+## Required environment (host → compose)
+
+Compose uses `${VAR:?…}` so a missing variable **fails at parse time**.
+
+### Crypto / identity (never committed)
+
+| Variable | Panic / fail site | How to set |
+| --- | --- | --- |
+| `PUBLISHER_KEY` | `node/src/lib.rs` `PUBLISHER_KEY` | `export PUBLISHER_KEY="$(openssl rand -hex 32)"` — real secp256k1 secret; no compose default |
+| `USERNAME_DOMAIN` | `node/src/lib.rs` `USERNAME_DOMAIN` | e.g. `export USERNAME_DOMAIN=local.zkcoins.test` |
+
+### Residual Esplora (still mandatory at node boot)
+
+| Variable | Fail site | Notes |
+| --- | --- | --- |
+| `ESPLORA_URL` | `build_network_config_from_env` | HTTP base; node `/health/ready` pings tip height |
+| `ESPLORA_WS_URL` | same | Still required even though Stage-3 scan does not use the legacy WS scanner |
+
+No invented third-party URLs in this document. Point at an Esplora **you** run for the same regtest chain if you have one; if you only care about the mint/nullifier path, expect node `/health/ready` to stay non-ready while jobs still run against bitcoind.
+
+### §3.6 boot pins
+
+Compose sets `ZKCOINS_V1_SHADOW=1`, `ZKCOINS_NETWORK=regtest`, `ZKCOINS_ACTIVATION_HEIGHT=0`.
+
+You supply:
+
+| Variable | Fail site |
+| --- | --- |
+| `ZKCOINS_CIRCUIT_DIGEST_C` | `v1_boot_pins_from_env` — 64 lowercase hex |
+| `ZKCOINS_CIRCUIT_DIGEST_C_BALANCE` | same |
+| `ZKCOINS_BOOTSTRAP_PUBKEY` | same — 64 lowercase hex BIP-340 x-only |
+| `ZKCOINS_EXPECTED_PARAMS_IDENTIFIER` | same — `SHA-256(canonical_encoding(NetworkParams))` |
+
+#### Circuit digests for this tree (regtest)
+
+From `script-plonky2/tests/generated_circuit_digests.txt` (drop the `0x` prefix):
+
+```text
+ZKCOINS_CIRCUIT_DIGEST_C=9d256e8c828f531fc6cf9ffd4fa1ca9480473d00a99f92ea535912daa34e8352
+ZKCOINS_CIRCUIT_DIGEST_C_BALANCE=bd696087e0e0f47b556a6803ef4fb5b9ebae2327e0438dd405f33752dc90772d
+```
+
+#### Computing `ZKCOINS_EXPECTED_PARAMS_IDENTIFIER`
+
+Canonical encoding (`shared/src/spec_v1/network_params.rs`):
+
+```text
+u8(len(tag)) || tag || digest_c || digest_c_balance || u64_be(activation_height) || u8(6) || bootstrap_pubkey
+```
+
+Regtest tag bytes: `zkCoins/v1/regtest` (`NETWORK_TAG_REGTEST`). `activation_height` must be `0`.
+
+```bash
+python3 - <<'PY'
+import hashlib, os
+tag = b"zkCoins/v1/regtest"
+c = bytes.fromhex(os.environ["ZKCOINS_CIRCUIT_DIGEST_C"])
+cb = bytes.fromhex(os.environ["ZKCOINS_CIRCUIT_DIGEST_C_BALANCE"])
+boot = bytes.fromhex(os.environ["ZKCOINS_BOOTSTRAP_PUBKEY"])
+enc = bytes([len(tag)]) + tag + c + cb + (0).to_bytes(8, "big") + bytes([6]) + boot
+print(hashlib.sha256(enc).hexdigest())
+PY
+export ZKCOINS_EXPECTED_PARAMS_IDENTIFIER="$(…output…)"
+```
+
+`ZKCOINS_BOOTSTRAP_PUBKEY` is **your** 32-byte x-only network bootstrap key for this local network — generate or load from your operator material; this doc does not invent one.
+
+### Signed §4.3 BootstrapManifest (required for complete ChainIdentity)
+
+The exclusive v1 node **refuses to install `ChainIdentity`** without a verified BMF1 artifact (`node/src/runtime.rs`: *ChainIdentity requires a verified §4.3 BootstrapManifest*). Compose mounts a host file into the container and sets `ZKCOINS_V1_BOOTSTRAP_MANIFEST_PATH=/run/bootstrap/manifest.bmf1`. There is **no** invented default manifest and **no** compose-time signature.
+
+Produce the artifact with the in-tree tool **`gen_bootstrap_manifest`** (same codec and BIP-340 domain as the node loader: `shared::spec_v1::bootstrap_manifest`).
+
+#### Order of operations
+
+1. **Obtain a bootstrap key pair for this local network** (operator material — not supplied by this repo).
+ - Secret: 32-byte secp256k1 scalar as **64 lowercase hex**.
+ - Public: BIP-340 x-only encoding of that secret as **64 lowercase hex** → this is `ZKCOINS_BOOTSTRAP_PUBKEY`.
+ - How you generate or load the pair is up to you (HSM, existing network pin, offline tool). This document does **not** invent a key. Keep the secret out of shell history and process lists: write it to a file with mode `0600`, or inject via a secret manager.
+2. **Export the public pin** and compute `ZKCOINS_EXPECTED_PARAMS_IDENTIFIER` (section above) — the params hash includes `bootstrap_pubkey`.
+3. **Sign a BMF1 artifact** with the matching secret (env/file only — never argv):
+
+```bash
+cargo build --release -p node --bin gen_bootstrap_manifest
+
+# Secret: exactly one of these two (never as a CLI flag)
+export ZKCOINS_BOOTSTRAP_PRIVKEY_FILE=./bootstrap.priv # file contains 64 lowercase hex
+# OR: export ZKCOINS_BOOTSTRAP_PRIVKEY=… # prefer the file form
+
+# Public pin must match the secret (tool fail-closes on mismatch — no write)
+export ZKCOINS_BOOTSTRAP_PUBKEY=… # 64 lowercase hex x-only — your material
+
+# Seed lists should match how this stack advertises itself (compose DNS / host).
+# Placeholders only — substitute your operator URLs and trust-list entries.
+./target/release/gen_bootstrap_manifest \
+ --output ./bootstrap.bmf1 \
+ --network regtest \
+ --bootstrap-pubkey "$ZKCOINS_BOOTSTRAP_PUBKEY" \
+ --seed-relay 'ws://nostr-relay:8080/' \
+ --blob-store 'http://127.0.0.1:8080/' \
+ --operator-id '<64-hex-op-pubkey>' \
+ --issued-at "$(date +%s)" \
+ --expires-at "$(( $(date +%s) + 31536000 ))"
+```
+
+4. **Point compose at the host path** (absolute path recommended):
+
+```bash
+export ZKCOINS_V1_BOOTSTRAP_MANIFEST_HOST_PATH="$(pwd)/bootstrap.bmf1"
+```
+
+Compose bind-mounts that file read-only to `/run/bootstrap/manifest.bmf1` and sets `ZKCOINS_V1_BOOTSTRAP_MANIFEST_PATH` inside the node container. If the artifact fails BIP-340 under `ZKCOINS_BOOTSTRAP_PUBKEY`, or the `network` field is not `regtest`, the node **aborts at boot** (no half-started listener).
+
+| Variable | Where | Meaning |
+| --- | --- | --- |
+| `ZKCOINS_V1_BOOTSTRAP_MANIFEST_HOST_PATH` | host / compose parse | Absolute host path of the signed BMF1 file (`${…:?}` — required) |
+| `ZKCOINS_V1_BOOTSTRAP_MANIFEST_PATH` | inside node container | Fixed `/run/bootstrap/manifest.bmf1` (bind mount target) |
+| `ZKCOINS_BOOTSTRAP_PRIVKEY` / `_FILE` | host only, for the generator | Never mount the secret into the node container |
+
+### GetInfo operational pins (required at node boot since `3acd71d`)
+
+| Variable | Fail site | Notes |
+| --- | --- | --- |
+| `ZKCOINS_RELAY_URL` | `chain_identity_ops_from_env` | Operator-chosen advertised Nostr relay URL |
+| `ZKCOINS_BLOSSOM_URL` | same | Operator-chosen advertised Blossom base URL |
+| `ZKCOINS_MAX_BLOB_BYTES` | same | Integer **> 0** |
+| `ZKCOINS_KERNEL_PARTS` | same | Comma-separated closed set: `scanner`, `prover`, `publisher` (at least one) |
+| `KERNEL_GRPC_ADDR` | `kernel_grpc_addr_from_env` | Bind address; for this stack use `0.0.0.0:50051` |
+
+### Publish path (required by this compose for a completable mint)
+
+| Variable | Meaning |
+| --- | --- |
+| `ZKCOINS_V1_BITCOIND_WALLET` | bitcoind wallet name funding AggregateStateNullifierV3 commits |
+| `ZKCOINS_V1_FEE_RATE_SAT_PER_VB` | sat/vB, integer > 0 |
+| `ZKCOINS_V1_REVEAL_OUTPUT_SATS` | reveal output sats, integer > 0 |
+| `ZKCOINS_PUBLISH_BATCH_ETA_SECS` | seconds until the next expected §7.6 batch (`AcceptFeeLess`); **required** when `ZKCOINS_KERNEL_PARTS` includes `publisher` — no invented default (`runtime.rs`; missing → `internal_error` on Publish) |
+
+### api (compose service)
+
+| Variable | Meaning |
+| --- | --- |
+| `ZKCOINS_FEATURES` | e.g. `wallet,explorer` |
+| `ZKCOINS_PUBLIC_HOST` | may be empty; for host wallets use `127.0.0.1:8080` |
+| `ZKCOINS_BLOSSOM_MAX_BLOB_BYTES` | integer > 0 (store is always set in compose) |
+| `ZKCOINS_BLOSSOM_ALLOWED_OPS` | may be empty (uploads all 403) |
+
+### Fixed inside compose
+
+| Variable | Value | Why |
+| --- | --- | --- |
+| `IS_MAINNET` | `false` | Local stack never mainnet |
+| `ZKCOINS_V1_SHADOW` | `1` | Stage-3 refuses legacy dual stack |
+| `ZKCOINS_NETWORK` | `regtest` | Local target |
+| `ZKCOINS_ACTIVATION_HEIGHT` | `0` | §3.6 regtest pin |
+| `DATABASE_URL` | internal to `postgres` | User `zkcoins` / password `localdev` / db `zkcoins` — local-only |
+| `ZKCOINS_V1_BITCOIND_RPC_URL` | `http://bitcoind:18443` | Compose DNS |
+| `ZKCOINS_V1_BITCOIND_COOKIE_PATH` | `/run/bitcoind-data/regtest/.cookie` | Shared volume |
+| api `ZKCOINS_BIND_ADDR` | `0.0.0.0:8080` | Local-stack bind convention |
+| api `ZKCOINS_KERNEL_ADDR` | `http://node:50051` | Compose DNS → kernel |
+| api `ZKCOINS_BLOSSOM_STORE` | `/data/blossom` | Volume mount |
+| node `ZKCOINS_V1_BOOTSTRAP_MANIFEST_PATH` | `/run/bootstrap/manifest.bmf1` | Bind mount of host BMF1 |
+
+## Start
+
+### 1. Export host env (and produce the BMF1 first)
+
+```bash
+export PUBLISHER_KEY="$(openssl rand -hex 32)"
+export USERNAME_DOMAIN=local.zkcoins.test
+
+# Residual Esplora (you operate)
+export ESPLORA_URL=… # your Esplora HTTP base
+export ESPLORA_WS_URL=… # your Esplora WS URL
+
+# §3.6 pins — bootstrap pubkey is *your* material (see BootstrapManifest section)
+export ZKCOINS_CIRCUIT_DIGEST_C=9d256e8c828f531fc6cf9ffd4fa1ca9480473d00a99f92ea535912daa34e8352
+export ZKCOINS_CIRCUIT_DIGEST_C_BALANCE=bd696087e0e0f47b556a6803ef4fb5b9ebae2327e0438dd405f33752dc90772d
+export ZKCOINS_BOOTSTRAP_PUBKEY=… # 64 lowercase hex x-only — your material
+export ZKCOINS_EXPECTED_PARAMS_IDENTIFIER=… # compute as above (includes bootstrap_pubkey)
+
+# Produce bootstrap.bmf1 *before* compose up (see "Signed §4.3 BootstrapManifest")
+# export ZKCOINS_BOOTSTRAP_PRIVKEY_FILE=./bootstrap.priv
+# ./target/release/gen_bootstrap_manifest --output ./bootstrap.bmf1 …
+export ZKCOINS_V1_BOOTSTRAP_MANIFEST_HOST_PATH="$(pwd)/bootstrap.bmf1"
+
+# Operational pins (operator-chosen URLs for *this* local node — not invented)
+# Compose service `nostr-relay` → ws://nostr-relay:8080/ (host tools: ws://127.0.0.1:18080/)
+export ZKCOINS_RELAY_URL=ws://nostr-relay:8080/
+# Advertised Blossom base for GetInfo ops — host-facing api Blossom surface is :8080
+export ZKCOINS_BLOSSOM_URL=http://127.0.0.1:8080/
+export ZKCOINS_MAX_BLOB_BYTES=1048576
+export ZKCOINS_KERNEL_PARTS=scanner,prover,publisher
+# Required when KERNEL_PARTS includes publisher — no invented default
+export ZKCOINS_PUBLISH_BATCH_ETA_SECS=60
+export KERNEL_GRPC_ADDR=0.0.0.0:50051
+
+# Publish path — wallet name must match the wallet you create in step 3
+export ZKCOINS_V1_BITCOIND_WALLET=zkcoins
+export ZKCOINS_V1_FEE_RATE_SAT_PER_VB=2
+export ZKCOINS_V1_REVEAL_OUTPUT_SATS=1000
+
+# api
+export ZKCOINS_FEATURES=wallet,explorer
+export ZKCOINS_PUBLIC_HOST=127.0.0.1:8080
+export ZKCOINS_BLOSSOM_MAX_BLOB_BYTES=1048576
+export ZKCOINS_BLOSSOM_ALLOWED_OPS= # empty = surface up, uploads 403 until you list op pubkeys
+```
+
+### 2. Bring Compose up
+
+```bash
+docker compose up --build
+```
+
+First build builds the **node** image (Rust + circuits) and the **api** image
+(from `../api`). Leave the stack running.
+
+### 3. Operator bitcoind steps (no silent funding in compose)
+
+Create a wallet, mine blocks for coinbase maturity, confirm the cookie path the node sees.
+
+```bash
+# Wallet name must equal ZKCOINS_V1_BITCOIND_WALLET
+docker compose exec bitcoind \
+ bitcoin-cli -regtest -datadir=/home/bitcoin/.bitcoin \
+ createwallet zkcoins
+
+# Mine enough blocks for mature coinbase (regtest: 100+ is the usual operator habit;
+# exact maturity rules are Bitcoin Core’s — fund until the wallet can pay fees).
+docker compose exec bitcoind \
+ bitcoin-cli -regtest -datadir=/home/bitcoin/.bitcoin \
+ -rpcwallet=zkcoins getnewaddress
+# Then generatetoaddress N (N large enough for spendable balance)
+
+docker compose exec bitcoind \
+ bitcoin-cli -regtest -datadir=/home/bitcoin/.bitcoin \
+ -rpcwallet=zkcoins getbalance
+```
+
+If the node was already running before the wallet existed, restart the **node** service after the wallet is ready so a first publish does not fail against a missing wallet:
+
+```bash
+docker compose restart node
+```
+
+(`api` depends on node healthy — it will restart/wait with the dependency chain when you recreate, not on a bare `restart node` of an already-running api.)
+
+## Probes — when is each piece actually up?
+
+Do not “wait a bit”. Use these checks:
+
+| Piece | Probe | Expected |
+| --- | --- | --- |
+| Postgres | `docker compose exec postgres pg_isready -U zkcoins -d zkcoins` | exit 0 / “accepting connections” |
+| bitcoind | `docker compose exec bitcoind bitcoin-cli -regtest -datadir=/home/bitcoin/.bitcoin getblockchaininfo` | JSON with `"chain": "regtest"` |
+| bitcoind cookie | `docker compose exec bitcoind test -f /home/bitcoin/.bitcoin/regtest/.cookie` | exit 0 |
+| nostr-relay (in-container) | `docker compose exec nostr-relay bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'` | exit 0 (TCP accept on 8080) |
+| nostr-relay (host) | TCP connect to `127.0.0.1:18080` (e.g. `nc -z 127.0.0.1 18080`) | open once relay accepts |
+| node liveness | `curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4242/health` | `200`, body `ok` (`router.rs` `health_handler`) |
+| node gRPC port | TCP connect to `127.0.0.1:50051` (e.g. `nc -z 127.0.0.1 50051`) | open once REST/gRPC task bound |
+| node readiness | `curl -sS http://127.0.0.1:4242/health/ready` | `200` only when Postgres, **Esplora tip**, prover warm, v1 scan caught up, no deep reorg — else `503`. Liveness can be green while ready is red. |
+| api liveness | `curl -sS http://127.0.0.1:8080/health` | body `ok` (`api` `GET /health`) |
+| api readiness | `curl -sS http://127.0.0.1:8080/health/ready` | Kernel `GetInfo` (needs verified BMF1 + ChainIdentity). With a valid mounted manifest and ops pins, expect **200** once the kernel answers; a bad/missing BMF1 aborts the **node** before it stays half-up. |
+| api discovery | `curl -sS http://127.0.0.1:8080/` | JSON with `endpoints` for registered surfaces only (includes `blossom_*` while the store is configured) |
+
+## Full pass (entrust → mint → sign → completed → nullifier → send → receive)
+
+All api calls below assume `http://127.0.0.1:8080` and features `wallet,explorer`.
+
+### 0. Entrust the operational bundle — `POST /v1/bootstrap/*`
+
+Who: the **account holder’s wallet** (holds the seed / operational secrets). How: §7.7 via the api edge (`api/src/bootstrap.rs`) → kernel `EntrustOperationalBundle` (`node/node/src/kernel/bootstrap/bundle.rs`).
+
+Wire:
+
+1. `POST /v1/bootstrap/challenge` with `{ "subject": "", "action": "entrust" }` → `{ nonce, expiry, domain }` (`domain` = `zkCoins/v1/EntrustChallenge`).
+2. Wallet builds an OwnershipProof under that domain with `chan_bind` for the host in `ZKCOINS_PUBLIC_HOST` (SDK: `buildOwnershipProof` / pull-challenge helpers in `sdk/src/v1/ownership.ts` — same composition as the api edge).
+3. `POST /v1/bootstrap/entrust` with `{ challenge: { nonce, expiry }, ownership_proof, bundle }` where `bundle` is **322 lowercase hex characters** = 161 bytes `serialize(OperationalBundle)` = `version(0x01) ‖ ivk ‖ ovk ‖ op ‖ nk ‖ op_secret` (each 32 B). **Never log the hex.**
+
+```bash
+# Challenge
+curl -sS -X POST http://127.0.0.1:8080/v1/bootstrap/challenge \
+ -H 'content-type: application/json' \
+ -d '{"subject":"","action":"entrust"}'
+
+# Entrust (bundle hex is wallet material — not invented here)
+curl -sS -X POST http://127.0.0.1:8080/v1/bootstrap/entrust \
+ -H 'content-type: application/json' \
+ -d '{
+ "challenge": {"nonce":"<64-hex>","expiry":""},
+ "ownership_proof": {
+ "type": "ownership",
+ "subject": "",
+ "public_key": "<64-hex Pk0>",
+ "nk_commit": "<64-hex>",
+ "signature": "<128-hex>"
+ },
+ "bundle": "<322-hex>"
+ }'
+```
+
+**Expected:** HTTP **200** `{ "accepted": true }`.
+
+> The SDK v1 client (`ZkCoinsV1Client`) exposes transition/pull helpers; it does **not** currently ship a dedicated `entrust` method — challenge + OwnershipProof + bundle assembly is wallet work over the same wire. Bundle store is process-local in the kernel today (lost on node restart — see gaps).
+
+Without an active bundle, receive/scan-side decryption and recovery paths that need `ivk` / operational keys fail closed. Mint admit can still be attempted; full hosted receive needs entrust.
+
+### A. Mint — `POST /v1/tx`
+
+Shape enforced in `api/src/jobs.rs` (`TransitionRequestJson`, kind `mint`):
+
+- Required: `kind`, `subject`, `next_pubkey` (32-byte hex), `npk_rand` (32-byte hex), non-empty `output_templates`, `issuance`
+- Forbidden for mint: `input_coins`, `fold_coin_ids`, `fee_address`
+- Optional: `publisher_pubkey`, `Idempotency-Key` header (≤ 64 bytes)
+
+```bash
+curl -sS -X POST http://127.0.0.1:8080/v1/tx \
+ -H 'content-type: application/json' \
+ -H 'idempotency-key: ' \
+ -d '{
+ "kind": "mint",
+ "subject": "",
+ "next_pubkey": "<64-hex>",
+ "npk_rand": "<64-hex>",
+ "output_templates": [{
+ "recipient": "",
+ "asset_id": "<64-hex>",
+ "amount": ""
+ }],
+ "issuance": {
+ "name": "",
+ "decimals": 8,
+ "issuance_version": 1,
+ "amount": ""
+ }
+ }'
+```
+
+**Expected:** HTTP **202** body `{ "job_id": "…", "status": "accepted" }`.
+
+> Placeholders only — no example keys that look like live secrets. Field widths are from the api validator (`decode_hex_exact` 32 bytes for pubkey digests). How you derive `subject` / keys is wallet/SDK work.
+
+### B. Wait for signature challenge — `GET /v1/jobs/`
+
+```bash
+curl -sS http://127.0.0.1:8080/v1/jobs/
+```
+
+Poll until `status` is `awaiting_signature`. The object then includes `awaiting_signature` with
+(`api/src/jobs.rs` `awaiting_signature_json`):
+
+- `new_account_state_hash`, `output_coins_root`, `input_nullifiers_root`,
+ `coin_history_root`, `nav_commitment`, `npk_commit`, `proof_data_hash`,
+ `txn_pubkey` (each 32-byte hex), and `send_counter`
+
+Alternatively: `GET /v1/jobs//stream` (SSE: `phase` / `complete` / `error`).
+
+### C. Sign — Wallet / SDK, then `POST /v1/jobs//sign`
+
+The **node does not sign**. The signature is produced by the wallet using the
+**v1 signer** in **`zk-coins/sdk`**:
+
+- `refuseOrSignTransition` / `signTransition` / `signTransitionOverProofData`
+ (`sdk/src/v1/signGate.ts`, `sdk/src/v1/transitionSignature.ts`)
+- Wire body via `signBodyFromSignature` → `{ signature, s2c_nonce }`
+
+Body (`SignBodyJson`): `{ "signature": "<128-hex = 64 bytes>", "s2c_nonce": "<64-hex = 32 bytes>" }` — BIP-340 creator signature + x-only even-y `R'` (`node/src/v1/signature.rs` wire rules: lowercase hex, no `0x`).
+
+```bash
+curl -sS -X POST http://127.0.0.1:8080/v1/jobs//sign \
+ -H 'content-type: application/json' \
+ -d '{"signature":"<128-hex>","s2c_nonce":"<64-hex>"}'
+```
+
+**Expected:** HTTP **200** with updated job JSON. Production path then finalises (prove/apply, durable `members_ready`, construct/broadcast handoff).
+
+### D. Job reaches `completed`
+
+```bash
+curl -sS http://127.0.0.1:8080/v1/jobs/
+```
+
+**Expected:** `status: "completed"` and a `result` object (digest fields + `output_coin_ids`, …).
+
+**What `completed` means** (`node/src/job_dispatcher.rs` `JOB_FINALISE_HOST_EDGE`):
+
+> Host edge after durable engine + `members_ready` **and** nullifier broadcast handoff (construct/broadcast).
+> **Not** on-chain AggregateStateNullifierV3 confirmation.
+> **Not** NfLog scan-fold.
+> Those need bitcoind inclusion + the scanner.
+
+If the job stays short of `completed` with pending publish still `members_ready`, check bitcoind wallet balance, fee/reveal env, and node logs for publish errors.
+
+### E. Mine blocks (include commit/reveal)
+
+```bash
+# Address from the publisher wallet; mine at least enough to include mempool txs
+ADDR=$(docker compose exec -T bitcoind \
+ bitcoin-cli -regtest -datadir=/home/bitcoin/.bitcoin \
+ -rpcwallet=zkcoins getnewaddress | tr -d '\r')
+docker compose exec bitcoind \
+ bitcoin-cli -regtest -datadir=/home/bitcoin/.bitcoin \
+ -rpcwallet=zkcoins generatetoaddress 1 "$ADDR"
+```
+
+Repeat as needed until commit/reveal leave the mempool. For **finality** (protocol pin **6** confirmations — `FINALITY_CONFIRMATIONS` in `node/src/kernel/chain.rs`), mine additional blocks so the inclusion height sits ≥ 6 deep under tip. One block is inclusion, not finality.
+
+### F. Prove the nullifier on the canonical chain view
+
+```bash
+curl -sS "http://127.0.0.1:8080/v1/chain/nullifier/"
+```
+
+- Path segment: **32-byte hex** account pubkey for the nullifier index (`api/src/chain.rs` `get_nullifier` → kernel `GetNullifierPath`).
+- Which pubkey? The NfLog first-occurrence key for the transition (account state nullifier `pk`). For a mint this is the account public key whose state was nullified — typically the signing account’s x-only pubkey for that transition, **not** the bech32 `subject` string as-is. Mapping from wallet material → this 32-byte key is wallet/SDK territory.
+
+**Expected after scanner fold of an included nullifier:**
+
+- `present: true`
+- `position`, `leaf`, `audit_path`, plus `root` / `tip_block_hash` / `tip_height` / `tree_size`
+
+**Before** inclusion/fold: `present: false` with empty `audit_path` (unauthenticated local-index absence — not a proof of non-existence on another node).
+
+Kernel `internal_error` is **not** rewritten as absent.
+
+Optional cross-check: `GET /v1/chain/accumulator` → `{ size, root, tip_block_hash, tip_height }` (pass-through of kernel `nav_root`).
+
+### G. Send — `POST /v1/tx` kind `send`
+
+Same job lifecycle as mint (`api/src/jobs.rs`: send requires non-empty `input_coins` + `output_templates`, forbids `fold_coin_ids` / `issuance`). Sign with the SDK v1 gate again.
+
+**Recipient addressing:** a real send needs the recipient’s **`IVPK`** (and relays) so the delivery event can be encrypted (§4.2 / §4.3). That material is **not** on the §7.5 REST inventory — there is **no** `Invoice` path key in `CLOSED_ENDPOINT_KEYS` (`api/src/routes.rs`). Spec addressing is off-chain `Invoice` / kind-30420 profile / handle resolution (`docs` specification §4.3). For a local two-wallet pass you must obtain `IVPK` from the recipient wallet out-of-band (or construct a verified Invoice outside this stack). Without it, on-chain nullifier publish may still complete while **private delivery cannot**.
+
+### H. Receive — `POST /v1/tx` kind `receive`
+
+Receive requires non-empty `fold_coin_ids` and forbids `input_coins` / `output_templates` / `issuance` (`api/src/jobs.rs`). Folding needs coins the node can already see as incoming (delivery + decrypt under entrusteed `ivk`). That path depends on Nostr delivery wiring and an active operational bundle — both called out under gaps when incomplete.
+
+## What the pass proves — and what it does not
+
+| Claim | Proved by this pass? |
+| --- | --- |
+| Five compose services start; each readiness probe above is checkable | Yes when probes match the Expected column |
+| api accepts mint, returns a job, and projects kernel status | Yes, if steps A–D succeed |
+| Wallet signature verified; host applied state; broadcast handoff recorded | Yes when status is `completed` (`JOB_FINALISE_HOST_EDGE`) — signature from **SDK/wallet**, not the node |
+| Operational bundle accepted by the kernel for a subject | Yes when step 0 returns `accepted: true` |
+| Commit/reveal in a mined block on **this** regtest bitcoind | Yes only after step E and mempool/chain checks you perform |
+| Nullifier folded into the node’s NfLog and served with inclusion path | Yes when step F returns `present: true` |
+| Six-confirmation finality | Only if you mined depth ≥ 6 under tip; one block is not finality |
+| `completed` alone = chain inclusion | **No** — that is why step F exists |
+| Full `GetInfo` / signed network bootstrap | **Yes** when a BMF1 signed under `ZKCOINS_BOOTSTRAP_PUBKEY` is mounted and boot completes |
+| Production readiness (node `/health/ready` green without Esplora) | **No** — Esplora still on the residual path |
+| api `/health/ready` green | **Yes** when kernel `GetInfo` succeeds (BMF1 + ops + digests); still independent of Esplora residual on the node ready probe |
+| Mainnet safety | **No** — regtest only; never set `IS_MAINNET=true` |
+| End-to-end private **send delivery** (Nostr gift-wrap → recipient decrypt) | **No** until recipient `IVPK` is available out-of-band **and** node delivery/relay wiring is live |
+| That the **Wallet** is replaceable by curl alone for signatures | **No** — BIP-340 transition signatures and OwnershipProofs are wallet/SDK work (`zk-coins/sdk` v1) |
+
+## Cleanup
+
+```bash
+# Stop containers; keep volumes
+docker compose down
+
+# Stop and remove volumes (Postgres state, node /data/proofs, bitcoind regtest,
+# nostr relay db, api Blossom store)
+docker compose down -v
+```
+
+Re-creating volumes wipes the regtest chain, cookie, wallet, node state, and
+Blossom blobs. After `-v`, re-run wallet create, funding, and pin exports from
+scratch. Kernel process-local bundle store is always empty after a node restart
+(even without `-v`).
+
+## Fail-loud behaviour (by design)
+
+- Missing compose-required env → **parse-time** error (`${VAR:?…}`), including `ZKCOINS_V1_BOOTSTRAP_MANIFEST_HOST_PATH`.
+- Missing `PUBLISHER_KEY` / Esplora / §3.6 pins / identity ops / bitcoind RPC env → process panic or `Err`.
+- Missing or invalid BMF1 at `ZKCOINS_V1_BOOTSTRAP_MANIFEST_PATH` → node boot aborts (`load_manifest_store` / ChainIdentity install).
+- `gen_bootstrap_manifest` secret not deriving to `--bootstrap-pubkey` → exit non-zero, **no** output file written.
+- Unreachable bitcoind after boot → scanner connect fails → process exits (`run_v1_scan_loop`). No `restart: always`.
+- Wrong circuit digests vs the binary → self-heal / boot refusal.
+- api missing any of its four Pflicht env vars → exit code 1 with named error (`Config::from_env`).
+- api Blossom store set without companions → start error (`parse_blossom_config`).
+
+## Gaps / open items
+
+1. **Esplora not bundled** — residual boot + node readiness still need operator Esplora; scan/publish use bitcoind.
+2. **Bootstrap key material** — compose never invents `ZKCOINS_BOOTSTRAP_PUBKEY` or the matching secret; the operator supplies both and runs `gen_bootstrap_manifest` before `docker compose up`.
+3. **Wallet signing** — compose does not ship a mint/send signer; use **`zk-coins/sdk`** v1 (`refuseOrSignTransition` / `signTransition`).
+4. **Entrust material** — 161-byte operational bundle and OwnershipProof come from the wallet; no compose default. Kernel `BundleStore` is process-local (lost on node restart; durable table is a separate migration — `bundle.rs` comment).
+5. **Recipient `IVPK` / Invoice** — §7.5 REST has **no** `Invoice` carrier (`CLOSED_ENDPOINT_KEYS`). Send delivery needs `IVPK` (+ relays) from Invoice / kind-30420 / handle resolution (§4.3) **outside** this compose REST surface.
+6. **Nostr delivery wiring** — relay service is up; node client into send/receive is not yet the production path (see service table). Receive fold may stall without delivery + decrypt.
+7. **Exact pubkey for `/v1/chain/nullifier/`** after a mint depends on wallet key layout; not derivable from compose alone.
+8. **Blossom upload allow-list** — empty `ZKCOINS_BLOSSOM_ALLOWED_OPS` leaves the surface up but every upload `403` until real `op` pubkeys are listed.
+9. **Blossom volume ownership** — api image runs as uid `10001` (`Dockerfile`); a root-owned named volume can make `BlobStore::open` fail at create — operator must ensure the mount is writable by that user.
+10. **Legacy residual network label** — `IS_MAINNET=false` still maps residual `EsploraConfig::network()` to **Signet** for Taproot address derivation (`publisher.rs`), while v1 pins use `regtest` (existing node behaviour).
+11. **First boot time** — node circuit build dominates; not fixed to a single number in compose.
+12. **Host port split** — api owns host **8080**; nostr-relay host map is **18080** (container still 8080; compose DNS unchanged).
+
+## Policy reminders
+
+- Never set `IS_MAINNET=true` in this file or a local override for this stack.
+- Never commit a real `PUBLISHER_KEY`, bootstrap secret, or operational-bundle hex.
+- Never pass the bootstrap secret on argv; use `ZKCOINS_BOOTSTRAP_PRIVKEY` or `ZKCOINS_BOOTSTRAP_PRIVKEY_FILE` for `gen_bootstrap_manifest` only.
+- Do not add `restart: always` to paper over boot failures.
+- Do not replace `/health` with a `true` healthcheck; do not use `/health/ready` as a compose gate.
+- Do not invent URLs, digests, or example keys that look live.
diff --git a/downstream-boundary/Cargo.toml b/downstream-boundary/Cargo.toml
new file mode 100644
index 00000000..5edd3ded
--- /dev/null
+++ b/downstream-boundary/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "downstream-boundary"
+version.workspace = true
+edition.workspace = true
+publish = false
+
+# This package exists solely as a true downstream edge for the sealed
+# plumbing compile-fail matrix. trybuild flattens the *host* package's
+# direct dependencies into the generated UI crate; hosting the matrix
+# under `node` would make `zkcoins-prover` nameable for reasons unrelated
+# to the boundary under test. A downstream fixture must depend on `node`
+# only.
+[dependencies]
+node = { path = "../node" }
+
+[dev-dependencies]
+trybuild = "1.0"
diff --git a/downstream-boundary/src/lib.rs b/downstream-boundary/src/lib.rs
new file mode 100644
index 00000000..8d54bf57
--- /dev/null
+++ b/downstream-boundary/src/lib.rs
@@ -0,0 +1,5 @@
+//! Downstream-only edge used by the sealed plumbing compile-fail matrix.
+//!
+//! Production code does not depend on this crate. It exists so trybuild
+//! generates a fixture whose sole library dependency is `node` — the same
+//! edge a real consumer of the `node` package would have.
diff --git a/downstream-boundary/tests/sealed_plumbing_compile_fail_matrix.rs b/downstream-boundary/tests/sealed_plumbing_compile_fail_matrix.rs
new file mode 100644
index 00000000..1e8d5850
--- /dev/null
+++ b/downstream-boundary/tests/sealed_plumbing_compile_fail_matrix.rs
@@ -0,0 +1,24 @@
+//! Single downstream compile-fail matrix for the sealed v1.1 plumbing surface.
+//!
+//! Hosted here (not under `node`) so the generated trybuild crate depends on
+//! `node` **only**. trybuild flattens the host package's direct deps into the
+//! UI fixture; running under `-p node` would make `zkcoins-prover` nameable
+//! for reasons unrelated to the boundary.
+//!
+//! Raw publish / DB-write / adapter-mutation / scan-apply sinks are
+//! `pub(crate)` on `node`. This integration target is a **separate crate**
+//! that depends on `node` as a normal library dependency — the same edge a
+//! downstream application would use. Feature flags cannot reopen the sinks
+//! (no Cargo feature exists for them).
+//!
+//! One matrix beats scattered trybuild files: every sealed sink is named in
+//! one place, and widening any of them fails loudly here.
+//!
+//! Run: `cargo test -p downstream-boundary --test sealed_plumbing_compile_fail_matrix`
+
+#[test]
+fn sealed_plumbing_sinks_unobtainable_from_outside_node() {
+ let t = trybuild::TestCases::new();
+ // One UI crate enumerates every sealed sink; stderr pins the errors.
+ t.compile_fail("tests/ui/sealed_plumbing_sinks_unobtainable.rs");
+}
diff --git a/downstream-boundary/tests/ui/sealed_plumbing_sinks_unobtainable.rs b/downstream-boundary/tests/ui/sealed_plumbing_sinks_unobtainable.rs
new file mode 100644
index 00000000..addbbd34
--- /dev/null
+++ b/downstream-boundary/tests/ui/sealed_plumbing_sinks_unobtainable.rs
@@ -0,0 +1,201 @@
+// Compile-fail matrix: every raw durable / publish / mutation / scan-apply
+// sink on `node::v1` is sealed (`pub(crate)`). This file is an external
+// crate depending on `node` as a library — same reachability as any
+// downstream, release or debug, feature-gated or not.
+//
+// Beyond naming private wrappers, this matrix also proves **capability
+// reachability** on whatever `connect_v1_publisher` actually returns
+// (type derived from the connect expression — never a hardcoded facade
+// name), trait methods via UFCS, free-standing construction of the
+// argument types those methods take, coercion / Deref / AsRef reopenings,
+// and a pin of the public API surface so future widening fails loudly.
+//
+// Driven by trybuild (`tests/sealed_plumbing_compile_fail_matrix.rs`)
+// under the `downstream-boundary` package (node-only direct dependency).
+
+/// Obtain a value whose type is whatever `connect_v1_publisher` returns.
+///
+/// Macro (not an `impl Trait` helper): an opaque return type would erase
+/// inherent methods and the matrix would stay green even if connect
+/// regressed to the raw foreign `Publisher`. Expansion keeps the concrete
+/// type so the probe follows whatever connect actually returns.
+/// Hardcoding `&V1Publisher` would pin a name, not the boundary.
+macro_rules! publisher_from_connect {
+ () => {
+ match node::v1::connect_v1_publisher(loop {}) {
+ Ok(p) => p,
+ Err(_) => loop {},
+ }
+ };
+}
+
+fn main() {
+ // --- publish sinks ---
+ // Former free-standing publish helper (already removed) + raw batch sink.
+ let _ = node::v1::publish_applied_nullifier;
+ let _ = node::v1::publish_v1_batch;
+ let _ = node::v1::publish::publish_v1_batch;
+
+ // --- database-write sinks ---
+ let _ = node::v1::db_v1::persist_engine_snapshot;
+ let _ = node::v1::db_v1::persist_engine_with_pending_members_ready;
+ let _ = node::v1::db_v1::insert_pending_publish_members_ready;
+ let _ = node::v1::db_v1::mark_pending_publish_constructed;
+ let _ = node::v1::db_v1::mark_pending_publish_status;
+
+ // --- adapter-mutation sinks ---
+ let _ = node::v1::EngineAdapter::with_engine_mut;
+ let _ = node::v1::EngineAdapter::restore_live;
+ let _ = node::v1::EngineAdapter::set_tip_hash;
+ let _ = node::v1::EngineAdapter::persist;
+ let _ = node::v1::EngineAdapter::reload_from_db;
+ let _ = node::v1::EngineAdapter::lock_writes;
+ let _ = node::v1::EngineAdapter::snapshot_live;
+
+ // --- scan-apply sinks (raw fold/replace; orchestration stays public) ---
+ let _ = node::v1::fold_survivors_into_engine;
+ let _ = node::v1::replace_engine_nflog_from_survivors;
+ let _ = node::v1::scan::fold_survivors_into_engine;
+ let _ = node::v1::scan::replace_engine_nflog_from_survivors;
+
+ // Reachability probes below are typechecked as free-standing function
+ // bodies (never invoked from main — avoids arity noise drowning the
+ // real capability errors).
+}
+
+/// Inherent prepare / broadcast_commit / broadcast_reveal / publish on the
+/// type returned by `connect_v1_publisher` must not resolve.
+///
+/// Type is derived from the connect expression via `publisher_from_connect!`
+/// — not a hardcoded `&V1Publisher`. If connect regresses to the raw
+/// foreign `Publisher`, these four calls compile and the matrix fails.
+fn probe_inherent_methods_on_connect_return() {
+ let publisher = publisher_from_connect!();
+ let _ = publisher.prepare(&[]);
+ let _ = publisher.broadcast_commit(loop {});
+ let _ = publisher.broadcast_reveal(loop {});
+ let _ = publisher.publish(&[]);
+}
+
+/// UFCS on the publisher trait must fail — trait is crate-private.
+fn probe_trait_methods_via_ufcs() {
+ let publisher = publisher_from_connect!();
+ let _ = node::v1::NullifierBatchPublisher::publish_batch(&publisher, &[]);
+ let _ = node::v1::NullifierBatchPublisher::try_prepare(&publisher, &[]);
+ let _ = node::v1::NullifierBatchPublisher::broadcast_commit(&publisher, loop {});
+ let _ = node::v1::NullifierBatchPublisher::broadcast_reveal(&publisher, loop {});
+ let _ = node::v1::receive::NullifierBatchPublisher::publish_batch(&publisher, &[]);
+}
+
+/// Free-standing `BatchMember` / `PreparedBatch` (and the foreign crate path)
+/// must not be constructible from a crate that depends only on `node`.
+fn probe_freestanding_batch_member_and_equivalents() {
+ // Not re-exported on the v1 surface.
+ let _ = node::v1::BatchMember {
+ sig: loop {},
+ build_tip: loop {},
+ };
+ let _ = node::v1::PreparedBatch {
+ aggregate: loop {},
+ payload: loop {},
+ signed_commit: loop {},
+ reveal_tx: loop {},
+ commit_output: loop {},
+ block_anchor: loop {},
+ commit_vsize: loop {},
+ reveal_vsize: loop {},
+ commit_fee: loop {},
+ reveal_fee: loop {},
+ };
+ // Not available through the publish submodule either.
+ let _ = node::v1::publish::BatchMember {
+ sig: loop {},
+ build_tip: loop {},
+ };
+ // Foreign defining crate is not a direct dependency of this node-only
+ // consumer. Use the Cargo package name (`zkcoins-prover` →
+ // `zkcoins_prover`), not the path-directory name — a wrong name would
+ // fail for the wrong reason and mask a real dep leak.
+ let _ = ::zkcoins_prover::publisher::BatchMember {
+ sig: loop {},
+ build_tip: loop {},
+ };
+ let _ = ::zkcoins_prover::publisher::Publisher::connect(loop {});
+}
+
+/// Coercion / auto-deref / explicit Deref must not re-open foreign inherent
+/// methods on the connect return type.
+fn probe_coercion_and_deref() {
+ let publisher = publisher_from_connect!();
+
+ // Auto-deref through `&_`: foreign inherent methods must still fail.
+ let _ = (&publisher).prepare(&[]);
+ let _ = (&publisher).publish(&[]);
+
+ // Explicit `Deref` bound — must not hold for the connect return type.
+ // Adding `impl Deref` (or any Deref) makes this
+ // bound succeed and the matrix fails the compile_fail expectation.
+ fn needs_deref(_t: &T) {}
+ needs_deref(&publisher);
+
+ // Explicit deref operator + method: same reopening if Target has prepare.
+ let _ = (*&publisher).prepare(&[]);
+ let _ = std::ops::Deref::deref(&publisher).prepare(&[]);
+}
+
+/// `AsRef` must not re-open foreign inherent methods.
+fn probe_asref_does_not_open_foreign_methods() {
+ let publisher = publisher_from_connect!();
+ // Fully-qualified `AsRef::as_ref` — fails when no AsRef impl exists.
+ // If `AsRef` (or any AsRef target with prepare) is added,
+ // `as_ref` succeeds and the subsequent inherent calls compile → matrix
+ // fails the compile_fail expectation.
+ let exposed = std::convert::AsRef::as_ref(&publisher);
+ let _ = exposed.prepare(&[]);
+ let _ = exposed.broadcast_commit(loop {});
+ let _ = exposed.broadcast_reveal(loop {});
+ let _ = exposed.publish(&[]);
+}
+
+/// Public API surface pin: foreign types, re-export aliases, and extraction
+/// helpers must not appear on the node public surface. Future widening of
+/// these names fails here rather than silently shipping.
+fn probe_public_api_surface_not_widened() {
+ let publisher = publisher_from_connect!();
+
+ // Facade field must stay private (no `publisher.inner` extraction).
+ let _ = publisher.inner;
+
+ // No inherent extraction / conversion helpers on the connect return type.
+ let _ = publisher.into_inner();
+ let _ = publisher.as_inner();
+ let _ = publisher.inner();
+ let _ = publisher.into_publisher();
+ let _ = publisher.as_publisher();
+
+ // Foreign publisher type and friends must not be re-exported on v1 /
+ // publish (including under alias names other than the opaque facade).
+ let _ = node::v1::Publisher;
+ let _ = node::v1::publish::Publisher;
+ let _ = node::v1::PublisherConfig;
+ let _ = node::v1::publish::PublisherConfig;
+ let _ = node::v1::PublishedBatch;
+ let _ = node::v1::publish::PublishedBatch;
+ let _ = node::v1::PreparedBatch;
+ let _ = node::v1::publish::PreparedBatch;
+ // BatchMember already probed via struct literal above; also pin the
+ // bare path form so a future `pub use` / type alias is caught.
+ let _ = node::v1::BatchMember;
+ let _ = node::v1::publish::BatchMember;
+
+ // No re-export of the foreign crate through the node package root.
+ let _ = node::zkcoins_prover;
+ let _ = node::v1::zkcoins_prover;
+ let _ = node::v1::publish::zkcoins_prover;
+
+ // Stage 3 Runde 4: legacy scan private field (cap unconstructible).
+ // Prover type deleted — also unobtainable. Kept as extra sinks in this
+ // multi-sink file (existing sealed_plumbing matrix). New Stage-3
+ // one-file-one-error cases live under node/tests/ui.
+ let _ = node::legacy_commitment_scan::LegacyCommitmentScanCap { _private: () };
+}
diff --git a/downstream-boundary/tests/ui/sealed_plumbing_sinks_unobtainable.stderr b/downstream-boundary/tests/ui/sealed_plumbing_sinks_unobtainable.stderr
new file mode 100644
index 00000000..6107b35d
--- /dev/null
+++ b/downstream-boundary/tests/ui/sealed_plumbing_sinks_unobtainable.stderr
@@ -0,0 +1,787 @@
+error[E0433]: cannot find `NullifierBatchPublisher` in `v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:83:23
+ |
+83 | let _ = node::v1::NullifierBatchPublisher::publish_batch(&publisher, &[]);
+ | ^^^^^^^^^^^^^^^^^^^^^^^ could not find `NullifierBatchPublisher` in `v1`
+
+error[E0433]: cannot find `NullifierBatchPublisher` in `v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:84:23
+ |
+84 | let _ = node::v1::NullifierBatchPublisher::try_prepare(&publisher, &[]);
+ | ^^^^^^^^^^^^^^^^^^^^^^^ could not find `NullifierBatchPublisher` in `v1`
+
+error[E0433]: cannot find `NullifierBatchPublisher` in `v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:85:23
+ |
+85 | let _ = node::v1::NullifierBatchPublisher::broadcast_commit(&publisher, loop {});
+ | ^^^^^^^^^^^^^^^^^^^^^^^ could not find `NullifierBatchPublisher` in `v1`
+
+error[E0433]: cannot find `NullifierBatchPublisher` in `v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:86:23
+ |
+86 | let _ = node::v1::NullifierBatchPublisher::broadcast_reveal(&publisher, loop {});
+ | ^^^^^^^^^^^^^^^^^^^^^^^ could not find `NullifierBatchPublisher` in `v1`
+
+error[E0433]: cannot find `zkcoins_prover` in the crate root
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:119:15
+ |
+119 | let _ = ::zkcoins_prover::publisher::BatchMember {
+ | ^^^^^^^^^^^^^^ could not find `zkcoins_prover` in the list of imported crates
+
+error[E0433]: cannot find `zkcoins_prover` in the crate root
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:123:15
+ |
+123 | let _ = ::zkcoins_prover::publisher::Publisher::connect(loop {});
+ | ^^^^^^^^^^^^^^ could not find `zkcoins_prover` in the list of imported crates
+
+error[E0425]: cannot find value `publish_applied_nullifier` in module `node::v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:35:23
+ |
+35 | let _ = node::v1::publish_applied_nullifier;
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `node::v1`
+
+error[E0425]: cannot find value `publish_v1_batch` in module `node::v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:36:23
+ |
+36 | let _ = node::v1::publish_v1_batch;
+ | ^^^^^^^^^^^^^^^^ not found in `node::v1`
+
+error[E0425]: cannot find value `fold_survivors_into_engine` in module `node::v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:56:23
+ |
+56 | let _ = node::v1::fold_survivors_into_engine;
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `node::v1`
+
+error[E0425]: cannot find value `replace_engine_nflog_from_survivors` in module `node::v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:57:23
+ |
+57 | let _ = node::v1::replace_engine_nflog_from_survivors;
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ not found in `node::v1`
+
+error[E0422]: cannot find struct, variant or union type `BatchMember` in module `node::v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:94:23
+ |
+94 | let _ = node::v1::BatchMember {
+ | ^^^^^^^^^^^ not found in `node::v1`
+
+error[E0422]: cannot find struct, variant or union type `PreparedBatch` in module `node::v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:98:23
+ |
+98 | let _ = node::v1::PreparedBatch {
+ | ^^^^^^^^^^^^^ not found in `node::v1`
+
+error[E0425]: cannot find value `Publisher` in module `node::v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:178:23
+ |
+178 | let _ = node::v1::Publisher;
+ | ^^^^^^^^^ not found in `node::v1`
+
+error[E0423]: expected value, found struct `node::v1::publish::Publisher`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:179:13
+ |
+179 | let _ = node::v1::publish::Publisher;
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ |
+ ::: $WORKSPACE/script-plonky2/src/publisher.rs
+ |
+ | pub struct Publisher {
+ | -------------------- `node::v1::publish::Publisher` defined here
+
+error[E0425]: cannot find value `PublisherConfig` in module `node::v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:180:23
+ |
+180 | let _ = node::v1::PublisherConfig;
+ | ^^^^^^^^^^^^^^^ not found in `node::v1`
+
+error[E0423]: expected value, found struct `node::v1::publish::PublisherConfig`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:181:13
+ |
+181 | let _ = node::v1::publish::PublisherConfig;
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: use struct literal syntax instead: `node::v1::publish::PublisherConfig { rpc_url: val, cookie_path: val, wallet_name: val, fee_rate_sat_per_vb: val, reveal_output_value: val, network: val, inclusion_delay_margin: val }`
+ |
+ ::: $WORKSPACE/script-plonky2/src/publisher.rs
+ |
+ | pub struct PublisherConfig {
+ | -------------------------- `node::v1::publish::PublisherConfig` defined here
+
+error[E0425]: cannot find value `PublishedBatch` in module `node::v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:182:23
+ |
+182 | let _ = node::v1::PublishedBatch;
+ | ^^^^^^^^^^^^^^ not found in `node::v1`
+
+error[E0423]: expected value, found struct `node::v1::publish::PublishedBatch`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:183:13
+ |
+183 | let _ = node::v1::publish::PublishedBatch;
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: use struct literal syntax instead: `node::v1::publish::PublishedBatch { aggregate: val, payload: val, commit_txid: val, reveal_txid: val, commit_output: val, block_anchor: val }`
+ |
+ ::: $WORKSPACE/script-plonky2/src/publisher.rs
+ |
+ | pub struct PublishedBatch {
+ | ------------------------- `node::v1::publish::PublishedBatch` defined here
+
+error[E0425]: cannot find value `PreparedBatch` in module `node::v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:184:23
+ |
+184 | let _ = node::v1::PreparedBatch;
+ | ^^^^^^^^^^^^^ not found in `node::v1`
+
+error[E0423]: expected value, found struct `node::v1::publish::PreparedBatch`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:185:13
+ |
+185 | let _ = node::v1::publish::PreparedBatch;
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: use struct literal syntax instead: `node::v1::publish::PreparedBatch { aggregate: val, payload: val, signed_commit: val, reveal_tx: val, commit_output: val, block_anchor: val, commit_vsize: val, reveal_vsize: val, commit_fee: val, reveal_fee: val }`
+ |
+ ::: $WORKSPACE/script-plonky2/src/publisher.rs
+ |
+ | pub struct PreparedBatch {
+ | ------------------------ `node::v1::publish::PreparedBatch` defined here
+
+error[E0425]: cannot find value `BatchMember` in module `node::v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:188:23
+ |
+188 | let _ = node::v1::BatchMember;
+ | ^^^^^^^^^^^ not found in `node::v1`
+
+error[E0423]: expected value, found struct `node::v1::publish::BatchMember`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:189:13
+ |
+189 | let _ = node::v1::publish::BatchMember;
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: use struct literal syntax instead: `node::v1::publish::BatchMember { sig: val, build_tip: val }`
+ |
+ ::: $WORKSPACE/script-plonky2/src/publisher.rs
+ |
+ | pub struct BatchMember {
+ | ---------------------- `node::v1::publish::BatchMember` defined here
+
+error[E0425]: cannot find value `zkcoins_prover` in crate `node`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:192:19
+ |
+192 | let _ = node::zkcoins_prover;
+ | ^^^^^^^^^^^^^^ not found in `node`
+
+error[E0425]: cannot find value `zkcoins_prover` in module `node::v1`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:193:23
+ |
+193 | let _ = node::v1::zkcoins_prover;
+ | ^^^^^^^^^^^^^^ not found in `node::v1`
+
+error[E0425]: cannot find value `zkcoins_prover` in module `node::v1::publish`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:194:32
+ |
+194 | let _ = node::v1::publish::zkcoins_prover;
+ | ^^^^^^^^^^^^^^ not found in `node::v1::publish`
+
+error[E0603]: function `publish_v1_batch` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:37:32
+ |
+37 | let _ = node::v1::publish::publish_v1_batch;
+ | ^^^^^^^^^^^^^^^^ private function
+ |
+note: the function `publish_v1_batch` is defined here
+ --> $WORKSPACE/node/src/v1/publish.rs
+ |
+ | / pub(crate) fn publish_v1_batch(
+ | | publisher: &Publisher,
+ | | members: &[BatchMember],
+ | | ) -> Result {
+ | |___________________________^
+
+error[E0603]: function `persist_engine_snapshot` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:40:30
+ |
+40 | let _ = node::v1::db_v1::persist_engine_snapshot;
+ | ^^^^^^^^^^^^^^^^^^^^^^^ private function
+ |
+note: the function `persist_engine_snapshot` is defined here
+ --> $WORKSPACE/node/src/v1/db_v1.rs
+ |
+ | pub(crate) async fn persist_engine_snapshot(pool: &PgPool, snap: &EngineSnapshot) -> Result<()> {
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error[E0603]: function `persist_engine_with_pending_members_ready` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:41:30
+ |
+41 | let _ = node::v1::db_v1::persist_engine_with_pending_members_ready;
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ private function
+ |
+note: the function `persist_engine_with_pending_members_ready` is defined here
+ --> $WORKSPACE/node/src/v1/db_v1.rs
+ |
+ | / pub(crate) async fn persist_engine_with_pending_members_ready(
+ | | pool: &PgPool,
+ | | snap: &EngineSnapshot,
+ | | owner: Address,
+... |
+ | | build_tip_hash: [u8; 32],
+ | | ) -> Result<()> {
+ | |_______________^
+
+error[E0603]: function `insert_pending_publish_members_ready` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:42:30
+ |
+42 | let _ = node::v1::db_v1::insert_pending_publish_members_ready;
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ private function
+ |
+note: the function `insert_pending_publish_members_ready` is defined here
+ --> $WORKSPACE/node/src/v1/db_v1.rs
+ |
+ | / pub(crate) async fn insert_pending_publish_members_ready(
+ | | pool: &PgPool,
+ | | owner: Address,
+ | | pk: [u8; 32],
+... |
+ | | build_tip_hash: [u8; 32],
+ | | ) -> Result<()> {
+ | |_______________^
+
+error[E0603]: function `mark_pending_publish_constructed` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:43:30
+ |
+43 | let _ = node::v1::db_v1::mark_pending_publish_constructed;
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ private function
+ |
+note: the function `mark_pending_publish_constructed` is defined here
+ --> $WORKSPACE/node/src/v1/db_v1.rs
+ |
+ | / pub(crate) async fn mark_pending_publish_constructed(
+ | | pool: &PgPool,
+ | | pk: [u8; 32],
+ | | commit_tx: &[u8],
+... |
+ | | reveal_txid: [u8; 32],
+ | | ) -> Result<()> {
+ | |_______________^
+
+error[E0603]: function `mark_pending_publish_status` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:44:30
+ |
+44 | let _ = node::v1::db_v1::mark_pending_publish_status;
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ private function
+ |
+note: the function `mark_pending_publish_status` is defined here
+ --> $WORKSPACE/node/src/v1/db_v1.rs
+ |
+ | / pub(crate) async fn mark_pending_publish_status(
+ | | pool: &PgPool,
+ | | pk: [u8; 32],
+ | | from_status: &str,
+ | | to_status: &str,
+ | | ) -> Result<()> {
+ | |_______________^
+
+error[E0603]: function `fold_survivors_into_engine` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:58:29
+ |
+58 | let _ = node::v1::scan::fold_survivors_into_engine;
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^ private function
+ |
+note: the function `fold_survivors_into_engine` is defined here
+ --> $WORKSPACE/node/src/v1/scan.rs
+ |
+ | / pub(crate) fn fold_survivors_into_engine(
+ | | engine: &mut StateEngine,
+ | | survivors: &[PublishedNullifier],
+ | | ) -> Result {
+ | |______________________^
+
+error[E0603]: function `replace_engine_nflog_from_survivors` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:59:29
+ |
+59 | let _ = node::v1::scan::replace_engine_nflog_from_survivors;
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ private function
+ |
+note: the function `replace_engine_nflog_from_survivors` is defined here
+ --> $WORKSPACE/node/src/v1/scan.rs
+ |
+ | / pub(crate) fn replace_engine_nflog_from_survivors(
+ | | engine: &mut StateEngine,
+ | | tip_height: u64,
+ | | tip_hash: [u8; 32],
+ | | survivors: &[PublishedNullifier],
+ | | ) -> Result {
+ | |______________________^
+
+error[E0603]: trait `NullifierBatchPublisher` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:87:32
+ |
+87 | let _ = node::v1::receive::NullifierBatchPublisher::publish_batch(&publisher, &[]);
+ | ^^^^^^^^^^^^^^^^^^^^^^^ ------------- associated function `publish_batch` is not publicly re-exported
+ | |
+ | private trait
+ |
+note: the trait `NullifierBatchPublisher` is defined here
+ --> $WORKSPACE/node/src/v1/receive.rs
+ |
+ | pub(crate) trait NullifierBatchPublisher {
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error[E0603]: struct `BatchMember` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:111:32
+ |
+111 | let _ = node::v1::publish::BatchMember {
+ | ^^^^^^^^^^^ private struct
+ |
+note: the struct `BatchMember` is defined here
+ --> $WORKSPACE/node/src/v1/publish.rs
+ |
+ | BatchMember, PreparedBatch, PublishedBatch, Publisher, PublisherConfig,
+ | ^^^^^^^^^^^
+help: import `BatchMember` directly
+ |
+111 - let _ = node::v1::publish::BatchMember {
+111 + let _ = zkcoins_prover_plonky2::publisher::BatchMember {
+ |
+
+error[E0603]: struct `Publisher` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:179:32
+ |
+179 | let _ = node::v1::publish::Publisher;
+ | ^^^^^^^^^ private struct
+ |
+note: the struct `Publisher` is defined here
+ --> $WORKSPACE/node/src/v1/publish.rs
+ |
+ | BatchMember, PreparedBatch, PublishedBatch, Publisher, PublisherConfig,
+ | ^^^^^^^^^
+help: import `Publisher` directly
+ |
+179 - let _ = node::v1::publish::Publisher;
+179 + let _ = zkcoins_prover_plonky2::publisher::Publisher;
+ |
+
+error[E0603]: struct `PublisherConfig` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:181:32
+ |
+181 | let _ = node::v1::publish::PublisherConfig;
+ | ^^^^^^^^^^^^^^^ private struct
+ |
+note: the struct `PublisherConfig` is defined here
+ --> $WORKSPACE/node/src/v1/publish.rs
+ |
+ | BatchMember, PreparedBatch, PublishedBatch, Publisher, PublisherConfig,
+ | ^^^^^^^^^^^^^^^
+help: import `PublisherConfig` directly
+ |
+181 - let _ = node::v1::publish::PublisherConfig;
+181 + let _ = zkcoins_prover_plonky2::publisher::PublisherConfig;
+ |
+
+error[E0603]: struct `PublishedBatch` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:183:32
+ |
+183 | let _ = node::v1::publish::PublishedBatch;
+ | ^^^^^^^^^^^^^^ private struct
+ |
+note: the struct `PublishedBatch` is defined here
+ --> $WORKSPACE/node/src/v1/publish.rs
+ |
+ | BatchMember, PreparedBatch, PublishedBatch, Publisher, PublisherConfig,
+ | ^^^^^^^^^^^^^^
+help: import `PublishedBatch` directly
+ |
+183 - let _ = node::v1::publish::PublishedBatch;
+183 + let _ = zkcoins_prover_plonky2::publisher::PublishedBatch;
+ |
+
+error[E0603]: struct `PreparedBatch` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:185:32
+ |
+185 | let _ = node::v1::publish::PreparedBatch;
+ | ^^^^^^^^^^^^^ private struct
+ |
+note: the struct `PreparedBatch` is defined here
+ --> $WORKSPACE/node/src/v1/publish.rs
+ |
+ | BatchMember, PreparedBatch, PublishedBatch, Publisher, PublisherConfig,
+ | ^^^^^^^^^^^^^
+help: import `PreparedBatch` directly
+ |
+185 - let _ = node::v1::publish::PreparedBatch;
+185 + let _ = zkcoins_prover_plonky2::publisher::PreparedBatch;
+ |
+
+error[E0603]: struct `BatchMember` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:189:32
+ |
+189 | let _ = node::v1::publish::BatchMember;
+ | ^^^^^^^^^^^ private struct
+ |
+note: the struct `BatchMember` is defined here
+ --> $WORKSPACE/node/src/v1/publish.rs
+ |
+ | BatchMember, PreparedBatch, PublishedBatch, Publisher, PublisherConfig,
+ | ^^^^^^^^^^^
+help: import `BatchMember` directly
+ |
+189 - let _ = node::v1::publish::BatchMember;
+189 + let _ = zkcoins_prover_plonky2::publisher::BatchMember;
+ |
+
+error[E0624]: method `with_engine_mut` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:47:38
+ |
+47 | let _ = node::v1::EngineAdapter::with_engine_mut;
+ | ^^^^^^^^^^^^^^^ private method
+ |
+ ::: $WORKSPACE/node/src/v1/adapter.rs
+ |
+ | pub(crate) fn with_engine_mut(&self, f: impl FnOnce(&mut StateEngine) -> R) -> Result {
+ | ------------------------------------------------------------------------------------------- private method defined here
+
+error[E0624]: method `restore_live` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:48:38
+ |
+48 | let _ = node::v1::EngineAdapter::restore_live;
+ | ^^^^^^^^^^^^ private method
+ |
+ ::: $WORKSPACE/node/src/v1/adapter.rs
+ |
+ | pub(crate) fn restore_live(&self, snap: EngineSnapshot) -> Result<()> {
+ | --------------------------------------------------------------------- private method defined here
+
+error[E0624]: method `set_tip_hash` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:49:38
+ |
+49 | let _ = node::v1::EngineAdapter::set_tip_hash;
+ | ^^^^^^^^^^^^ private method
+ |
+ ::: $WORKSPACE/node/src/v1/adapter.rs
+ |
+ | pub(crate) fn set_tip_hash(&self, tip_hash: [u8; 32]) -> Result<()> {
+ | ------------------------------------------------------------------- private method defined here
+
+error[E0624]: method `persist` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:50:38
+ |
+50 | let _ = node::v1::EngineAdapter::persist;
+ | ^^^^^^^ private method
+ |
+ ::: $WORKSPACE/node/src/v1/adapter.rs
+ |
+ | pub(crate) async fn persist(&self) -> Result<()> {
+ | ------------------------------------------------ private method defined here
+
+error[E0624]: method `reload_from_db` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:51:38
+ |
+51 | let _ = node::v1::EngineAdapter::reload_from_db;
+ | ^^^^^^^^^^^^^^ private method
+ |
+ ::: $WORKSPACE/node/src/v1/adapter.rs
+ |
+ | pub(crate) async fn reload_from_db(&self) -> Result<()> {
+ | ------------------------------------------------------- private method defined here
+
+error[E0624]: method `lock_writes` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:52:38
+ |
+52 | let _ = node::v1::EngineAdapter::lock_writes;
+ | ^^^^^^^^^^^ private method
+ |
+ ::: $WORKSPACE/node/src/v1/adapter.rs
+ |
+ | pub(crate) async fn lock_writes(&self) -> AsyncMutexGuard<'_, ()> {
+ | ----------------------------------------------------------------- private method defined here
+
+error[E0624]: method `snapshot_live` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:53:38
+ |
+53 | let _ = node::v1::EngineAdapter::snapshot_live;
+ | ^^^^^^^^^^^^^ private method
+ |
+ ::: $WORKSPACE/node/src/v1/adapter.rs
+ |
+ | pub(crate) fn snapshot_live(&self) -> EngineSnapshot {
+ | ---------------------------------------------------- private method defined here
+
+warning: unreachable call
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:25:15
+ |
+25 | match node::v1::connect_v1_publisher(loop {}) {
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ------- any code following this expression is unreachable
+ | |
+ | unreachable call
+...
+73 | let publisher = publisher_from_connect!();
+ | ------------------------- in this macro invocation
+ |
+ = note: `#[warn(unreachable_code)]` (part of `#[warn(unused)]`) on by default
+ = note: this warning originates in the macro `publisher_from_connect` (in Nightly builds, run with -Z macro-backtrace for more info)
+
+error[E0599]: no method named `prepare` found for struct `V1Publisher` in the current scope
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:74:23
+ |
+74 | let _ = publisher.prepare(&[]);
+ | ^^^^^^^ method not found in `V1Publisher`
+
+error[E0624]: method `broadcast_commit` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:75:23
+ |
+75 | let _ = publisher.broadcast_commit(loop {});
+ | ^^^^^^^^^^^^^^^^ private method
+ |
+ ::: $WORKSPACE/node/src/v1/publish.rs
+ |
+ | pub(crate) fn broadcast_commit(&self, prepared: &PreparedBatch) -> Result {
+ | ---------------------------------------------------------------------------------------- private method defined here
+
+warning: unreachable call
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:75:23
+ |
+75 | let _ = publisher.broadcast_commit(loop {});
+ | ^^^^^^^^^^^^^^^^ ------- any code following this expression is unreachable
+ | |
+ | unreachable call
+
+error[E0624]: method `broadcast_reveal` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:76:23
+ |
+76 | let _ = publisher.broadcast_reveal(loop {});
+ | ^^^^^^^^^^^^^^^^ private method
+ |
+ ::: $WORKSPACE/node/src/v1/publish.rs
+ |
+ | pub(crate) fn broadcast_reveal(&self, prepared: &PreparedBatch) -> Result {
+ | ---------------------------------------------------------------------------------------- private method defined here
+
+warning: unreachable call
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:76:23
+ |
+76 | let _ = publisher.broadcast_reveal(loop {});
+ | ^^^^^^^^^^^^^^^^ ------- any code following this expression is unreachable
+ | |
+ | unreachable call
+
+error[E0599]: no method named `publish` found for struct `V1Publisher` in the current scope
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:77:23
+ |
+77 | let _ = publisher.publish(&[]);
+ | ^^^^^^^ method not found in `V1Publisher`
+
+warning: unreachable call
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:25:15
+ |
+25 | match node::v1::connect_v1_publisher(loop {}) {
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ------- any code following this expression is unreachable
+ | |
+ | unreachable call
+...
+82 | let publisher = publisher_from_connect!();
+ | ------------------------- in this macro invocation
+ |
+ = note: this warning originates in the macro `publisher_from_connect` (in Nightly builds, run with -Z macro-backtrace for more info)
+
+warning: unreachable call
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:85:13
+ |
+85 | let _ = node::v1::NullifierBatchPublisher::broadcast_commit(&publisher, loop {});
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ------- any code following this expression is unreachable
+ | |
+ | unreachable call
+
+warning: unreachable call
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:86:13
+ |
+86 | let _ = node::v1::NullifierBatchPublisher::broadcast_reveal(&publisher, loop {});
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ------- any code following this expression is unreachable
+ | |
+ | unreachable call
+
+warning: unreachable expression
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:96:20
+ |
+95 | sig: loop {},
+ | ------- any code following this expression is unreachable
+96 | build_tip: loop {},
+ | ^^^^^^^ unreachable expression
+
+warning: unreachable expression
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:100:18
+ |
+ 99 | aggregate: loop {},
+ | ------- any code following this expression is unreachable
+100 | payload: loop {},
+ | ^^^^^^^ unreachable expression
+
+warning: unreachable expression
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:113:20
+ |
+112 | sig: loop {},
+ | ------- any code following this expression is unreachable
+113 | build_tip: loop {},
+ | ^^^^^^^ unreachable expression
+
+warning: unreachable expression
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:121:20
+ |
+120 | sig: loop {},
+ | ------- any code following this expression is unreachable
+121 | build_tip: loop {},
+ | ^^^^^^^ unreachable expression
+
+warning: unreachable call
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:123:13
+ |
+123 | let _ = ::zkcoins_prover::publisher::Publisher::connect(loop {});
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ------- any code following this expression is unreachable
+ | |
+ | unreachable call
+
+warning: unreachable call
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:25:15
+ |
+ 25 | match node::v1::connect_v1_publisher(loop {}) {
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ------- any code following this expression is unreachable
+ | |
+ | unreachable call
+...
+129 | let publisher = publisher_from_connect!();
+ | ------------------------- in this macro invocation
+ |
+ = note: this warning originates in the macro `publisher_from_connect` (in Nightly builds, run with -Z macro-backtrace for more info)
+
+error[E0599]: no method named `prepare` found for reference `&V1Publisher` in the current scope
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:132:26
+ |
+132 | let _ = (&publisher).prepare(&[]);
+ | ^^^^^^^ method not found in `&V1Publisher`
+
+error[E0599]: no method named `publish` found for reference `&V1Publisher` in the current scope
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:133:26
+ |
+133 | let _ = (&publisher).publish(&[]);
+ | ^^^^^^^ method not found in `&V1Publisher`
+
+error[E0277]: the trait bound `V1Publisher: Deref` is not satisfied
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:139:17
+ |
+139 | needs_deref(&publisher);
+ | ----------- ^^^^^^^^^^ the trait `Deref` is not implemented for `V1Publisher`
+ | |
+ | required by a bound introduced by this call
+ |
+note: required by a bound in `needs_deref`
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:138:23
+ |
+138 | fn needs_deref(_t: &T) {}
+ | ^^^^^^^^^^^^^^^ required by this bound in `needs_deref`
+
+error[E0599]: no method named `prepare` found for struct `V1Publisher` in the current scope
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:142:27
+ |
+142 | let _ = (*&publisher).prepare(&[]);
+ | ^^^^^^^ method not found in `V1Publisher`
+
+error[E0277]: the trait bound `V1Publisher: Deref` is not satisfied
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:143:36
+ |
+143 | let _ = std::ops::Deref::deref(&publisher).prepare(&[]);
+ | ---------------------- ^^^^^^^^^^ the trait `Deref` is not implemented for `V1Publisher`
+ | |
+ | required by a bound introduced by this call
+
+error[E0277]: the trait bound `V1Publisher: Deref` is not satisfied
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:143:13
+ |
+143 | let _ = std::ops::Deref::deref(&publisher).prepare(&[]);
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Deref` is not implemented for `V1Publisher`
+
+warning: unreachable call
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:25:15
+ |
+ 25 | match node::v1::connect_v1_publisher(loop {}) {
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ------- any code following this expression is unreachable
+ | |
+ | unreachable call
+...
+148 | let publisher = publisher_from_connect!();
+ | ------------------------- in this macro invocation
+ |
+ = note: this warning originates in the macro `publisher_from_connect` (in Nightly builds, run with -Z macro-backtrace for more info)
+
+error[E0277]: the trait bound `V1Publisher: AsRef<_>` is not satisfied
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:153:47
+ |
+153 | let exposed = std::convert::AsRef::as_ref(&publisher);
+ | --------------------------- ^^^^^^^^^^ the trait `AsRef<_>` is not implemented for `V1Publisher`
+ | |
+ | required by a bound introduced by this call
+
+warning: unreachable call
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:155:21
+ |
+155 | let _ = exposed.broadcast_commit(loop {});
+ | ^^^^^^^^^^^^^^^^ ------- any code following this expression is unreachable
+ | |
+ | unreachable call
+
+warning: unreachable call
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:156:21
+ |
+156 | let _ = exposed.broadcast_reveal(loop {});
+ | ^^^^^^^^^^^^^^^^ ------- any code following this expression is unreachable
+ | |
+ | unreachable call
+
+warning: unreachable call
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:25:15
+ |
+ 25 | match node::v1::connect_v1_publisher(loop {}) {
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ------- any code following this expression is unreachable
+ | |
+ | unreachable call
+...
+164 | let publisher = publisher_from_connect!();
+ | ------------------------- in this macro invocation
+ |
+ = note: this warning originates in the macro `publisher_from_connect` (in Nightly builds, run with -Z macro-backtrace for more info)
+
+error[E0616]: field `inner` of struct `V1Publisher` is private
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:167:23
+ |
+167 | let _ = publisher.inner;
+ | ^^^^^ private field
+
+error[E0599]: no method named `into_inner` found for struct `V1Publisher` in the current scope
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:170:23
+ |
+170 | let _ = publisher.into_inner();
+ | ^^^^^^^^^^
+ |
+help: there is a method `into_either` with a similar name, but with different arguments
+ --> $CARGO/either-$VERSION/src/into_either.rs
+ |
+ | fn into_either(self, into_left: bool) -> Either {
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error[E0599]: no method named `as_inner` found for struct `V1Publisher` in the current scope
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:171:23
+ |
+171 | let _ = publisher.as_inner();
+ | ^^^^^^^^ method not found in `V1Publisher`
+
+error[E0599]: no method named `inner` found for struct `V1Publisher` in the current scope
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:172:23
+ |
+172 | let _ = publisher.inner();
+ | ^^^^^ private field, not a method
+
+error[E0599]: no method named `into_publisher` found for struct `V1Publisher` in the current scope
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:173:23
+ |
+173 | let _ = publisher.into_publisher();
+ | ^^^^^^^^^^^^^^
+ |
+help: there is a method `into_either` with a similar name, but with different arguments
+ --> $CARGO/either-$VERSION/src/into_either.rs
+ |
+ | fn into_either(self, into_left: bool) -> Either {
+ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+error[E0599]: no method named `as_publisher` found for struct `V1Publisher` in the current scope
+ --> tests/ui/sealed_plumbing_sinks_unobtainable.rs:174:23
+ |
+174 | let _ = publisher.as_publisher();
+ | ^^^^^^^^^^^^ method not found in `V1Publisher`
diff --git a/esplora-bound/Cargo.toml b/esplora-bound/Cargo.toml
new file mode 100644
index 00000000..aa1d35fd
--- /dev/null
+++ b/esplora-bound/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "esplora-bound"
+version.workspace = true
+edition.workspace = true
+description = "Guarded Esplora HTTP wrappers — sole workspace owner of the esplora-client dependency"
+publish = false
+
+[dependencies]
+bitcoin = { workspace = true }
+esplora-client = { git = "https://github.com/BitVM/rust-esplora-client", branch = "master" }
+# Process-stack policy runs inside the broadcast-client constructor so every
+# construction — including from `node` itself — executes the same check.
+# Claim reset is `#[cfg(test)]` of `stack-policy` only (not a feature).
+stack-policy = { path = "../stack-policy" }
diff --git a/esplora-bound/src/lib.rs b/esplora-bound/src/lib.rs
new file mode 100644
index 00000000..6da603e3
--- /dev/null
+++ b/esplora-bound/src/lib.rs
@@ -0,0 +1,207 @@
+//! Sole workspace owner of the `esplora-client` crate.
+//!
+//! ## Compiler-enforced boundary
+//!
+//! Downstream packages (notably `node`) depend on **this** crate, not on
+//! `esplora-client`. The raw `AsyncClient` / `Builder` types are never
+//! re-exported, and both wrappers keep the raw handle in a **private**
+//! field with no `into_inner` / `as_raw` / public field. A raw client is
+//! therefore unobtainable from `node` because the type is not in scope —
+//! not because of a string-search convention.
+//!
+//! Callers only see [`EsploraReadClient`] (reads) and
+//! [`EsploraBroadcastClient`] (broadcast + get_tx).
+//!
+//! ## Broadcast capability = co-located process-stack policy
+//!
+//! [`EsploraBroadcastClient::connect`] runs
+//! [`stack_policy::ensure_legacy_publisher_allowed`] **inside** this
+//! constructor before any Esplora I/O. There is no witness typestate and
+//! no feature-gated mint path: every construction of a broadcast-capable
+//! facade, from any crate including `node`, executes the same check.
+//! Possession of a returned client is therefore evidence that the process
+//! claim allowed legacy publish at construction time.
+
+use bitcoin::{Address, BlockHash, OutPoint, Transaction, Txid};
+use esplora_client::{
+ r#async::DefaultSleeper, AsyncClient as RawAsyncClient, Builder as RawBuilder,
+};
+
+type BoxError = Box;
+
+/// Read-only Esplora HTTP surface used by the legacy scanner, readiness
+/// probe, and UTXO fetch.
+pub struct EsploraReadClient {
+ inner: RawAsyncClient,
+}
+
+impl std::fmt::Debug for EsploraReadClient {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str("EsploraReadClient { /* inner private */ }")
+ }
+}
+
+/// Subset of Esplora block status the scanner needs (no raw type leak).
+#[derive(Clone, Debug)]
+pub struct BlockStatusView {
+ pub height: Option,
+ pub next_best: Option,
+}
+
+/// One confirmed UTXO as returned by Esplora `GET /address/:addr/utxo`.
+#[derive(Clone, Debug)]
+pub struct AddressUtxo {
+ pub outpoint: OutPoint,
+ pub value_sats: u64,
+}
+
+impl EsploraReadClient {
+ /// Build a read client from an Esplora base URL.
+ pub fn connect(url: &str) -> Result {
+ let builder = RawBuilder::new(url);
+ let inner = RawAsyncClient::::from_builder(builder)?;
+ Ok(Self { inner })
+ }
+
+ pub async fn get_tip_hash(&self) -> Result {
+ Ok(self.inner.get_tip_hash().await?)
+ }
+
+ pub async fn get_height(&self) -> Result {
+ Ok(self.inner.get_height().await?)
+ }
+
+ pub async fn get_block_txids(&self, block_hash: BlockHash) -> Result, BoxError> {
+ Ok(self.inner.get_block_txids(block_hash).await?)
+ }
+
+ pub async fn get_tx(&self, txid: &Txid) -> Result