Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/auto-release-pr-staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ jobs:
"- [ ] Merge to promote staging to develop (deploys to DEV)" \
> /tmp/pr-body.md

# Created as DRAFT so the operator's `gh pr ready` is the
# explicit gate that fires a `ready_for_review` event and
# triggers ci.yaml — PRs opened via GITHUB_TOKEN would
# otherwise hit GitHub's anti-recursion policy and skip
# downstream workflows entirely.
gh pr create \
--draft \
--base develop \
--head staging \
--title "Promote: staging -> develop" \
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/auto-release-pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,13 @@ jobs:
--description "Run heavy M3 Ultra test + coverage jobs on this PR" \
2>/dev/null || true

# Created as DRAFT — same rationale as auto-release-pr-staging.yaml:
# the operator's explicit `gh pr ready` toggle fires the
# `ready_for_review` event that triggers ci.yaml. The `ci:full`
# label is still applied at creation so the heavy M3 Ultra
# tests + Coverage Gate run as soon as the PR is marked ready.
gh pr create \
--draft \
--base main \
--head develop \
--title "Release: develop -> main" \
Expand Down
500 changes: 350 additions & 150 deletions .github/workflows/ci.yaml

Large diffs are not rendered by default.

43 changes: 30 additions & 13 deletions .github/workflows/deploy-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -102,28 +102,45 @@ jobs:
${{ secrets.DEPLOY_DEV_USER }}@${{ secrets.DEPLOY_DEV_HOST }} \
"$DEPLOY_CMD"

# Post-deploy smoke test: hit the public endpoint until /api/info
# answers 200 or we give up. A green "Build and deploy to DEV"
# without this step was historically misleading — a runtime-bootstrap
# panic left the container Up-but-unresponsive while the workflow
# reported success. Failing this step blocks the auto-release PR
# from collecting a green check and surfaces the regression in CI.
# Post-deploy smoke test: hit the public endpoint until
# /health/ready reports `ready: true` (or we give up). A green
# "Build and deploy to DEV" without this step was historically
# misleading — a runtime-bootstrap panic left the container
# Up-but-unresponsive while the workflow reported success.
# Failing this step blocks the auto-release PR from collecting
# a green check and surfaces the regression in CI.
#
# `/health/ready` (not `/api/info`) is the load-bearing gate.
# Post-#154 the node binds the HTTP listener immediately and
# warms the Plonky2 prover in a background task; `/api/info`
# returns 200 within seconds, but `/health/ready` stays at
# `{"ready":false,"prover":"warming"}` for the 10-30 s warmup.
# Downstream jobs (E2E preflight, smoke tests against the
# publisher wallet) gated on `/health/ready` and were racing
# the warmup — observed empirically in
# https://github.com/zk-coins/node/actions/runs/26793933906/job/78986599030
# (Release PR #166, prover still warming at +4 s after the
# E2E job picked the runner up). Polling `/health/ready` here
# means the deploy job only reports success once the node is
# actually ready for traffic.
- name: Smoke test public endpoint
run: |
set -euo pipefail
URL="https://dev-api.zkcoins.app/api/info"
URL="https://dev-api.zkcoins.app/health/ready"
for i in $(seq 1 30); do
code=$(curl -sS -o /tmp/info.json -w '%{http_code}' --max-time 10 "$URL" || echo "000")
if [ "$code" = "200" ]; then
echo "DEV /api/info responded 200 after ${i} attempt(s):"
cat /tmp/info.json
body=$(curl -sS -o /tmp/ready.json -w '%{http_code}' --max-time 10 "$URL" || echo "000")
code="$body"
if [ "$code" = "200" ] && jq -e '.ready == true' /tmp/ready.json > /dev/null 2>&1; then
echo "DEV /health/ready reports ready=true after ${i} attempt(s):"
cat /tmp/ready.json
echo
exit 0
fi
echo "[$i/30] $URL -> ${code} (waiting 10 s)"
ready_snap=$(jq -c '. // "(no body)"' /tmp/ready.json 2>/dev/null || echo "(non-json)")
echo "[$i/30] $URL -> ${code} ${ready_snap} (waiting 10 s)"
sleep 10
done
echo "::error::DEV /api/info never returned 200 within ~5 min after deploy"
echo "::error::DEV /health/ready never reported ready=true within ~5 min after deploy"
exit 1

# Functional verification of the deployed DEV node.
Expand Down
63 changes: 61 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,26 @@

This guide covers everything you need to develop, test, and deploy the zkCoins backend.

The first section, "Working on the Plonky2 Migration", documents the project invariants, the decision recipe for "should this go in the MVP?", the pre-push checklist, and the known foot-guns. It applies to all work on `develop` after the 2026-05-18 SP1 → Plonky2 cutover. The rest of this file is the dev guide for day-to-day node work.
## Trust model — node is trusted, wallet is thin

zkCoins is built around a single trust assumption: **the wallet trusts the node it talks to.** The only line the node is not allowed to cross is the wallet's private key — that stays in the wallet. Everything else may be delegated.

This is a hard project rule. It shapes every design and implementation decision:

- **No anti-node logic in the wallet or SDK.** No client-side proof verification, no scan loops, no view-key / spend-key splits, no consistency checks against a second node, no "node integrity" indicators in the UI. If a feature exists to reduce trust in the node, it does not belong in the wallet or SDK.
- **Self-hosting is the escape hatch.** Users who do not want to trust the public operator run their own node. The wallet must always be able to switch to a different node by changing a single configuration value.
- **The node is built so that self-hosting is easy.** Single container, documented configuration, deterministic state, no operator-specific dependencies.
- **The SDK and wallet stay thin.** They expose seed + address + the small set of operations every familiar wallet SDK exposes. Integrators (Cake Wallet, LayerZ, BlueWallet, …) should be able to wire zkCoins up with the same effort as adding a second Bitcoin-family chain.

When in doubt about whether a feature belongs in the wallet, SDK, or node: if it exists to reduce trust in the node, build it node-side, or document self-hosting as the answer. This rule is mirrored verbatim in [`zk-coins/node`](https://github.com/zk-coins/node/blob/develop/CONTRIBUTING.md), [`zk-coins/sdk`](https://github.com/zk-coins/sdk/blob/develop/CONTRIBUTING.md), [`zk-coins/app`](https://github.com/zk-coins/app/blob/develop/CONTRIBUTING.md), and [`zk-coins/docs`](https://github.com/zk-coins/docs/blob/develop/CONTRIBUTING.md).

---

## Working on the Plonky2 Migration

Canonical entry point for any session (agent or human) picking up the
This section documents the project invariants, the decision recipe for "should this go in the MVP?", the pre-push checklist, and the known foot-guns. It applies to all work on `develop` after the 2026-05-18 SP1 → Plonky2 cutover. The rest of this file is the dev guide for day-to-day node work.

It is the canonical entry point for any session (agent or human) picking up the
codebase without prior context. The Plonky2 migration (PR [#17](https://github.com/zk-coins/node/pull/17))
merged on 2026-05-18; this section captures the project invariants that
survive the migration. Read this section, then dive into the linked
Expand Down Expand Up @@ -529,8 +542,54 @@ on startup if unset — there is no silent fallback.
| `NETWORK_NAME` | `Mutinynet` / `Mainnet` | Human-readable name returned by `/api/info`. Derived from `IS_MAINNET` if unset. Purely cosmetic — no behavioural effect. |
| `PROOFS_DIR` | `./proofs` | Directory for per-proof bincode files (see `Persistent State` below). |
| `SCANNER_INITIAL_SETTLE_TIMEOUT_MS` | (runtime-defined) | Override for the scanner's initial-settle deadline; see `runtime.rs`. |
| `ZKCOINS_SKIP_BOOTSTRAP_WARMUP` | `false` | When `1`/`true`, skip the background Plonky2 prover warmup task at startup. Sets `prover_warm = true` immediately so `/health/ready` returns 200 the moment the listener binds. Set in the runtime smoke tests so pre-push wall stays bounded; production deploys leave it unset. See **Bootstrap timing** below. |
| `RUST_LOG` | `info` | Log level (`debug`, `info`, `warn`, `error`). |

### Bootstrap timing

The node bootstraps the HTTP listener and the Plonky2 prover in a
specific sequence so the API is reachable as quickly as possible:

1. `~0.1 s` — `TcpListener::bind` returns. `/health` (liveness) is now
200. The listener accepts connections and `axum::serve` starts
draining them.
2. `~0.1 s` — `tokio::task::spawn_blocking` is launched with
`AccountNode::warmup_prover`, a synthetic discardable
`prove_initial` that wakes the Rayon worker pool and the AOT-
compiled Plonky2 evaluator caches. The task runs CPU-bound on a
blocking-pool thread so the tokio worker that owns `axum::serve` is
not starved.
3. `~21 s` — `warmup_prover` returns Ok. The background task flips
`prover_warm = true`. `/health/ready` now returns 200 with
`prover: ready`.

While step 3 is in progress, `/health/ready` returns 503 with
`{"ready":false,"failures":["prover"],"status":"starting","prover":"warming"}`.
A load balancer (or Kuma monitor) keyed on the readiness endpoint
keeps traffic on the previous-generation pod through the warmup
window — the new pod's `/health` still returns 200 so the container
runtime does not restart it.

A user request that lands BEFORE the warmup completes still serves
correctly — it just pays the ~7 s cold-prove tax instead of the
steady-state ~5 s p50. The trade-off vs. the previous synchronous
shape (PR #147, closed): API offline time per deploy stays ~0.1 s
instead of ~21 s; the cold-tax shifts from the first
post-deploy user request to whichever request arrives during the
warmup window.

Empirical numbers (dfxdev R2 probe, 2026-05-31):

| Stage | Wall (ms) | Notes |
|---|---|---|
| `circuit_build_wall_ms` | 14214 | `Prover::new()` — paid by `load_from_pg` BEFORE the listener binds. |
| `prove_cold_wall_ms` | 7012 | First prove call after build — what the background warmup pays. |
| `prove_warm p50` | 4777 | Steady state — every request after the warmup task flips the flag. |

Set `ZKCOINS_SKIP_BOOTSTRAP_WARMUP=1` to skip the warmup task entirely.
Used by the runtime smoke tests in `runtime_tests.rs`; production
deploys leave it unset.

### Minimal local-dev env

All chain-shaping vars are required — there are no defaults. Set them
Expand Down
66 changes: 66 additions & 0 deletions MIGRATION_RESEARCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,72 @@ neither the original one:
the client side. Always cross-check the request against a
known-good client's wire format before blaming the server.

### 7.25 Bootstrap warmup: background over synchronous to preserve API availability — **codified**

The dfxdev R2 probe (2026-05-31, see `node/src/bin/probe_r2.rs`)
measured a ~7 s cold-prove tax on the first `prove_initial` after
`Prover::new()` — paid in production by whichever user request
arrived first after a container restart, surfacing as a ~12 s
`/api/mint` instead of the steady-state ~5 s p50. Two shapes were
considered for hiding the tax inside the bootstrap.

**Shape A: synchronous warmup before listener bind (PR #147,
closed).** Run `warmup_prover` synchronously between `load_from_pg`
and `TcpListener::bind`. Pushes API offline time per deploy from
~14 s (circuit build) to ~21 s (circuit build + cold prove). Net
benefit per deploy: every user request after the listener binds is
warm. Rejected because the offline-window grew by 50%; the user
constraint is explicit ("API soll wenn immer möglich SOFORT online
sein").

**Shape B: background warmup after listener bind (this PR).** Bind
the listener at ~0.1 s, then spawn `warmup_prover` on the
`tokio::task::spawn_blocking` pool so the CPU-bound prove runs on a
blocking-pool thread and does not starve the tokio worker that owns
`axum::serve`. Expose the warmup status as
`AppState::prover_warm: Arc<AtomicBool>` and gate `/health/ready` on
it: while the task is running the readiness probe returns 503 with
`{"status":"starting","prover":"warming","failures":["prover"]}`. A
load balancer keeps holding traffic on the previous-gen pod through
the ~21 s warmup window; the new pod's `/health` (liveness) returns
200 immediately so the container runtime does not restart it. A user
request that lands DURING the warmup still serves correctly — it
pays the ~7 s cold tax, which is the worst-case-equivalent cost to
the pre-PR-#147 shape but bounded to the ~21 s window instead of
"first request after every deploy".

Three architecture decisions inside Shape B that are easy to get
wrong:

1. **`spawn_blocking` over `tokio::spawn`.** Plonky2 `prove_initial`
is CPU-bound (Rayon worker pool, AOT-compiled evaluator caches);
running it on a tokio worker thread would starve every other
future on that worker for ~7 s — including the `axum::serve`
future, which is the entire point of binding the listener first.
`spawn_blocking` runs the closure on the blocking pool, leaving
the tokio workers free to dispatch HTTP requests.

2. **`Arc<AtomicBool>` over `Arc<RwLock<bool>>`.** The flag is
write-once + read-many. `AtomicBool::store(true, SeqCst)` is a
single instruction; `RwLock` would add a syscall on every
`/health/ready` read for a flag that flips exactly once per
process lifetime.

3. **`std::process::exit(1)` over `panic!()`.** A panic inside the
`spawn_blocking` closure surfaces as a `JoinError` only when the
`JoinHandle` is awaited — but we deliberately do not await it
(the listener serves while the warmup runs). A bare `panic!()`
would leave the node running with `prover_warm = false`
permanently, never returning 200 on `/health/ready`. `exit(1)`
crash-loops the container immediately, matching the same severity
as the previous synchronous `expect()` shape.

The user-visible behavioural change from Shape A to Shape B is the
small window where a request lands during warmup and pays the ~7 s
cold tax. That trade-off is documented in `CONTRIBUTING.md`
("Bootstrap timing") so an operator does not misread the warmup-
window p50 as a regression.

---

## 8. Local Artifacts
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ The **on-chain footprint stays private** — Plonky2 ensures that the public out
| | Hosted (`api.zkcoins.app`) | Self-hosted |
| --- | --- | --- |
| On-chain privacy (vs. block explorers) | ✅ | ✅ |
| Operator sees plaintext transaction data | ❌ Yes — DFX runs the hosted node | ✅ No |
| Operator sees plaintext transaction data | ❌ Yes — `api.zkcoins.app` is 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.
Expand Down Expand Up @@ -72,6 +72,7 @@ API endpoints, background services, their activation status, and the tests that
| Health check | `GET /health` | always | mvp | 100% (router) |
| Network info | `GET /api/info` | env¹ | mvp | 100% (router) |
| Get balance | `GET /api/balance?address=<hex>` | always | mvp | 100% (router) |
| List per-address history | `GET /api/history?address=<hex>&limit=<n>&offset=<n>` | always | mvp | 100% (router) |
| List all addresses | `GET /api/address` | feature (`address-list`) | gate | 100% (router) |
| Mint coins (single-phase) | `POST /api/mint` | always² | mvp | 100% (account_node) |
| Send — phase 1 (generate proof) | `POST /api/send` | env² | mvp | 100% (router) |
Expand Down Expand Up @@ -218,8 +219,8 @@ Features tagged `mvp` whose current test coverage is insufficient — these bloc
| 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. PRD: `http://electrs-mainnet:3000` (DFX Mainnet stack). 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). PRD: `wss://mempool.space/api/v1/ws`. DEV: `ws://mempool-api-mutinynet:8999/api/v1/ws` on the DFX mempool/backend stack. Empty string is treated as unset. |
| `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 `<hex\|username>@<domain>` 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. |
Expand Down
Loading
Loading