Online creation checks whether the exact proposal already exists before requesting a signature, and again when assessing a stale transaction. The recovery panel offers View proposal so another owner can approve the existing proposal. Propose and approve recheck their indexed witnesses against fresh vault state after the proposal signature, before building the transaction.
Initial verified-store mismatches show “Vault data isn’t up to date” with Retry / Cancel. Retry restarts online preparation or offline request export from current state and indexed stores; it never bypasses root validation or retries automatically. Network and unrelated errors are not classified as store mismatches.
Prepared transactions are checked against current on-chain state before broadcast (before wallet handoff for Auro). The UI shows “Checking latest vault state…” before Auro handoff, then “Waiting for wallet confirmation…”. Stale transactions stop with an explicit recovery action. See transaction preflight.
MinaGuard is a multisig wallet zkApp for Mina built with o1js, plus a Next.js UI and an Express indexer API.
contracts/- MinaGuard smart contract, stores, and tests.backend/- Express + Prisma + PostgreSQL read API and chain indexer.ui/- Next.js app with Auro wallet integration and on-chain actions.
- Propose -> approve -> execute lifecycle using proposal hash keyed approvals.
- Optional proposal deadlines apply to proposing, approving, and executing. Zero means no expiry; other values must fit in 32 bits, and inclusion at the deadline is allowed.
- Transfer, add/remove owner, threshold change, and delegate execution support.
- For transfers to new recipient accounts, the executor pays the account-creation cost and transaction fee; see the accepted risk.
- Indexed read API for contracts, owners, proposals, approvals, and raw events.
- Deploy + setup UI flow with a creation-time local deploy-key download. The browser does not persist the key; the creator may retain the downloaded copy. A Mina transaction-version upgrade can make that key sufficient to replace the vault verification key without owner approval. If a fork breaks old proofs and no backup exists, funds may become inaccessible. See the accepted risk.
- For an older-version vault whose VK still matches this release, the detail page guides owners to submit an ordinary proved action and check that Mina refreshed the stored version. This works only if Mina accepts the existing proof. Any fee payer can instead submit a no-op vault update that ends the deploy-key fallback, so a proved action is the safer compatibility check. If the installed VK differs from the reviewed replacement, the detail page can export a version 2 VK migration request. The MinaGuard offline CLI compiles and checks the replacement VK, signs with the saved deploy key and an offline fee payer key, and returns a transaction for the UI to validate and broadcast. If proofs break, that migration must land before any other vault update, including a third-party no-op. A future fork still needs a reviewed compatible release and per-network VK hash before migration is attempted.
- Child reservations validate governance bounds and initialized root-parent state; successful child setup clears the consumed reservation hash.
- Child allocations require initialized recipients bound to the sending parent. Complete child setup before funding; ordinary transfers and external deposits to uninitialized children remain unrecoverable until setup succeeds. See safe child funding.
Packages that import o1js pin stable o1js@3.0.0 directly for proving, decoding, or tests. MinaGuard uses 13 on-chain state slots, so it requires the Mesa 32-slot protocol. 3.0.0-mesa.final was pinned before stable 3.0.0 was published; the stable package compiles the same testnet and mainnet verification keys for this circuit. The UI, backend lightnet helper, and offline CLI pin mina-signer@4.1.0 for signing and transaction commitments. Signing and proving compatibility is covered by the cross-network tests; the signer is a separate package from the prover.
When changing o1js, compile and compare both network verification-key hashes with the current release, run a genuine-proof test and signing checks, then publish the generated VK manifest for the new build. If either hash changes, update deployment and offline CLI fingerprints together before users sign or broadcast transactions against the new circuit.
Run the complete offline CLI end-to-end suite with real proofs before merging a prover or signer dependency change; a SKIP_PROOFS=1 pass does not satisfy this gate.
bun install
# Build contracts (required by backend and UI)
bun run --filter contracts build
# Set up the backend environment
cp backend/.env.example backend/.env
# Generate Prisma client
cd backend && bunx prisma generate && cd ..dev-helpers/lightnet-up.sh starts a standalone Mesa lightnet on the host (ports 8080 GraphQL, 8282 archive, 8181 account manager, 5432 Postgres). It first frees those host ports by stopping any colliding containers (zkao-postgres-dev, local-lightnet-1) and records what it stopped:
./dev-helpers/lightnet-up.shdev-helpers/lightnet-down.sh stops lightnet and restarts whatever the up script paused, so you're back where you started:
./dev-helpers/lightnet-down.shOnce lightnet is running, accounts must have funds before they can submit transactions. Two ways:
- From the UI — once a wallet is connected on a testnet network, the header shows a "Fund" button that calls the backend's
/api/fundroute (which in turn drips from the lightnet account manager). - From the CLI — add public keys to
dev-helpers/.envand run:cd dev-helpers && bun run cli.ts lightnet-fund
NOTE: To test with a Ledger device, its public key (corresponding to the account index used) must be funded similarly. Only the public key is needed.
Lightnet resets: lightnet sometimes stops or wipes its chain state without warning. When that happens the backend's indexed view (contracts, proposals, etc.) no longer matches the live chain, so wipe the DB to start clean:
cd backend && bunx prisma db push --force-reset --skip-generateThen restart lightnet (./dev-helpers/lightnet-up.sh) and the backend.
To run in browser:
# Run UI (from ui/ directory)
cd ui && bun run dev
# Run backend API/indexer (from backend/ directory)
cd backend && bun run dev
# Run contract tests (choose the circuit domain explicitly)
MINA_NETWORK_DOMAIN=testnet bun run --filter contracts testTo run the Electron app:
# Build UI (from ui/ directory; use the target network's domain)
cd ui && NEXT_PUBLIC_MINA_NETWORK=testnet bun run build
# Run backend API/indexer (from backend/ directory)
cd backend && bun run dev
# Run the Electron desktop app
cd desktop && bun run devFull end-to-end tests live in e2e/ and exercise the deploy → propose → approve → execute lifecycle against a real Mina network. See e2e/README.md for setup details.
# Quick start with local lightnet (default)
bun run test:e2e
# Against Mina devnet (requires funded accounts in e2e/.env.devnet)
NETWORK=devnet bun run test:e2e
# Fast UI bundle-export and desktop network checks (also run in CI)
bun run --filter ui test
bun run --filter desktop testbun run --filter contracts build
bun run --filter backend build
NEXT_PUBLIC_MINA_NETWORK=testnet bun run --filter ui buildCI computes a fingerprint of the tracked circuit inputs. It compiles testnet
and mainnet in parallel when their cached hashes are missing, then publishes a
minaguard-vk-manifest artifact for the exact source commit. It contains
contracts/.vk-hash with testnet, mainnet, and devnet entries; devnet uses the
testnet circuit. Release and deploy jobs verify the artifact's commit before
using its network hash for the backend, UI, or desktop bundle. Local builds do
not need to compile the circuit just to update a committed hash file.
The testnet runner proves parent-state paths and, when present, proposal-signing
paths in separate steps so each has its own timeout. The mainnet runner proves
child reservation and setup, plus the proposal expiry lifecycle. The offline
CLI propose regression allows 15 minutes for a cold compile plus proof, and
its platform CI step allows 25 minutes for the selected tests. These proof
tests still run on every PR check. A VK cache miss recompiles the circuit,
including after cache expiry or a merge to a branch that cannot access the
PR's cache.
Each PR targeting main gets an isolated preview stack deployed to the Hetzner server via a self-hosted GitHub Actions runner. Preview URLs follow the pattern https://mina-nodes.duckdns.org/preview/<PR_NUMBER>/.
Each stack includes: lightnet, PostgreSQL, backend, frontend, block explorer, and a Caddy reverse proxy.
# From repo root
./preview-env/preview.sh up <PR_NUMBER> # deploy
./preview-env/preview.sh down <PR_NUMBER> # teardown
./preview-env/preview.sh list # show active previewsYou can run the full stack locally without the server's Caddy:
./preview-env/local-preview.sh up 1Access at https://localhost:10001/preview/1/. In Auro Wallet, set the network URL to https://localhost:10001/preview/1/graphql.
Caddy serves this over HTTPS with a self-signed cert (tls internal) and sets the COOP/COEP headers o1js needs — accept the cert warning on first visit. The CA persists in the caddy-local-data volume so the cert stays stable across restarts.
The helper builds backend, frontend, and explorer sequentially before starting the stack. This avoids the RAM spike from docker compose up -d --build, which can try to build all three images at once.
The first uncached frontend build can take a few minutes because Next.js has to compile the heavy o1js worker bundle. local-preview.sh now prints a periodic heartbeat during long builds so this does not look like a freeze.
Frontend changes do not hot-reload in this Docker preview flow. The container runs a built next start app, so after changing files under ui/, rebuild the frontend image and restart only that service:
PR_NUMBER=1 PREVIEW_PORT=10001 docker compose \
-f preview-env/docker-compose.preview.yml \
-f preview-env/docker-compose.local.yml \
-p local \
build frontend
PR_NUMBER=1 PREVIEW_PORT=10001 docker compose \
-f preview-env/docker-compose.preview.yml \
-f preview-env/docker-compose.local.yml \
-p local \
up -d --no-deps frontendbuild frontend picks up your latest UI code. up -d --no-deps frontend recreates just the frontend container without restarting backend, lightnet, explorer, or Caddy.
To seed coherent test data, prefer the real on-chain fixture helper over direct DB inserts:
MINA_NETWORK_DOMAIN=testnet bun run dev-helpers/cli.ts lightnet-fixture --main-address <YOUR_WALLET_ADDRESS>By default this uses the quick-test minimal scenario: 2 vaults, 2 executed proposals per vault, and both vaults ending with your wallet as the sole owner at threshold 1. For broader coverage, you can also use --scenario full.
That command acquires a funded lightnet deployer, generates helper signers, deploys real MinaGuard contracts that include your wallet address as an owner, submits real propose / approve / execute transactions with proofs disabled, and waits for the preview indexer to ingest them. The result is fixture data that stays consistent across chain, backend, and UI.
# Logs
docker compose -p local logs -f # all services
docker compose -p local logs -f frontend # frontend only
docker compose -p local logs -f backend # backend/indexer
docker compose -p local logs -f lightnet # mina node + archive
# Tear down
./preview-env/local-preview.sh down 1When you want the heavy services (lightnet, backend, frontend dev server) to run on the server but iterate from your laptop's browser + Auro wallet.
On the server, follow First-time setup and Running so lightnet, backend, and the UI dev server are listening on the default localhost:* ports. backend/.env and ui/.env.local need no changes.
On the laptop, open one SSH tunnel that forwards every port the bundle references:
ssh -L 3000:localhost:3000 \
-L 3001:localhost:3001 \
-L 8080:localhost:8080 \
-L 8282:localhost:8282 \
user@serverThen open http://localhost:3000 in the browser. The frontend bundle has localhost:* baked in for the backend / Mina / archive URLs; both the server-side dev server and the laptop-side browser resolve localhost to themselves, so the URLs work on both ends as long as the tunnel is up.
Notes:
- Add a custom network in Auro Wallet pointing at
http://localhost:8080/graphqland switch to it before connecting. - Tunnel dies → the page errors. Run the SSH command inside
tmux/screenif you want it sticky.
Requests hit the main Caddy (TLS + COOP/COEP headers) which reverse-proxies to a per-preview Caddy container that routes to individual services. COOP/COEP headers are set at the main Caddy level and upstream copies are stripped to prevent duplicates.
Preview routes are managed via the Caddy admin API (localhost:2019) — no sudo required. The self-hosted runner only needs Docker access (docker group).
- SharedArrayBuffer: o1js WASM requires
crossOriginIsolated, which needs COOP + COEP headers over HTTPS. Do not addCross-Origin-Resource-Policy: same-origin— it blocks o1js blob URL sub-workers. - Bun workspaces: keep the exact
o1jsandmina-signerpins inbun.lockwhen building UI, backend, and offline CLI artifacts. - Minification disabled: SWC/terser mangle BigInt ops used by o1js.
- Server limits: ~2GB RAM per preview stack, max 2–3 concurrent previews on the 30GB server. Run
docker image prune -fperiodically.
# Pull previous working lightnet (non-MESA) docker image
docker pull 'o1labs/mina-local-network@sha256:746190ff2f556f252b7f50215ae60d4a5e786c8adc16f27986e3e35ce6105949'
# Verify it was pulled
docker inspect 'o1labs/mina-local-network@sha256:746190ff2f556f252b7f50215ae60d4a5e786c8adc16f27986e3e35ce6105949' --format '{{.Id}} {{.RepoTags}}'
# Tag it as a distinct image
docker tag 'o1labs/mina-local-network@sha256:746190ff2f556f252b7f50215ae60d4a5e786c8adc16f27986e3e35ce6105949' o1labs/mina-local-network:known-good
# Add a second tag, the one zk will look for
docker tag 'o1labs/mina-local-network@sha256:746190ff2f556f252b7f50215ae60d4a5e786c8adc16f27986e3e35ce6105949' o1labs/mina-local-network:compatible-latest-lightnet
# Confirm no stale state
zk lightnet stop --clean-up
# Start lightnet WITHOUT pulling latest
zk lightnet start --pull=false
The signing worker saves public owner/approval/nullifier checkpoints locally and fetches only newer event blocks on later operations using the existing backend block filters and offset pagination. It checks the resulting roots against the Mina node and falls back to one full replay on mismatch. Initial sync still needs complete history; cold restoration rehashes saved leaves. Clearing browser storage loses only this optimization, not signing keys. Ranges exceeding the existing pagination cap fail closed; cursor pagination is tracked in issue #143.
Offline requests exported by this UI use version 2 with a complete public
store snapshot. Proposals use application-tagged hashes, distinct
propose/approve signing messages, and length-prefixed memo commitments (including
empty memos). Owner-chain links, vote-nullifier keys, and child configuration
hashes also have separate tags. Use matching CLI/UI/backend/desktop builds and
new network VKs.
Owner requests use v2 and their signed responses use v1. Discard older files:
the signed-response version alone does not distinguish old files from current
ones, and proposals now
sign the native MINA token ID Field(1).
This breaking change requires fresh vaults and recreated proposals.
See the offline audit guide for migration and trust
boundaries. Desktop packages the same worker and exporter.