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 and the research repo.
zkCoins follows the Bitcoin full-node model: your wallet trusts your node, exactly as a Bitcoin wallet trusts your own bitcoind. "Trusted node" means your node — never a third party. Running your own node is the trustless, private path, and it is the model the whole system is designed around. The node↔wallet split is packaging (a heavy validator process vs. a thin key-holder), not a trust boundary. The only line the node never crosses is the wallet's private key — that stays in the wallet.
This is a hard project rule. It shapes every design and implementation decision:
- Self-hosting gives you trustlessness and privacy at once. Your own node verifies your transactions and sees your plaintext — and you are the operator, so nothing leaks. The wallet must always be able to switch to a different node by changing a single configuration value.
- Using someone else's node is a trade-off you choose, not a flaw. A public operator can never steal, forge, or double-spend your coins — that is enforced cryptographically (recursive proofs + Bitcoin-anchored nullifiers). What a foreign operator can see is your privacy, and it can affect liveness — the same spectrum as using an Electrum/SPV server instead of your own Bitcoin node.
- The thin wallet and SDK are not a compromise. No anti-node logic: 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. Trustlessness comes from running your own node, not from bolting verification onto a thin client. Anything that exists to reduce trust in the node belongs node-side — or the answer is self-hosting.
- 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, zk-coins/sdk, zk-coins/app, and zk-coins/docs.
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| 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) |
Enable the repo's pre-push hook. It runs cargo fmt --check, cargo clippy
(all three feature scopes), and cargo check --workspace --all-features —
fast enough to stay out of the way (< 30 s warm) while catching lint and type
regressions before they reach CI.
git config core.hooksPath .githooksThe authoritative test + coverage gate runs in CI on a self-hosted M3 Ultra
runner pool, not in this hook (see CI/CD). You can bypass the hook with
git push --no-verify in genuine emergencies — CI is the real gate.
The state layer expects a PostgreSQL instance reachable at DATABASE_URL. For
ad-hoc work:
docker run --name zkcoins-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 -d postgres:17
export DATABASE_URL=postgres://postgres:dev@localhost:5432/postgres
# Apply migrations:
cargo install sqlx-cli --no-default-features --features rustls,postgres
cd node && sqlx migrate runThe db_tests spin up their own postgres:17 container via
testcontainers-modules; each test gets a UUID-named schema inside one shared,
reused container. The schema lives in node/migrations/*.sql and is
forward-only (no down migrations in the MVP).
cargo test -p node db -- --test-threads=8- Edition 2021,
opt-level = 3for dev (heavy crypto). cargo fmtbefore every commit.cargo clippy— treat warnings as errors.- No
unwrap()in production paths — use?orexpect("descriptive message"). - No
println!— usetracing::info!,tracing::warn!, etc.
| Item | Convention | Example |
|---|---|---|
| Crate | kebab-case | zkcoins-program-plonky2 |
| Module | snake_case | account_node |
| Struct | PascalCase | AccountState, CoinProof |
| Function | snake_case | process_block, send_coins |
| Constant | SCREAMING_SNAKE | ACCOUNT_NODE_ADDR |
// Good — propagate with context
let block = fetch_block(hash).map_err(|e| anyhow!("Failed to fetch block {}: {}", hash, e))?;
// Bad — panic in production
let block = fetch_block(hash).unwrap();- Workspace dependencies in root
Cargo.toml; individual crates reference{ workspace = true }. - Pin exact versions for security-critical crates (
bitcoin,sha2). plonky2 = "1.1.0"from crates.io; no[patch.crates-io]entries.
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.)
CI enforces this with a grep step in the Lint & Build job
(.github/workflows/ci.yaml):
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 \
| 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.
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.
node/
├── node/ # Axum REST API (router, account_node, state, scanner, publisher)
├── 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)
├── Dockerfile # Multi-stage Rust build (linux/arm64, FEATURES build-arg)
└── rust-toolchain # Pinned nightly date
When working inside program-plonky2/, read
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.
The HTTP surface is documented by an OpenAPI 3.x spec generated at compile
time from #[utoipa::path] annotations and #[derive(ToSchema)] impls — there
is no separately maintained YAML/JSON, so the wire contract and the docs cannot
drift. The spec is served at GET /openapi.json and rendered with bundled
Swagger UI at GET /docs (assets vendored, zero-CDN).
Adding an endpoint:
- Annotate the handler in
node/src/router.rswith#[utoipa::path(...)]; reuse the sibling endpoints'tag; enumerate every status code and bind it to a response schema; bump visibility topub(crate). - Derive
ToSchemaon every request/response struct. For foreign types (bitcoin::secp256k1::PublicKey, …) override at the use site with#[schema(value_type = String, example = "02a34b…")]. - Register the handler under
paths(...)and new schemas undercomponents(schemas(...))innode/src/openapi.rs. - Extend the network-free smoke test in
node/tests/openapi_smoke.rs(spec_lists_every_always_on_route,spec_registers_critical_schemas) — it runs on every PR and fails fast on wire-contract drift.
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.
| 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. 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. |
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. |
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. |
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 nodedocker build -t zkcoins/node .
docker run -p 4242:4242 --network bitcoin \
-e ESPLORA_URL=http://electrs-mainnet:3000 \
-e USERNAME_DOMAIN=zkcoins.app \
zkcoins/nodeDocker 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.
| 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 |
- Open feature PRs against
stagingby default — it is the integration buffer where feature branches accumulate before being batched into a singledeveloppromotion. (Repo-hygiene/cleanup PRs that target develop-only files may go directly todevelop; note the reason in the PR body.) developandmainare protected — no direct pushes, no force-pushes, no deletions.developis auto-PR'd fromstaging(auto-release-pr-staging.yaml,ci:fullapplied);mainis auto-PR'd fromdevelop(auto-release-pr.yaml).- Maintainers merge PRs; agents open them as drafts. Never force-push, never amend, never
--no-verifyon a real change.
English, concise, what not how:
# Good
Bind to 0.0.0.0 instead of 127.0.0.1 for Docker access
Decouple node from SP1: optional zkvm feature, stub prover
# Bad
fix build
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. |
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 |
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.
- zk-coins/app — Web application (frontend).
- zk-coins/docs — Documentation (docs.zkcoins.app).
- zk-coins/research — Protocol research, design drafts, upstream repos, paper PDFs.