This guide covers everything you need to develop, test, and deploy the zkCoins web application.
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/app.git
cd app
npm install
npm run dev # http://localhost:3090| Tool | Version | Purpose |
|---|---|---|
| Node.js | 20+ | Runtime |
| npm | 10+ | Package manager |
| Rust | 1.81+ | WASM crypto module (optional, JS fallback available) |
| LLVM | 21+ with wasm32 target | secp256k1 C compilation for WASM |
app/
├── src/
│ ├── app/ # Next.js App Router (layout, pages)
│ ├── components/ # React components
│ │ ├── Header.tsx
│ │ ├── WalletCard.tsx
│ │ ├── SendForm.tsx
│ │ ├── TransactionLog.tsx
│ │ ├── SeedPhraseSetup.tsx
│ │ ├── SeedPhraseImport.tsx
│ │ ├── SetPassword.tsx
│ │ ├── UnlockWallet.tsx
│ │ ├── PasskeySetup.tsx
│ │ └── Footer.tsx
│ ├── hooks/ # React hooks
│ │ └── useZkCoins.ts # WASM integration
│ ├── lib/
│ │ ├── api/ # REST API client (backend communication)
│ │ └── crypto/ # Encryption, key derivation, passkey, storage
│ └── stores/ # Zustand state management
│ ├── auth.ts # Auth flow state
│ ├── network.ts # API URL, network name
│ └── wallet.ts # Account, encrypted persistence
├── packages/
│ └── zkcoins-wasm/ # TypeScript wrapper for Rust WASM module
│ └── src/
│ └── index.ts # WASM API surface + JS fallback
├── rust/
│ └── client/ # Rust WASM crate (BIP32, Schnorr, secp256k1)
├── public/ # Static assets, PWA manifest, service worker
├── Dockerfile # Multi-stage Next.js build
├── entrypoint.sh # Runtime env var injection (DEV/PRD)
└── next.config.js # WASM support, standalone output
The App is a thin client. All authoritative state lives on the node.
The App's only responsibilities are:
- Private key custody — generate / restore the BIP-32 master xpriv, store it encrypted on the device, sign messages locally with WASM crypto helpers. The xpriv never leaves the device.
- UI rendering — present what the node returns.
Every other piece of state — balance, send-counter (num_sends), transaction history, server capabilities, account proofs, commitment lookups — is owned by zk-coins/node. The App MUST fetch the authoritative value from the node before any operation that depends on it, and MUST NOT maintain a parallel local source of truth that can drift.
- Before any signed request (
/api/send,/api/commit,/api/username/claim): callapi.balance(address)first and use the response'snum_sendsto drive BIP-32 derivation. Readingaccount.numPubkeysfrom the Zustand store is a bug — the store resets to 0 on every fresh tab, page reload, and Playwright retry; the node's counter is the only value that survives. - The Zustand wallet store holds the cryptographic identity (
xpriv,address) and transient UI state only. It must NOT hold a copy of balance-truth, send-counter-truth, transaction-history-truth, or feature-capability-truth. Those come from/api/balance(balance,num_sends),/api/info(capabilities), and (when persistence is needed) the node'srequest_log/account_historytables surfaced through dedicated endpoints. - New features go server-side first. If a UI flow needs information the node doesn't already expose, the correct sequence is: add the endpoint to
zk-coins/node, deploy it to DEV, then consume it in the App. Implementing the logic in the App and "syncing later" is what produced theprove_account_update failedclass of bugs. - Validation, derivation, formatting that affect protocol-level decisions belong in the WASM crypto layer (
rust/client) or the node, not in React components. Components are render-only. - Drift between App and node: the node always wins. The App syncs on the next operation.
The May 2026 07-send-success E2E failure is the canonical incident. The App used a local numPubkeys counter from the Zustand store; that counter resets to 0 on every fresh page load. After a successful first send (server-side num_sends → 1), every Playwright retry / fresh-tab session signed the next send with pubkey(0) instead of pubkey(1), violating the in-circuit account-update continuity constraint at program-plonky2/src/circuit/main.rs:615-623. Three server-side fixes (Account.num_sends counter, server-owned commitment_public_key, canonical 64-byte SMT value) all shipped before anyone noticed that the App was still signing with the wrong index. The thin-client rule exists so this class of bug cannot recur: if every signed operation hydrates num_sends from /api/balance immediately before signing, the local store can never drift far enough to matter.
The api_remote suite (zk-coins/node/node/tests/api_remote.rs::TestWallet) threads the BIP-32 index explicitly into every signed request and is therefore immune to the bug — that pattern is the reference implementation for any App-side signed flow.
| Branch | Purpose | Deploy target |
|---|---|---|
staging |
Integration buffer — feature PRs land here first | none |
develop |
Active development, promoted from staging in batches |
DEV server |
main |
Production releases, promoted from develop |
PRD server |
- Open feature PRs against
staging(notdevelop) —stagingis the integration buffer where multiple feature branches accumulate before being batched into a singledeveloppromotion. This keepsdevelopclean for DEV-deploy churn and gives reviewers a smaller blast radius per merge. developandmainare protected — direct pushes are rejected.developaccepts only the auto-PR fromstaging;mainaccepts only the auto-PR fromdevelop. Hotfixes still go throughstagingso the same review path applies.developis auto-PR'd fromstagingbyauto-release-pr-staging.yamlwhenever new commits land onstaging. Merge that PR to promote the batch to DEV.mainis auto-PR'd fromdevelopbyauto-release-pr.yaml. Merge to release to PRD.- Never force-push, never amend published commits.
Write in English. Be concise. Describe what changed, not how.
# Good
Add PWA support: manifest, service worker, icons
Fix runtime env var injection with build-time placeholders
Use DEPLOY_DEV_/DEPLOY_PRD_ secret naming convention
# Bad
update stuff
WIP
fix
- Strict mode —
strict: truein tsconfig - Functional components — no class components
'use client'directive on all components that use hooks, state, or browser APIs- No
console.login committed code - Named exports for components, default exports only for pages
- ESLint:
next lint(Next.js default rules) - Prettier: single quotes, trailing commas, 100 char width
- Run before every commit:
npm run lint # ESLint + Prettier check
npm run lint:fix # Auto-fix// 1. React/Next.js
import { useState, useCallback } from 'react';
// 2. Third-party
import { create } from 'zustand';
// 3. Internal (absolute paths via @/)
import { useWalletStore } from '@/stores/wallet';
import { api } from '@/lib/api/client';
// 4. WASM
import { initWasm } from '@zkcoins/wasm';'use client';
import { useCallback } from 'react';
import { useWalletStore } from '@/stores/wallet';
export function MyComponent() {
const { account } = useWalletStore();
const handleAction = useCallback(async () => {
// ...
}, []);
if (!account) return null;
return (
<div className="rounded-xl border border-zkcoins-border bg-zkcoins-card p-6">
{/* content */}
</div>
);
}- Tailwind CSS only — no CSS files, no styled-components
- Dark theme — use
zkcoins-*custom colors fromtailwind.config.ts - Bitcoin orange —
bg-bitcoin,text-bitcoin,hover:bg-bitcoin-dark - Consistent spacing —
p-6for cards,gap-3for form fields,space-y-6for sections
| Color | Tailwind class | Hex |
|---|---|---|
| Background | bg-zkcoins-bg |
#0a0a0a |
| Card | bg-zkcoins-card |
#141414 |
| Border | border-zkcoins-border |
#1f1f1f |
| Text | text-zkcoins-text |
#e5e5e5 |
| Muted | text-zkcoins-muted |
#737373 |
| Bitcoin Orange | bg-bitcoin |
#f7931a |
- Zustand for transient UI state and the cryptographic identity only — see the Thin Client rule above for what must NOT live in the store.
- Encrypted IndexedDB persistence via
saveEncryptedWallet()/loadEncryptedWallet()(AES-GCM) — for the xpriv and the unlocked-state flags, not for server-owned values. - No React Context for state — Zustand stores are global singletons.
- Wallet store fields:
account(xpriv,address),isLoading,isLocked,hasStoredWallet,storedAddress,storedAuthMethod,error. Anything that the node can recompute (balance,num_sends, transaction history) is read from the node on demand, not cached as ground truth in the store.
All backend communication goes through src/lib/api/client.ts:
import { api } from '@/lib/api/client';
await api.mint(address);
await api.send({ account_address, recipient, amount, public_key, next_public_key });
const { balance } = await api.balance(address);Never call fetch() directly — always use the api object.
The WASM module provides crypto operations (BIP32, Schnorr). It loads asynchronously with a JS fallback:
import { initWasm } from '@zkcoins/wasm';
const wasm = await initWasm();
const account = await wasm.createAccount();- WASM cannot run during SSR — all WASM usage must be in
'use client'components - The
useZkCoinshook handles WASM initialization
Only needed if you change rust/client/:
# Requires Rust + LLVM with wasm32 target
cd rust/client
CC="/opt/homebrew/opt/llvm/bin/clang" AR="/opt/homebrew/opt/llvm/bin/llvm-ar" \
cargo build --target wasm32-unknown-unknown --release
wasm-bindgen --out-dir ../../packages/zkcoins-wasm/src/pkg --target web \
../target/wasm32-unknown-unknown/release/client.wasmThe app runs as a standalone Next.js container:
docker build -t zkcoins/app .
docker run -p 3090:3090 \
-e NEXT_PUBLIC_API_URL=https://api.zkcoins.app \
-e NEXT_PUBLIC_EXPLORER_URL=https://zkcoins.space \
zkcoins/appEnvironment variables are injected at runtime via entrypoint.sh — the same image works for DEV and PRD.
The Dockerfile sets placeholder values at build time (NEXT_PUBLIC_API_URL_PLACEHOLDER). The entrypoint.sh replaces them with actual values at container start. This pattern allows one image for multiple environments.
| Workflow | Trigger | Action |
|---|---|---|
ci.yaml |
Push to develop, PR | Lint + Build |
deploy-dev.yaml |
Push to develop | Docker build → push zkcoins/app:beta → deploy to DEV |
deploy-prd.yaml |
Push to main | Docker build → push zkcoins/app:latest → deploy to PRD |
auto-release-pr-staging.yaml |
Push to staging | Creates Promote PR (staging → develop) |
auto-release-pr.yaml |
Push to develop | Creates Release PR (develop → main) |
Always run locally:
npm run lint # Must pass
npm run build # Must succeedNever push if lint or build fails.
The app is a Progressive Web App:
public/manifest.json— app metadata, icons, theme colorpublic/sw.js— service worker (cache-first for assets, network-first for API)public/icons/— 192px and 512px icons
Changes to the service worker require incrementing CACHE_NAME in sw.js.
- zk-coins/node — Rust backend (API)
- zk-coins/docs — Documentation (docs.zkcoins.app)